diff --git a/cspell.json b/cspell.json index 4b0b15a4..163d7024 100644 --- a/cspell.json +++ b/cspell.json @@ -78,6 +78,7 @@ "Menlo", "menuitemcheckbox", "menuitemradio", + "middlewares", "minlength", "monospace", "mousedown", diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 87c35676..74789235 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -77,6 +77,7 @@ - [Format Number](/components/format-number) - [Include](/components/include) - [Mutation Observer](/components/mutation-observer) + - [Popup](/components/Popup) - [Relative Time](/components/relative-time) - [Resize Observer](/components/resize-observer) - [Responsive Media](/components/responsive-media) diff --git a/docs/components/popup.md b/docs/components/popup.md new file mode 100644 index 00000000..571b8d7e --- /dev/null +++ b/docs/components/popup.md @@ -0,0 +1,112 @@ +# Popup + +[component-header:sl-popup] + +A description of the component goes here. + +```html preview + + + + + + + +``` + +## Examples + +### Fixed Positioning Strategy + +By default, an absolute positioning strategy is used for maximum performance. However, if your content is within a container that has `overflow: auto|hidden` the popup will be clipped. To work around this, you can switch to the fixed positioning strategy by setting the `strategy` attribute to `fixed`. + +The fixed positioning strategy allows the content to break out containers that clip them. When using this strategy, it's important to note that the content will be positioned _relative to its containing block_, which is usually the viewport unless an ancestor uses a `transform`, `perspective`, or `filter`. [Refer to this page](https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed) for more details. + +TODO + +### Arrows + +TODO + +[component-metadata:sl-popup] diff --git a/src/components/popup/popup.styles.ts b/src/components/popup/popup.styles.ts new file mode 100644 index 00000000..de6cce46 --- /dev/null +++ b/src/components/popup/popup.styles.ts @@ -0,0 +1,38 @@ +import { css } from 'lit'; +import componentStyles from '../../styles/component.styles'; + +export default css` + ${componentStyles} + + :host { + --arrow-size: 4px; + --arrow-color: var(--sl-color-neutral-0); + --arrow-shadow: none; + + display: inline; + } + + .popup { + display: block; + position: absolute; + isolation: isolate; + } + + .popup--fixed { + position: fixed; + } + + .popup:not(.popup--active) { + display: none; + } + + .popup__arrow { + position: absolute; + width: calc(var(--arrow-size) * 2); + height: calc(var(--arrow-size) * 2); + transform: rotate(45deg); + background: var(--arrow-color); + box-shadow: var(--arrow-shadow); + z-index: -1; + } +`; diff --git a/src/components/popup/popup.test.ts b/src/components/popup/popup.test.ts new file mode 100644 index 00000000..ce0002d2 --- /dev/null +++ b/src/components/popup/popup.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/src/components/popup/popup.ts b/src/components/popup/popup.ts new file mode 100644 index 00000000..ed4bf411 --- /dev/null +++ b/src/components/popup/popup.ts @@ -0,0 +1,264 @@ +import { arrow, autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom'; +import { LitElement, html } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { watch } from '../../internal/watch'; +import styles from './popup.styles'; +import type { CSSResultGroup } from 'lit'; + +/** + * @since 2.0 + * @status experimental + * + * @slot - The popup's content. + * @slot anchor - The element the popup will be anchored to. + * + * @csspart arrow - The arrow's container. Avoid setting `top|bottom|left|right` properties, as these values are + * assigned dynamically as the popup moves. This is most useful for applying a background color to match the popup, and + * maybe a border or box shadow. + * @csspart popup - The popup's container. Useful for setting a background color, box shadow, etc. + * + * @cssproperty [--arrow-size=4px] - The size of the arrow. + * @cssproperty [--arrow-color=4px] - The color of the arrow. + */ +@customElement('sl-popup') +export default class SlPopup extends LitElement { + static styles: CSSResultGroup = styles; + + @query('.popup') popupEl: HTMLElement; + @query('.popup__arrow') arrowEl: HTMLElement; + + private anchor: HTMLElement | null; + private cleanup: ReturnType | undefined; + + /** Activates popup logic and shows the popup. */ + @property({ type: Boolean, reflect: true }) active = false; + + /** + * The preferred placement of the popup. Note that the actual placement will vary as configured to keep the + * panel inside of the viewport. + */ + @property({ reflect: true }) placement: + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'right' + | 'right-start' + | 'right-end' + | 'left' + | 'left-start' + | 'left-end' = 'bottom-start'; + + /** + * Determines how the popup is positioned. The `absolute` strategy works well in most cases, but if + * overflow is clipped, using a `fixed` position strategy can often workaround it. + */ + @property({ reflect: true }) strategy: 'absolute' | 'fixed' = 'absolute'; + + /** The distance in pixels from which to offset the panel away from its anchor. */ + @property({ type: Number }) distance = 0; + + /** The distance in pixels from which to offset the panel along its anchor. */ + @property({ type: Number }) skidding = 0; + + /** Moves the popup along the axis to keep it in view when clipped. */ + @property({ type: Boolean }) shift = false; + + /** Attaches an arrow to the popup. */ + @property({ type: Boolean }) arrow = false; + + /** + * When set, placement of the popup will flip to the opposite site to keep it in view. You can use + * `flipFallbackPlacement` to further configure how the fallback placement is determined. + */ + @property({ type: Boolean }) flip = false; + + /** + * If the preferred placement doesn't fit, popup will be tested in these fallback placements until one fits. Must be a + * string of any number of placements separated by a space, e.g. "top bottom left". If no placement fits, the flip + * fallback strategy will be used instead. + * */ + @property() flipFallbackPlacement: string; + + /** + * When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether + * the popup should be positioned as it was initially preferred or using the best available fit based on available + * space. + */ + @property() flipFallbackStrategy: 'bestFit' | 'initialPlacement' = 'initialPlacement'; + + async connectedCallback() { + super.connectedCallback(); + + // Start the positioner after the first update + await this.updateComplete; + this.start(); + } + + disconnectedCallback() { + this.stop(); + } + + async handleAnchorSlotChange() { + await this.stop(); + + this.anchor = this.querySelector('[slot="anchor"]'); + if (!this.anchor) { + throw new Error('Invalid anchor element: no child with slot="anchor" was found.'); + } + + this.start(); + } + + private start() { + // We can't start the positioner without an anchor + if (!this.anchor) { + return; + } + + this.cleanup = autoUpdate(this.anchor, this.popupEl, () => { + this.reposition(); + }); + } + + private async stop(): Promise { + return new Promise(resolve => { + if (this.cleanup) { + this.cleanup(); + this.cleanup = undefined; + requestAnimationFrame(() => resolve()); + } else { + resolve(); + } + }); + } + + @watch('active', { waitUntilFirstUpdate: true }) + handleActiveChange() { + if (this.active) { + this.reposition(); + } else { + this.stop(); + } + } + + @watch('arrow') + @watch('distance') + @watch('flip') + @watch('flipFallbackPlacement') + @watch('flipFallbackStrategy') + @watch('placement') + @watch('shift') + @watch('skidding') + @watch('strategy') + async handlePositionChange() { + if (this.hasUpdated && this.active) { + await this.updateComplete; + this.reposition(); + } + } + + /** Recalculate and repositions the popup. */ + reposition() { + if (!this.anchor) { + throw new Error('Invalid anchor element: no child with slot="anchor" was found.'); + } + + // Nothing to do if the popup is inactive + if (!this.active) { + return; + } + + // + // NOTE: Floating UI middlewares are order dependent: https://floating-ui.com/docs/middleware + // + const middleware = [ + // The offset middleware goes first + offset({ mainAxis: this.distance, crossAxis: this.skidding }) + ]; + + // Then we flip, as needed + if (this.flip) { + middleware.push( + flip({ + // @ts-expect-error - We're converting a string attribute to an array here + // + // TODO - use a custom adapter for this property + // + fallbackPlacement: this.flipFallbackPlacement.split(' ').filter(p => p.trim()), + fallbackStrategy: this.flipFallbackStrategy + }) + ); + } + + // Then we shift, as needed + if (this.shift) { + middleware.push(shift()); + } + + // Finally, we add an arrow + if (this.arrow) { + middleware.push( + arrow({ + element: this.arrowEl, + padding: 10 // min distance from the edge, in pixels + }) + ); + } + + computePosition(this.anchor, this.popupEl, { + placement: this.placement, + middleware, + strategy: this.strategy + }).then(({ x, y, middlewareData, placement }) => { + const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!; + + Object.assign(this.popupEl.style, { + left: `${x}px`, + top: `${y}px` + }); + + if (this.arrow) { + const arrowX = middlewareData.arrow?.x; + const arrowY = middlewareData.arrow?.y; + + Object.assign(this.arrowEl.style, { + left: typeof arrowX === 'number' ? `${arrowX}px` : '', + top: typeof arrowY === 'number' ? `${arrowY}px` : '', + right: '', + bottom: '', + [staticSide]: 'calc(var(--arrow-size) * -1)' + }); + } + }); + } + + render() { + return html` + + +
+ + ${this.arrow ? html`` : ''} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sl-popup': SlPopup; + } +} diff --git a/src/shoelace.ts b/src/shoelace.ts index 8da0fd0c..b54c81d2 100644 --- a/src/shoelace.ts +++ b/src/shoelace.ts @@ -28,6 +28,7 @@ export { default as SlMenu } from './components/menu/menu'; export { default as SlMenuItem } from './components/menu-item/menu-item'; export { default as SlMenuLabel } from './components/menu-label/menu-label'; export { default as SlMutationObserver } from './components/mutation-observer/mutation-observer'; +export { default as SlPopup } from './components/popup/popup'; export { default as SlProgressBar } from './components/progress-bar/progress-bar'; export { default as SlProgressRing } from './components/progress-ring/progress-ring'; export { default as SlQrCode } from './components/qr-code/qr-code';