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.
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)