diff --git a/cspell.json b/cspell.json
index 236d02cc..087ede4e 100644
--- a/cspell.json
+++ b/cspell.json
@@ -98,6 +98,7 @@
"minlength",
"monospace",
"mousedown",
+ "mousemove",
"mouseup",
"multiselectable",
"nextjs",
diff --git a/docs/pages/components/popup.md b/docs/pages/components/popup.md
index 5a35c0c6..f378a539 100644
--- a/docs/pages/components/popup.md
+++ b/docs/pages/components/popup.md
@@ -1511,3 +1511,178 @@ const App = () => {
);
};
```
+
+### Virtual Elements
+
+In most cases, popups are anchored to an actual element. Sometimes, it can be useful to anchor them to a non-element. To do this, you can pass a `VirtualElement` to the anchor property. A virtual element must contain a function called `getBoundingClientRect()` that returns a [`DOMRect`](https://developer.mozilla.org/en-US/docs/Web/API/DOMRect) object as shown below.
+
+```ts
+const virtualElement = {
+ getBoundingClientRect() {
+ // ...
+ return { width, height, x, y, top, left, right, bottom };
+ }
+};
+```
+
+This example anchors a popup to the mouse cursor using a virtual element. As such, a mouse is required to properly view it.
+
+```html:preview
+
+
+
+
+
+ Highlight mouse cursor
+
+
+
+
+
+```
+
+```jsx:react
+import { useRef, useState } from 'react';
+import { SlPopup, SlSwitch } from '@shoelace-style/shoelace/dist/react';
+
+const css = `
+ /* If you need to set a z-index, set it on the popup part like this */
+ .popup-virtual-element sl-popup::part(popup) {
+ z-index: 1000;
+ pointer-events: none;
+ }
+
+ .popup-virtual-element .circle {
+ width: 100px;
+ height: 100px;
+ border: solid 4px var(--sl-color-primary-600);
+ border-radius: 50%;
+ translate: -50px -50px;
+ animation: 1s virtual-cursor infinite;
+ }
+
+ @keyframes virtual-cursor {
+ 0% { scale: 1; }
+ 50% { scale: 1.1; }
+ }
+`;
+
+const App = () => {
+ const [enabled, setEnabled] = useState(false);
+ const [clientX, setClientX] = useState(0);
+ const [clientY, setClientY] = useState(0);
+ const popup = useRef(null);
+ const circle = useRef(null);
+ const virtualElement = {
+ getBoundingClientRect() {
+ return {
+ width: 0,
+ height: 0,
+ x: clientX,
+ y: clientY,
+ top: clientY,
+ left: clientX,
+ right: clientX,
+ bottom: clientY
+ };
+ }
+ };
+
+ // Listen for the mouse to move
+ document.addEventListener('mousemove', handleMouseMove);
+
+ // Update the virtual element as the mouse moves
+ function handleMouseMove(event) {
+ setClientX(event.clientX);
+ setClientY(event.clientY);
+
+ // Reposition the popup when the virtual anchor moves
+ if (popup.active) {
+ popup.current.reposition();
+ }
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+```
diff --git a/docs/pages/resources/changelog.md b/docs/pages/resources/changelog.md
index 88a5c73d..00ca8b2e 100644
--- a/docs/pages/resources/changelog.md
+++ b/docs/pages/resources/changelog.md
@@ -16,6 +16,7 @@ New versions of Shoelace are released as-needed and generally occur when a criti
- Added tests for `` [#1416]
- Added support for pressing [[Space]] to select/toggle selected `` elements [#1429]
+- Added support for virtual elements in `` [#1449]
- Fixed a bug in focus trapping of modal elements like ``. We now manually handle focus ordering as well as added `offsetParent()` check for tabbable boundaries in Safari. Test cases added for `` inside a shadowRoot [#1403]
- Fixed a bug in `valueAsDate` on `` where it would always set `type="date"` for the underlying `` element. It now falls back to the native browser implementation for the in-memory input. This may cause unexpected behavior if you're using `valueAsDate` on any input elements that aren't `type="date"`. [#1399]
- Fixed a bug in `` where the `background` attribute was never passed to the QR code [#1416]
diff --git a/scripts/build.js b/scripts/build.js
index 315d70ea..463bcd45 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -263,13 +263,16 @@ if (serve) {
});
// Rebuild and reload when source files change
- bs.watch(['src/**/!(*.test).*']).on('change', async filename => {
+ bs.watch('src/**/!(*.test).*').on('change', async filename => {
+ console.log('updated file: ', filename);
+
try {
const isTheme = /^src\/themes/.test(filename);
const isStylesheet = /(\.css|\.styles\.ts)$/.test(filename);
// Rebuild the source
- await Promise.all([buildResults.map(result => result.rebuild())]);
+ const rebuildResults = buildResults.map(result => result.rebuild());
+ await Promise.all(rebuildResults);
// Rebuild stylesheets when a theme file changes
if (isTheme) {
diff --git a/src/components/popup/popup.ts b/src/components/popup/popup.ts
index f2ac9fb0..205a501f 100644
--- a/src/components/popup/popup.ts
+++ b/src/components/popup/popup.ts
@@ -35,11 +35,20 @@ import type { CSSResultGroup } from 'lit';
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
* available when using `auto-size`.
*/
+
+export interface VirtualElement {
+ getBoundingClientRect: () => DOMRect;
+}
+
+function isVirtualElement(e: unknown): e is VirtualElement {
+ return e !== null && typeof e === 'object' && 'getBoundingClientRect' in e;
+}
+
@customElement('sl-popup')
export default class SlPopup extends ShoelaceElement {
static styles: CSSResultGroup = styles;
- private anchorEl: Element | null;
+ private anchorEl: Element | VirtualElement | null;
private cleanup: ReturnType | undefined;
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@@ -47,10 +56,11 @@ export default class SlPopup extends ShoelaceElement {
@query('.popup__arrow') private arrowEl: HTMLElement;
/**
- * The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide its `id` or a
- * reference to it here. If the anchor lives inside the popup, use the `anchor` slot instead.
+ * The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide the anchor
+ * element `id`, a DOM element reference, or a `VirtualElement`. If the anchor lives inside the popup, use the
+ * `anchor` slot instead.
*/
- @property() anchor: Element | string;
+ @property() anchor: Element | string | VirtualElement;
/**
* Activates the positioning logic and shows the popup. When this attribute is removed, the positioning logic is torn
@@ -224,7 +234,7 @@ export default class SlPopup extends ShoelaceElement {
// Locate the anchor by id
const root = this.getRootNode() as Document | ShadowRoot;
this.anchorEl = root.getElementById(this.anchor);
- } else if (this.anchor instanceof Element) {
+ } else if (this.anchor instanceof Element || isVirtualElement(this.anchor)) {
// Use the anchor's reference
this.anchorEl = this.anchor;
} else {