Merge branch 'next' into lm/form-control-sizing

This commit is contained in:
lindsaym-fa
2025-06-03 11:23:46 -04:00
10 changed files with 615 additions and 3 deletions

View File

@@ -137,6 +137,7 @@
</ul>
</li>
<li><a href="/docs/components/mutation-observer/">Mutation Observer</a></li>
<li><a href="/docs/components/popover/">Popover</a></li>
<li><a href="/docs/components/popup/">Popup</a></li>
<li><a href="/docs/components/progress-bar/">Progress Bar</a></li>
<li><a href="/docs/components/progress-ring/">Progress Ring</a></li>
@@ -174,6 +175,7 @@
<li><a href="/docs/components/tooltip/">Tooltip</a></li>
<li><a href="/docs/components/tree/">Tree</a></li>
<li><a href="/docs/components/tree-item/">Tree Item</a></li>
{# PLOP_NEW_COMPONENT_PLACEHOLDER #}
</ul>
</wa-details>

View File

@@ -1,3 +1,19 @@
import { allDefined } from '/dist/webawesome.js';
/**
* Determines how the page was loaded. Possible return values include "reload", "navigate", "back_forward", "prerender",
* and "unknown".
*/
function getNavigationType() {
if (performance.getEntriesByType) {
const navEntries = performance.getEntriesByType('navigation');
if (navEntries.length > 0) {
return navEntries[0].type;
}
}
return 'unknown';
}
// Smooth links
document.addEventListener('click', event => {
const link = event.target.closest('a');
@@ -31,3 +47,26 @@ function updateScrollClass() {
window.addEventListener('scroll', updateScrollClass);
window.addEventListener('turbo:render', updateScrollClass);
updateScrollClass();
// Restore scroll position after components are defined
allDefined().then(() => {
const navigationType = getNavigationType();
const key = `wa-scroll-y-[${location.pathname}]`;
const scrollY = sessionStorage.getItem(key);
// Only restore when reloading, otherwise clear it
if (navigationType === 'reload' && scrollY) {
window.scrollTo(0, scrollY);
} else {
sessionStorage.removeItem(key);
}
// After restoring, keep tabs on the page's scroll position for next reload
window.addEventListener(
'scroll',
() => {
sessionStorage.setItem(key, window.scrollY);
},
{ passive: true },
);
});

View File

@@ -0,0 +1,143 @@
---
title: Popover
layout: component
---
Popovers display interactive content when their anchor element is clicked. Unlike [tooltips](/docs/components/tooltip), popovers can contain links, buttons, and form controls. They appear without an overlay and will close when you click outside or press [[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
Use `<wa-button>` or `<button>` elements as popover anchors. Connect the popover to its anchor by setting the `for` attribute to match 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
Make sure the anchor element exists in the DOM before the popover connects. If it doesn't exist, the popover won't attach and you'll see a console warning.
:::
### Opening and Closing
Popovers show when you click their anchor element. You can also control them programmatically by setting the `open` property to `true` or `false`.
Use `data-popover="close"` on any button inside a popover to close it automatically.
```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 set where the popover appears relative to its anchor. The popover will automatically reposition if there isn't enough space in the preferred location. 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
Use the `distance` attribute to control how far the popover appears from its anchor.
```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
Use the `--arrow-size` custom property to change the size of the popover's arrow. Set it to `0` to remove the arrow entirely.
```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 control 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
Use the [`autofocus`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/autofocus) global attribute to move focus to a specific form control when the popover opens.
```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

@@ -37,6 +37,7 @@ During the alpha period, things might break! We take breaking changes very serio
- Refactored default `--wa-font-size-*` values to use an apparent 1.125 ratio and round rendered values to the nearest whole pixel
- Added convenience tokens for `--wa-font-size-smaller` and `--wa-font-size-larger`
- Updated components to use relative `em` values for internal padding and margin wherever appropriate
- Added a new free component: `<wa-popover>` (#2 of 14 per stretch goals)
- Added a `min-block-size` to `<wa-divider orientation="vertical">` to ensure the divider is visible regardless of container height [issue:675]
- Added `--checked-icon-scale` to `<wa-checkbox>`
- Fixed a bug in `<wa-radio-group>` that caused radios to uncheck when assigning a numeric value [issue:924]

View File

@@ -50,6 +50,12 @@ export default function (plop) {
path: '../../docs/docs/components/{{ tagWithoutPrefix tag }}.md',
templateFile: 'templates/component/docs.hbs',
},
{
type: 'modify',
path: '../../docs/_includes/sidebar.njk',
pattern: /\{# PLOP_NEW_COMPONENT_PLACEHOLDER #\}/,
template: `<li><a href="/docs/components/{{ tagWithoutPrefix tag }}">{{ tagToTitle tag }}</a></li>\n {# PLOP_NEW_COMPONENT_PLACEHOLDER #}`,
},
],
});
}

View File

@@ -2,7 +2,6 @@ import { html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { watch } from '../../internal/watch.js';
import WebAwesomeElement from '../../internal/webawesome-element.js';
import componentStyles from '../../styles/component/host.css';
import styles from './{{ tagWithoutPrefix tag }}.css';
/**
@@ -22,7 +21,7 @@ import styles from './{{ tagWithoutPrefix tag }}.css';
*/
@customElement("{{ tag }}")
export default class {{ properCase tag }} extends WebAwesomeElement {
static shadowStyle = [componentStyles, styles];
static shadowStyle = styles;
/** An example attribute. */
@property() attr = 'example';

View File

@@ -0,0 +1,91 @@
: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 native dialog element */
.dialog {
display: none;
position: fixed;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: none;
background: transparent;
overflow: visible;
pointer-events: none;
&:focus {
outline: none;
}
&[open] {
display: block;
}
}
/* The <wa-popup> element */
.popover {
--arrow-size: inherit;
--show-duration: inherit;
--hide-duration: inherit;
pointer-events: auto;
&::part(arrow) {
background-color: var(--wa-color-surface-default);
border-top: none;
border-left: none;
border-bottom: solid var(--wa-panel-border-width) var(--wa-color-surface-border);
border-right: solid var(--wa-panel-border-width) var(--wa-color-surface-border);
box-shadow: none;
}
}
.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 dialog - The native dialog element that contains the popover content.
* @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('dialog') dialog: HTMLDialogElement;
@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();
}
firstUpdated() {
// If the popover is visible on init, update its position
if (this.open) {
this.dialog.show();
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 });
// Show the dialog non-modally
this.dialog.show();
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();
} else {
// Fall back to setting focus on the dialog
this.dialog.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.dialog.close();
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) {
newAnchor.addEventListener('click', this.handleAnchorClick, { signal });
}
if (oldAnchor) {
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`
<dialog part="dialog" class="dialog">
<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>
</dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'wa-popover': WaPopover;
}
}

View File

@@ -48,7 +48,19 @@
height: calc(var(--arrow-size-diagonal) * 2);
rotate: 45deg;
background: var(--arrow-color);
z-index: -1;
z-index: 3;
}
:host([data-current-placement~='left']) .arrow {
rotate: -45deg;
}
:host([data-current-placement~='right']) .arrow {
rotate: 135deg;
}
:host([data-current-placement~='bottom']) .arrow {
rotate: 225deg;
}
/* Hover bridge */