Skip to content

Anchored Popover

Similar to the Popover component, but this one is anchored to a virtual reference element. This is used in features such as floating toolbars.

Here, you'll need to create a virtual element because text selection and mouse coordinates don't have a DOM element associated with them. So you'll need to create a virtual element that responds to getBoundingClientRect(), retrieve the coordinates of the selection or mouse coordinates, and then position the popover relative to that.

Live islandRuns locally in this page

Page shell

<h1>Anchored Popover</h1>
<p>An anchored popover relative to any coordinate. In this example, we're usign the mouse position as the reference element.</p>
<cami-anchored-popover></cami-anchored-popover>
<style>
  h1, p {
    text-align: center;
  }
  .popover__backdrop {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    display: flex;
    justify-content: center;
    align-items: center;
  }
  .popover__backdrop--hidden {
    display: none;
  }
</style>
<!-- <script src="./build/cami.cdn.js"></script> -->
<!-- CDN version below -->
<script src="https://unpkg.com/cami@0.4/build/cami.cdn.js"></script>
<script type="module" src="./island.js"></script>

Island source

const { html, ReactiveElement } = cami
class AnchoredPopoverElement extends ReactiveElement {
    isOpen = false;
    point = { x: 0, y: 0 };
    anchor = null;
    openAt(point) {
        this.point = point;
        this.anchor = {
            getBoundingClientRect: () => new DOMRect(point.x, point.y, 0, 0),
        };
        this.isOpen = true;
    }
    close() {
        this.isOpen = false;
        this.anchor = null;
    }
    handlePointerMove(event) {
        this.point = { x: event.clientX, y: event.clientY };
    }
    handlePointerDown(event) {
        this.openAt({ x: event.clientX, y: event.clientY });
    }
    get positionStyle() {
        const rect = this.anchor?.getBoundingClientRect();
        if (!rect)
            return '';
        return `position:fixed;left:${rect.x}px;top:${rect.y - 8}px;transform:translate(-50%, -100%)`;
    }
    template() {
        return html `
      <section
        class="anchor-surface"
        @pointermove=${(event) => this.handlePointerMove(event)}
        @pointerdown=${(event) => this.handlePointerDown(event)}
      >
        Click anywhere to anchor the popover. Pointer: ${this.point.x}, ${this.point.y}
      </section>
      ${this.isOpen ? html `
        <aside role="dialog" class="popover" style=${this.positionStyle}>
          <p>Virtual anchor at ${this.point.x}, ${this.point.y}</p>
          <button @click=${() => this.close()}>Close</button>
        </aside>
      ` : ''}
    `;
    }
}
customElements.define('cami-anchored-popover', AnchoredPopoverElement);
import { html, ReactiveElement } from 'cami'

interface Point {
  x: number
  y: number
}

interface VirtualAnchor {
  getBoundingClientRect(): DOMRect
}

class AnchoredPopoverElement extends ReactiveElement {
  isOpen: boolean = false
  point: Point = { x: 0, y: 0 }
  anchor: VirtualAnchor | null = null

  openAt(point: Point): void {
    this.point = point
    this.anchor = {
      getBoundingClientRect: (): DOMRect => new DOMRect(point.x, point.y, 0, 0),
    }
    this.isOpen = true
  }

  close(): void {
    this.isOpen = false
    this.anchor = null
  }

  handlePointerMove(event: PointerEvent): void {
    this.point = { x: event.clientX, y: event.clientY }
  }

  handlePointerDown(event: PointerEvent): void {
    this.openAt({ x: event.clientX, y: event.clientY })
  }

  get positionStyle(): string {
    const rect = this.anchor?.getBoundingClientRect()
    if (!rect) return ''
    return `position:fixed;left:${rect.x}px;top:${rect.y - 8}px;transform:translate(-50%, -100%)`
  }

  template(): ReturnType<typeof html> {
    return html`
      <section
        class="anchor-surface"
        @pointermove=${(event: PointerEvent) => this.handlePointerMove(event)}
        @pointerdown=${(event: PointerEvent) => this.handlePointerDown(event)}
      >
        Click anywhere to anchor the popover. Pointer: ${this.point.x}, ${this.point.y}
      </section>
      ${this.isOpen ? html`
        <aside role="dialog" class="popover" style=${this.positionStyle}>
          <p>Virtual anchor at ${this.point.x}, ${this.point.y}</p>
          <button @click=${() => this.close()}>Close</button>
        </aside>
      ` : ''}
    `
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cami-anchored-popover': AnchoredPopoverElement
  }
}

customElements.define('cami-anchored-popover', AnchoredPopoverElement)