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 + + + + + +``` + +```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 ( + <> +
+ +
+ + + setEnabled(event.target.checked)}> + Highlight mouse cursor + +
+ + + + ); +}; +``` 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 {