add popover

This commit is contained in:
Cory LaViska
2025-06-02 14:33:58 -04:00
parent 698815a96b
commit 51677a38d9
4 changed files with 527 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
---
title: Popover
layout: component
---
Popovers appear when a corresponding anchor element is clicked. Unlike [tooltips](/docs/components/tooltip), popovers can contain interactive content such as links, buttons, and form controls. They are not modal, so no overlay is shown when open. Popovers will close when the user clicks outside of them or presses [[Escape]]. Only one popover can be open at a time.
```html {.example}
<wa-popover for="popover__overview">
<div style="display: flex; flex-direction: column; gap: 1rem;">
<p>This popover contains interactive content that users can engage with directly.</p>
<wa-button variant="primary" size="small">Take Action</wa-button>
</div>
</wa-popover>
<wa-button id="popover__overview">Show popover</wa-button>
```
## Examples
### Assigning an Anchor
Popover anchors should be `<wa-button>` or `<button>` elements. Use the `for` attribute on the popover to link it to the anchor's `id`.
```html {.example}
<wa-button id="popover__anchor-button">Show Popover</wa-button>
<wa-popover for="popover__anchor-button">
I'm anchored to a Web Awesome button.
</wa-popover>
<br><br>
<button id="popover__anchor-native-button">Show Popover</button>
<wa-popover for="popover__anchor-native-button">
I'm anchored to a native button.
</wa-popover>
```
:::warning
The anchor element must be in the DOM when the popover is connected, otherwise the popover won't be attached and a warning will be shown in the console.
:::
### Opening and Closing
Popovers will be shown when their anchor element is clicked. You can open and close a popover programmatically by obtaining a reference to it and setting the `open` property to `true` or `false`, respectively.
As a convenience, you can add `data-popover="close"` to any button inside a popover to close it without additional JavaScript.
```html {.example}
<wa-popover for="popover__opening">
<p>The button below has <code>data-popover="close"</code> so clicking it will close the popover.</p>
<wa-button data-popover="close" variant="primary">Dismiss</wa-button>
</wa-popover>
<wa-button id="popover__opening">Show popover</wa-button>
```
### Placement
Use the `placement` attribute to change the preferred location of the popover in reference to its anchor. The popover will shift to a more optimal location if the preferred placement doesn't have enough room. The default placement is `top`.
```html {.example}
<div style="display: flex; gap: 1rem; align-items: center;">
<wa-button id="popover__top">Top</wa-button>
<wa-popover for="popover__top" placement="top">I'm on the top</wa-popover>
<wa-button id="popover__bottom">Bottom</wa-button>
<wa-popover for="popover__bottom" placement="bottom">I'm on the bottom</wa-popover>
<wa-button id="popover__left">Left</wa-button>
<wa-popover for="popover__left" placement="left">I'm on the left</wa-popover>
<wa-button id="popover__right">Right</wa-button>
<wa-popover for="popover__right" placement="right">I'm on the right</wa-popover>
</div>
```
### Distance
You can change the distance of the popover from the anchor by setting the `distance` attribute to the desired number of pixels.
```html {.example}
<div style="display: flex; gap: 1rem; align-items: center;">
<wa-button id="popover__distance-near">Near</wa-button>
<wa-popover for="popover__distance-near" distance="0">I'm very close</wa-popover>
<wa-button id="popover__distance-far">Far</wa-button>
<wa-popover for="popover__distance-far" distance="30">I'm farther away</wa-popover>
</div>
```
### Arrow Size
You can change the size of the popover's arrow with the `--arrow-size` custom property. Set it to `0` to remove the arrow.
```html {.example}
<div style="display: flex; gap: 1rem; align-items: center;">
<wa-button id="popover__big-arrow">Big arrow</wa-button>
<wa-popover for="popover__big-arrow" style="--arrow-size: 8px;">I have a big arrow</wa-popover>
<wa-button id="popover__no-arrow">No arrow</wa-button>
<wa-popover for="popover__no-arrow" style="--arrow-size: 0;">I don't have an arrow</wa-popover>
</div>
```
### Setting a Maximum Width
Use the `--max-width` custom property to change the maximum width of the popover.
```html {.example}
<wa-button id="popover__max-width">Toggle me</wa-button>
<wa-popover for="popover__max-width" style="--max-width: 160px;">
Popovers will usually grow to be much wider, but this one has a custom max width that forces text to wrap.
</wa-popover>
```
### Setting Focus
To move focus to a specific form control when the popover opens, use the [`autofocus`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autofocus) global attribute.
```html {.example}
<wa-popover for="popover__autofocus">
<div style="display: flex; flex-direction: column; gap: 1rem;">
<wa-textarea
autofocus
placeholder="What's on your mind?"
size="small"
resize="none"
rows="3"
></wa-textarea>
<wa-button variant="primary" size="small" data-popover="close">
Submit
</wa-button>
</div>
</wa-popover>
<wa-button id="popover__autofocus">
<wa-icon name="comment" slot="prefix"></wa-icon>
Feedback
</wa-button>
```

View File

@@ -0,0 +1,65 @@
:host {
--arrow-size: 0.375rem;
--max-width: 25rem;
--show-duration: 100ms;
--hide-duration: 100ms;
/* Internal calculated properties */
--arrow-diagonal-size: calc((var(--arrow-size) * sin(45deg)));
display: contents;
/** Defaults for inherited CSS properties */
font-size: var(--wa-popover-font-size);
line-height: var(--wa-popover-line-height);
text-align: start;
white-space: normal;
}
/* The <wa-popup> element */
.popover {
--arrow-size: inherit;
--show-duration: inherit;
--hide-duration: inherit;
&::part(arrow) {
background-color: var(--wa-color-surface-default);
border-width: var(--wa-panel-border-width);
border-bottom: solid var(--wa-color-surface-border);
border-right: solid var(--wa-color-surface-border);
}
}
.popover[placement^='top']::part(popup) {
transform-origin: bottom;
}
.popover[placement^='bottom']::part(popup) {
transform-origin: top;
}
.popover[placement^='left']::part(popup) {
transform-origin: right;
}
.popover[placement^='right']::part(popup) {
transform-origin: left;
}
/* Body */
.body {
display: flex;
flex-direction: column;
width: max-content;
max-width: var(--max-width);
padding: var(--wa-space);
background-color: var(--wa-color-surface-default);
border: var(--wa-panel-border-width) solid var(--wa-color-surface-border);
border-radius: var(--wa-panel-border-radius);
border-style: var(--wa-panel-border-style);
box-shadow: var(--wa-shadow-s);
color: var(--wa-color-text-normal);
user-select: none;
-webkit-user-select: none;
}

View File

@@ -0,0 +1,9 @@
import { expect, fixture, html } from '@open-wc/testing';
describe('<wa-popover>', () => {
it('should render a component', async () => {
const el = await fixture(html` <wa-popover></wa-popover> `);
expect(el).to.exist;
});
});

View File

@@ -0,0 +1,310 @@
import { html } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { WaAfterHideEvent } from '../../events/after-hide.js';
import { WaAfterShowEvent } from '../../events/after-show.js';
import { WaHideEvent } from '../../events/hide.js';
import { WaShowEvent } from '../../events/show.js';
import { animateWithClass } from '../../internal/animate.js';
import { waitForEvent } from '../../internal/event.js';
import { uniqueId } from '../../internal/math.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import WaPopup from '../popup/popup.js';
import styles from './popover.css';
const openPopovers = new Set<WaPopover>();
/**
* @summary Popovers display contextual content and interactive elements in a floating panel.
* @documentation https://backers.webawesome.com/docs/components/popover
* @status stable
* @since 3.0
*
* @dependency wa-popup
*
* @slot - The popover's content. Interactive elements such as buttons and links are supported.
*
* @event wa-show - Emitted when the popover begins to show. Canceling this event will stop the popover from showing.
* @event wa-after-show - Emitted after the popover has shown and all animations are complete.
* @event wa-hide - Emitted when the popover begins to hide. Canceling this event will stop the popover from hiding.
* @event wa-after-hide - Emitted after the popover has hidden and all animations are complete.
*
* @csspart body - The popover's body where its content is rendered.
* @csspart popup - The internal `<wa-popup>` element that positions the popover.
* @csspart popup__popup - The popup's exported `popup` part. Use this to target the popover's popup container.
* @csspart popup__arrow - The popup's exported `arrow` part. Use this to target the popover's arrow.
*
* @cssproperty [--arrow-size=0.375rem] - The size of the tiny arrow that points to the popover (set to zero to remove).
* @cssproperty [--max-width=25rem] - The maximum width of the popover's body content.
* @cssproperty [--show-duration=100ms] - The speed of the show animation.
* @cssproperty [--hide-duration=100ms] - The speed of the hide animation.
*/
@customElement('wa-popover')
export default class WaPopover extends WebAwesomeElement {
static shadowStyle = styles;
static dependencies = { 'wa-popup': WaPopup };
@query('.body') body: HTMLElement;
@query('wa-popup') popup: WaPopup;
@state() anchor: null | Element = null;
/**
* The preferred placement of the popover. Note that the actual placement may vary as needed to keep the popover
* inside of the viewport.
*/
@property() placement:
| 'top'
| 'top-start'
| 'top-end'
| 'right'
| 'right-start'
| 'right-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end' = 'top';
/** Shows or hides the popover. */
@property({ type: Boolean, reflect: true }) open = false;
/** The distance in pixels from which to offset the popover away from its target. */
@property({ type: Number }) distance = 8;
/** The distance in pixels from which to offset the popover along its target. */
@property({ type: Number }) skidding = 0;
/** The ID of the popover's anchor element. This must be an interactive/focusable element such as a button. */
@property() for: string | null = null;
private eventController = new AbortController();
connectedCallback() {
super.connectedCallback();
// If the user doesn't give us an id, generate one.
if (!this.id) {
this.id = uniqueId('wa-popover-');
}
}
disconnectedCallback() {
super.disconnectedCallback();
// Cleanup events in case the popover is removed while open
document.removeEventListener('keydown', this.handleDocumentKeyDown);
this.eventController.abort();
if (this.anchor) {
this.anchor.removeAttribute('aria-haspopup');
}
}
firstUpdated() {
this.body.hidden = !this.open;
// If the popover is visible on init, update its position
if (this.open) {
this.popup.active = true;
this.popup.reposition();
}
}
private handleAnchorClick = () => {
// Clicks on the anchor should toggle the popover
this.open = !this.open;
};
private handleBodyClick = (event: PointerEvent) => {
const target = event.target as HTMLElement;
const button = target.closest('[data-popover="close"]');
// Watch for [data-popover="close"] clicks
if (button) {
event.stopPropagation();
this.open = false;
}
};
private handleDocumentKeyDown = (event: KeyboardEvent) => {
// Hide the popover when escape is pressed
if (event.key === 'Escape') {
event.preventDefault();
this.open = false;
if (this.anchor && typeof (this.anchor as any).focus === 'function') {
(this.anchor as any).focus();
}
}
};
private handleDocumentClick = (event: PointerEvent) => {
const target = event.target as HTMLElement;
// Ignore clicks on the anchor so it will be closed by the anchor's click handler
if (this.anchor && event.composedPath().includes(this.anchor)) {
return;
}
// Detect when clicks occur outside the popover
if (target.closest('wa-popover') !== this) {
this.open = false;
}
};
@watch('open', { waitUntilFirstUpdate: true })
async handleOpenChange() {
if (this.open) {
// Show
const waShowEvent = new WaShowEvent();
this.dispatchEvent(waShowEvent);
if (waShowEvent.defaultPrevented) {
this.open = false;
return;
}
// Close other popovers that are open
openPopovers.forEach(popover => (popover.open = false));
document.addEventListener('keydown', this.handleDocumentKeyDown, { signal: this.eventController.signal });
document.addEventListener('click', this.handleDocumentClick, { signal: this.eventController.signal });
this.body.hidden = false;
this.popup.active = true;
openPopovers.add(this);
// Autofocus the first element with the autofocus attribute
requestAnimationFrame(() => {
const elementToFocus = this.querySelector<HTMLElement>('[autofocus]');
if (elementToFocus && typeof elementToFocus.focus === 'function') {
elementToFocus.focus();
}
});
await animateWithClass(this.popup.popup, 'show-with-scale');
this.popup.reposition();
this.dispatchEvent(new WaAfterShowEvent());
} else {
// Hide
const waHideEvent = new WaHideEvent();
this.dispatchEvent(waHideEvent);
if (waHideEvent.defaultPrevented) {
this.open = true;
return;
}
document.removeEventListener('keydown', this.handleDocumentKeyDown);
document.removeEventListener('click', this.handleDocumentClick);
openPopovers.delete(this);
await animateWithClass(this.popup.popup, 'hide-with-scale');
this.popup.active = false;
this.body.hidden = true;
this.dispatchEvent(new WaAfterHideEvent());
}
}
@watch('for')
handleForChange() {
const rootNode = this.getRootNode() as Document | ShadowRoot | null;
if (!rootNode) {
return;
}
const newAnchor = this.for ? rootNode.querySelector(`#${this.for}`) : null;
const oldAnchor = this.anchor;
if (newAnchor === oldAnchor) {
return;
}
const { signal } = this.eventController;
if (newAnchor) {
// Add aria-haspopup="dialog" to the anchor element
newAnchor.setAttribute('aria-haspopup', 'dialog');
newAnchor.addEventListener('click', this.handleAnchorClick, { signal });
}
if (oldAnchor) {
oldAnchor.removeAttribute('aria-haspopup');
oldAnchor.removeEventListener('click', this.handleAnchorClick);
}
this.anchor = newAnchor;
if (this.for && !newAnchor) {
console.warn(
`A popover was assigned to an element with an ID of "${this.for}" but the element could not be found.`,
this,
);
}
}
@watch(['distance', 'placement', 'skidding'])
async handleOptionsChange() {
if (this.hasUpdated) {
await this.updateComplete;
this.popup.reposition();
}
}
/** Shows the popover. */
async show() {
if (this.open) {
return undefined;
}
this.open = true;
return waitForEvent(this, 'wa-after-show');
}
/** Hides the popover. */
async hide() {
if (!this.open) {
return undefined;
}
this.open = false;
return waitForEvent(this, 'wa-after-hide');
}
render() {
return html`
<wa-popup
part="popup"
exportparts="
popup:popup__popup,
arrow:popup__arrow
"
class=${classMap({
popover: true,
'popover-open': this.open,
})}
placement=${this.placement}
distance=${this.distance}
skidding=${this.skidding}
flip
shift
arrow
.anchor=${this.anchor}
>
<div part="body" class="body" @click=${this.handleBodyClick}>
<slot></slot>
</div>
</wa-popup>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-popover': WaPopover;
}
}