Skip to content

Popover

This inherits from the Modal component, so it has all the same features, but it's a bit more complex as it has to position itself relative to the element that triggered it.

This also demonstrates how you'd want to build & reuse components. The DemoPopoverElement defines just the properties you'd want: the reference element, the placement, and the template.

It inherits from ReactivePopoverElement, which has all the logic for positioning the popover relative to the reference element. And this inherits from ReactiveModalElement, which has all the logic for the modal behavior & accessibility.

Note that if you want a background click to close the popover, you'll need to define a backdrop div and add a click handler to it that calls this.closeDialog(). See the Modal component for an example.

Live islandRuns locally in this page

Page shell

<h1>Popover</h1>
<p>When you click on either the top / bottom / left / right buttons, a popover will appear in the corresponding position against the reference element.</p>
<cami-popover></cami-popover>
<style>
  h1, p {
    text-align: center;
  }
  .popover__backdrop { // unused in this example
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    display: flex;
    justify-content: center;
    align-items: center;
  }
  .popover__backdrop--hidden { // unused in this example
    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 ReactiveModalElement extends ReactiveElement {
    isOpen = false;
    lastFocusedElement = null;
    openDialog() {
        this.lastFocusedElement = document.activeElement instanceof HTMLElement
            ? document.activeElement
            : null;
        this.isOpen = true;
    }
    closeDialog() {
        this.isOpen = false;
        this.lastFocusedElement?.focus();
    }
    onConnect() {
        this.addEventListener('keydown', (event) => {
            if (event.key === 'Escape')
                this.closeDialog();
        });
    }
}
class ReactivePopoverElement extends ReactiveModalElement {
    placement = 'top';
    anchor = null;
    openAt(anchor, placement) {
        this.anchor = anchor;
        this.placement = placement;
        this.openDialog();
    }
    get positionStyle() {
        if (!this.anchor)
            return '';
        const anchor = this.anchor.getBoundingClientRect();
        const gap = 8;
        const positions = {
            top: { left: anchor.left + anchor.width / 2, top: anchor.top - gap, transform: 'translate(-50%, -100%)' },
            right: { left: anchor.right + gap, top: anchor.top + anchor.height / 2, transform: 'translateY(-50%)' },
            bottom: { left: anchor.left + anchor.width / 2, top: anchor.bottom + gap, transform: 'translateX(-50%)' },
            left: { left: anchor.left - gap, top: anchor.top + anchor.height / 2, transform: 'translate(-100%, -50%)' },
        };
        const position = positions[this.placement];
        return `position:fixed;left:${position.left}px;top:${position.top}px;transform:${position.transform}`;
    }
}
class DemoPopoverElement extends ReactivePopoverElement {
    show(event, placement) {
        this.openAt(event.currentTarget, placement);
    }
    template() {
        return html `
      <div class="popover-controls">
        ${['top', 'right', 'bottom', 'left'].map((placement) => html `
          <button @click=${(event) => this.show(event, placement)}>${placement}</button>
        `)}
      </div>
      ${this.isOpen ? html `
        <aside class="popover" role="dialog" style=${this.positionStyle}>
          <p>Placed ${this.placement}</p>
          <button @click=${() => this.closeDialog()}>Close</button>
        </aside>
      ` : ''}
    `;
    }
}
customElements.define('cami-popover', DemoPopoverElement);
import { html, ReactiveElement } from 'cami'

type Placement = 'top' | 'right' | 'bottom' | 'left'

class ReactiveModalElement extends ReactiveElement {
  isOpen: boolean = false
  private lastFocusedElement: HTMLElement | null = null

  openDialog(): void {
    this.lastFocusedElement = document.activeElement instanceof HTMLElement
      ? document.activeElement
      : null
    this.isOpen = true
  }

  closeDialog(): void {
    this.isOpen = false
    this.lastFocusedElement?.focus()
  }

  onConnect(): void {
    this.addEventListener('keydown', (event: KeyboardEvent) => {
      if (event.key === 'Escape') this.closeDialog()
    })
  }
}

class ReactivePopoverElement extends ReactiveModalElement {
  placement: Placement = 'top'
  anchor: HTMLElement | null = null

  openAt(anchor: HTMLElement, placement: Placement): void {
    this.anchor = anchor
    this.placement = placement
    this.openDialog()
  }

  get positionStyle(): string {
    if (!this.anchor) return ''
    const anchor = this.anchor.getBoundingClientRect()
    const gap = 8
    const positions: Record<Placement, { left: number; top: number; transform: string }> = {
      top: { left: anchor.left + anchor.width / 2, top: anchor.top - gap, transform: 'translate(-50%, -100%)' },
      right: { left: anchor.right + gap, top: anchor.top + anchor.height / 2, transform: 'translateY(-50%)' },
      bottom: { left: anchor.left + anchor.width / 2, top: anchor.bottom + gap, transform: 'translateX(-50%)' },
      left: { left: anchor.left - gap, top: anchor.top + anchor.height / 2, transform: 'translate(-100%, -50%)' },
    }
    const position = positions[this.placement]
    return `position:fixed;left:${position.left}px;top:${position.top}px;transform:${position.transform}`
  }
}

class DemoPopoverElement extends ReactivePopoverElement {
  show(event: MouseEvent, placement: Placement): void {
    this.openAt(event.currentTarget as HTMLButtonElement, placement)
  }

  template(): ReturnType<typeof html> {
    return html`
      <div class="popover-controls">
        ${(['top', 'right', 'bottom', 'left'] as const).map((placement: Placement) => html`
          <button @click=${(event: MouseEvent) => this.show(event, placement)}>${placement}</button>
        `)}
      </div>
      ${this.isOpen ? html`
        <aside class="popover" role="dialog" style=${this.positionStyle}>
          <p>Placed ${this.placement}</p>
          <button @click=${() => this.closeDialog()}>Close</button>
        </aside>
      ` : ''}
    `
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'cami-popover': DemoPopoverElement
  }
}

customElements.define('cami-popover', DemoPopoverElement)