From 7bdc9a2cc4c5d22b86d5ba7a326fdc4d921e57dd Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Mon, 2 Jun 2025 16:13:24 -0400 Subject: [PATCH] Add `` (#1012) * remove redundant styles from template * rotate arrow based on placement so borders show correctly when applied * use actual placement, not preferred * add popover * update changelog * update changelog * use for popover * fix arrow border in FF/Safari * update content * add sidebar to plop * add popover --- .../webawesome/docs/_includes/sidebar.njk | 2 + .../docs/docs/components/popover.md | 143 ++++++++ .../docs/docs/resources/changelog.md | 1 + packages/webawesome/scripts/plop/plopfile.js | 6 + .../plop/templates/component/component.hbs | 3 +- .../src/components/popover/popover.css | 91 +++++ .../src/components/popover/popover.test.ts | 9 + .../src/components/popover/popover.ts | 310 ++++++++++++++++++ .../webawesome/src/components/popup/popup.css | 14 +- 9 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 packages/webawesome/docs/docs/components/popover.md create mode 100644 packages/webawesome/src/components/popover/popover.css create mode 100644 packages/webawesome/src/components/popover/popover.test.ts create mode 100644 packages/webawesome/src/components/popover/popover.ts diff --git a/packages/webawesome/docs/_includes/sidebar.njk b/packages/webawesome/docs/_includes/sidebar.njk index 8f6a0d40c..8336b8b52 100644 --- a/packages/webawesome/docs/_includes/sidebar.njk +++ b/packages/webawesome/docs/_includes/sidebar.njk @@ -137,6 +137,7 @@
  • Mutation Observer
  • +
  • Popover
  • Popup
  • Progress Bar
  • Progress Ring
  • @@ -174,6 +175,7 @@
  • Tooltip
  • Tree
  • Tree Item
  • + {# PLOP_NEW_COMPONENT_PLACEHOLDER #} diff --git a/packages/webawesome/docs/docs/components/popover.md b/packages/webawesome/docs/docs/components/popover.md new file mode 100644 index 000000000..b4edbdaa6 --- /dev/null +++ b/packages/webawesome/docs/docs/components/popover.md @@ -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} + +
    +

    This popover contains interactive content that users can engage with directly.

    + Take Action +
    +
    + +Show popover +``` + +## Examples + +### Assigning an Anchor + +Use `` or ` + + + I'm anchored to a native button. + +``` + +:::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} + +

    The button below has data-popover="close" so clicking it will close the popover.

    + Dismiss +
    + +Show popover +``` + +### 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} +
    + Top + I'm on the top + + Bottom + I'm on the bottom + + Left + I'm on the left + + Right + I'm on the right +
    +``` + +### Distance + +Use the `distance` attribute to control how far the popover appears from its anchor. + +```html {.example} +
    + Near + I'm very close + + Far + I'm farther away +
    +``` + +### 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} +
    + Big arrow + I have a big arrow + + No arrow + I don't have an arrow +
    +``` + +### Setting a Maximum Width + +Use the `--max-width` custom property to control the maximum width of the popover. + +```html {.example} +Toggle me + + Popovers will usually grow to be much wider, but this one has a custom max width that forces text to wrap. + +``` + +### 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} + +
    + + + Submit + +
    +
    + + + + Feedback + +``` \ No newline at end of file diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index d16499060..0c6d661af 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -31,6 +31,7 @@ During the alpha period, things might break! We take breaking changes very serio - `` => `` - `` => `` - 🚨 BREAKING: removed the `size` attribute from ``; please set the size of child elements on the children directly +- Added a new free component: `` (#2 of 14 per stretch goals) - Added a `min-block-size` to `` to ensure the divider is visible regardless of container height [issue:675] - Fixed a bug in `` that caused radios to uncheck when assigning a numeric value [issue:924] - Fixed `` so dividers properly show between buttons diff --git a/packages/webawesome/scripts/plop/plopfile.js b/packages/webawesome/scripts/plop/plopfile.js index da5664c18..cc63ce8d5 100644 --- a/packages/webawesome/scripts/plop/plopfile.js +++ b/packages/webawesome/scripts/plop/plopfile.js @@ -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: `
  • {{ tagToTitle tag }}
  • \n {# PLOP_NEW_COMPONENT_PLACEHOLDER #}`, + }, ], }); } diff --git a/packages/webawesome/scripts/plop/templates/component/component.hbs b/packages/webawesome/scripts/plop/templates/component/component.hbs index d84d77930..1f141f54b 100644 --- a/packages/webawesome/scripts/plop/templates/component/component.hbs +++ b/packages/webawesome/scripts/plop/templates/component/component.hbs @@ -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'; diff --git a/packages/webawesome/src/components/popover/popover.css b/packages/webawesome/src/components/popover/popover.css new file mode 100644 index 000000000..80cf129a3 --- /dev/null +++ b/packages/webawesome/src/components/popover/popover.css @@ -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 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; +} diff --git a/packages/webawesome/src/components/popover/popover.test.ts b/packages/webawesome/src/components/popover/popover.test.ts new file mode 100644 index 000000000..eccb8adb9 --- /dev/null +++ b/packages/webawesome/src/components/popover/popover.test.ts @@ -0,0 +1,9 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +describe('', () => { + it('should render a component', async () => { + const el = await fixture(html` `); + + expect(el).to.exist; + }); +}); diff --git a/packages/webawesome/src/components/popover/popover.ts b/packages/webawesome/src/components/popover/popover.ts new file mode 100644 index 000000000..1b769b8f4 --- /dev/null +++ b/packages/webawesome/src/components/popover/popover.ts @@ -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(); + +/** + * @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 `` 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('[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` + + +
    + +
    +
    +
    + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'wa-popover': WaPopover; + } +} diff --git a/packages/webawesome/src/components/popup/popup.css b/packages/webawesome/src/components/popup/popup.css index cdb08e228..7db983610 100644 --- a/packages/webawesome/src/components/popup/popup.css +++ b/packages/webawesome/src/components/popup/popup.css @@ -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 */