mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 20:19:13 +00:00
add popover
This commit is contained in:
143
packages/webawesome/docs/docs/components/popover.md
Normal file
143
packages/webawesome/docs/docs/components/popover.md
Normal 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>
|
||||
```
|
||||
65
packages/webawesome/src/components/popover/popover.css
Normal file
65
packages/webawesome/src/components/popover/popover.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
310
packages/webawesome/src/components/popover/popover.ts
Normal file
310
packages/webawesome/src/components/popover/popover.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user