shoelace/src/components/popup/popup.ts

457 wiersze
17 KiB
TypeScript
Czysty Zwykły widok Historia

2022-08-05 13:17:58 +00:00
import { arrow, autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
import { classMap } from 'lit/directives/class-map.js';
2023-01-13 20:43:55 +00:00
import { customElement, property, query } from 'lit/decorators.js';
import { html } from 'lit';
2022-08-17 15:37:37 +00:00
import ShoelaceElement from '../../internal/shoelace-element';
2022-08-05 13:17:58 +00:00
import styles from './popup.styles';
import type { CSSResultGroup } from 'lit';
/**
Enrich components `@summary` with description from docs (#962) * keep header styles with repositioned description text * `animated-image` move description to component * code style * `avatar` add summary from docs * `badge` add summary from docs * `breadcrumb` add summary from docs * `button` add summary from docs * lead sentence is now part of the header * `button-group` add summary from docs * `card` add summary from docs * `checkbox` add summary from docs * `color-picker` add summary from docs * `details` add summary from docs * `dialog` add summary from docs * `divider` add summary from docs * `drawer` add summary from docs * `dropdown` add summary from docs * `format-bytes` add summary from docs * `format-date` add summary from docs * `format-number` add summary from docs * `icon` add summary from docs * `icon-button` add summary from docs * `image-comparer` add summary from docs * `include` add summary from docs * `input` add summary from docs * `menu` add summary from docs * `menu-item` add summary from docs * `menu-label` add summary from docs * `popup` add summary from docs * `progressbar` add summary from docs * `progress-ring` add summary from docs * `radio` add summary from docs * `radio-button` add summary from docs * `range` add summary from docs * `rating` add summary from docs * `relative-time` add summary from docs * `select` add summary from docs * `skeleton` add summary from docs * `spinner` add summary from docs * `split-panel` add summary from docs * `switch` add summary from docs * `tab-group` add summary from docs * `tag` add summary from docs * `textarea` add summary from docs * `tooltip` add summary from docs * `visually-hidden` add summary from docs * `animation` add summary from docs * `breadcrumb-item` add summary from docs * `mutation-observer` add summary from docs * `radio-group` add summary from docs * `resize-observer` add summary from docs * `tab` add summary from docs * `tab-panel` add summary from docs * `tree` add summary from docs * `tree-item` add summary from docs * remove `title` for further usage of `Sl` classnames in docs * revert: use markdown parser for component summary
2022-10-21 13:56:35 +00:00
* @summary Popup is a utility that lets you declaratively anchor "popup" containers to another element.
2023-01-12 15:26:25 +00:00
* @documentation https://shoelace.style/components/popup
2022-08-26 14:33:45 +00:00
* @status stable
2023-01-12 15:26:25 +00:00
* @since 2.0
2022-08-05 13:17:58 +00:00
*
* @event sl-reposition - Emitted when the popup is repositioned. This event can fire a lot, so avoid putting expensive
* operations in your listener or consider debouncing it.
*
2022-08-05 13:17:58 +00:00
* @slot - The popup's content.
2022-08-15 20:26:10 +00:00
* @slot anchor - The element the popup will be anchored to. If the anchor lives outside of the popup, you can use the
* `anchor` attribute or property instead.
2022-08-05 13:17:58 +00:00
*
* @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.
*
2022-08-25 20:09:27 +00:00
* @cssproperty [--arrow-size=6px] - The size of the arrow. Note that an arrow won't be shown unless the `arrow`
2022-08-10 15:01:13 +00:00
* attribute is used.
2022-08-05 20:11:39 +00:00
* @cssproperty [--arrow-color=var(--sl-color-neutral-0)] - The color of the arrow.
2022-08-10 15:01:13 +00:00
* @cssproperty [--auto-size-available-width] - A read-only custom property that determines the amount of width the
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
* available when using `auto-size`.
* @cssproperty [--auto-size-available-height] - A read-only custom property that determines the amount of height the
* popup can be before overflowing. Useful for positioning child elements that need to overflow. This property is only
* available when using `auto-size`.
2022-08-05 13:17:58 +00:00
*/
@customElement('sl-popup')
2022-08-17 15:37:37 +00:00
export default class SlPopup extends ShoelaceElement {
2022-08-05 13:17:58 +00:00
static styles: CSSResultGroup = styles;
2022-08-15 20:26:10 +00:00
private anchorEl: HTMLElement | null;
2022-08-05 13:17:58 +00:00
private cleanup: ReturnType<typeof autoUpdate> | undefined;
2023-01-03 20:04:07 +00:00
/** A reference to the internal popup container. Useful for animating and styling the popup with JavaScript. */
@query('.popup') popup: HTMLElement;
@query('.popup__arrow') private arrowEl: HTMLElement;
2022-08-15 20:26:10 +00:00
/**
* The element the popup will be anchored to. If the anchor lives outside of the popup, you can provide its `id` or a
* reference to it here. If the anchor lives inside the popup, use the `anchor` slot instead.
*/
@property() anchor: Element | string;
2022-08-05 20:11:39 +00:00
/**
* Activates the positioning logic and shows the popup. When this attribute is removed, the positioning logic is torn
* down and the popup will be hidden.
*/
2022-08-05 13:17:58 +00:00
@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'
2022-08-05 20:11:39 +00:00
| 'left-end' = 'top';
2022-08-05 13:17:58 +00:00
/**
* 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;
2022-08-05 20:11:39 +00:00
/**
* Attaches an arrow to the popup. The arrow's size and color can be customized using the `--arrow-size` and
* `--arrow-color` custom properties. For additional customizations, you can also target the arrow using
* `::part(arrow)` in your stylesheet.
2022-08-05 20:11:39 +00:00
*/
2022-08-05 13:17:58 +00:00
@property({ type: Boolean }) arrow = false;
2022-08-24 19:06:16 +00:00
/**
* The placement of the arrow. The default is `anchor`, which will align the arrow as close to the center of the
* anchor as possible, considering available space and `arrow-padding`. A value of `start`, `end`, or `center` will
* align the arrow to the start, end, or center of the popover instead.
*/
@property({ attribute: 'arrow-placement' }) arrowPlacement: 'start' | 'end' | 'center' | 'anchor' = 'anchor';
2022-08-05 20:11:39 +00:00
/**
* The amount of padding between the arrow and the edges of the popup. If the popup has a border-radius, for example,
* this will prevent it from overflowing the corners.
*/
2022-08-10 20:25:27 +00:00
@property({ attribute: 'arrow-padding', type: Number }) arrowPadding = 10;
2022-08-05 20:11:39 +00:00
2022-08-05 13:17:58 +00:00
/**
* When set, placement of the popup will flip to the opposite site to keep it in view. You can use
2022-08-22 21:11:21 +00:00
* `flipFallbackPlacements` to further configure how the fallback placement is determined.
2022-08-05 13:17:58 +00:00
*/
@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.
* */
2022-08-05 20:11:39 +00:00
@property({
2022-08-22 21:11:21 +00:00
attribute: 'flip-fallback-placements',
2022-08-05 20:11:39 +00:00
converter: {
fromAttribute: (value: string) => {
2022-08-08 14:08:57 +00:00
return value
2022-08-05 20:11:39 +00:00
.split(' ')
2022-08-08 14:08:57 +00:00
.map(p => p.trim())
.filter(p => p !== '');
2022-08-05 20:11:39 +00:00
},
toAttribute: (value: []) => {
return value.join(' ');
}
}
})
2022-08-22 21:11:21 +00:00
flipFallbackPlacements = '';
2022-08-05 13:17:58 +00:00
/**
* When neither the preferred placement nor the fallback placements fit, this value will be used to determine whether
2022-11-29 19:47:23 +00:00
* the popup should be positioned using the best available fit based on available space or as it was initially
* preferred.
2022-08-05 13:17:58 +00:00
*/
2022-11-29 19:47:23 +00:00
@property({ attribute: 'flip-fallback-strategy' }) flipFallbackStrategy: 'best-fit' | 'initial' = 'best-fit';
2022-08-05 20:11:39 +00:00
/**
* The flip boundary describes clipping element(s) that overflow will be checked relative to when flipping. By
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
* change the boundary by passing a reference to one or more elements to this property.
*/
2022-08-09 14:52:04 +00:00
@property({ type: Object }) flipBoundary: Element | Element[];
2022-08-05 20:11:39 +00:00
2022-08-09 14:52:04 +00:00
/** The amount of padding, in pixels, to exceed before the flip behavior will occur. */
2022-08-10 15:01:13 +00:00
@property({ attribute: 'flip-padding', type: Number }) flipPadding = 0;
2022-08-05 20:11:39 +00:00
/** Moves the popup along the axis to keep it in view when clipped. */
@property({ type: Boolean }) shift = false;
/**
* The shift boundary describes clipping element(s) that overflow will be checked relative to when shifting. By
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
* change the boundary by passing a reference to one or more elements to this property.
*/
2022-08-09 14:52:04 +00:00
@property({ type: Object }) shiftBoundary: Element | Element[];
2022-08-05 20:11:39 +00:00
2022-08-09 14:52:04 +00:00
/** The amount of padding, in pixels, to exceed before the shift behavior will occur. */
2022-08-10 15:01:13 +00:00
@property({ attribute: 'shift-padding', type: Number }) shiftPadding = 0;
2022-08-05 20:11:39 +00:00
2022-08-09 14:52:04 +00:00
/** When set, this will cause the popup to automatically resize itself to prevent it from overflowing. */
2022-08-19 18:21:30 +00:00
@property({ attribute: 'auto-size' }) autoSize: 'horizontal' | 'vertical' | 'both';
2022-08-05 20:11:39 +00:00
2022-08-19 13:17:44 +00:00
/** Syncs the popup's width or height to that of the anchor element. */
@property() sync: 'width' | 'height' | 'both';
2022-08-05 20:11:39 +00:00
/**
2022-08-09 14:52:04 +00:00
* The auto-size boundary describes clipping element(s) that overflow will be checked relative to when resizing. By
2022-08-05 20:11:39 +00:00
* default, the boundary includes overflow ancestors that will cause the element to be clipped. If needed, you can
* change the boundary by passing a reference to one or more elements to this property.
*/
2022-08-09 14:52:04 +00:00
@property({ type: Object }) autoSizeBoundary: Element | Element[];
2022-08-05 20:11:39 +00:00
2022-08-09 14:52:04 +00:00
/** The amount of padding, in pixels, to exceed before the auto-size behavior will occur. */
2022-08-10 15:01:13 +00:00
@property({ attribute: 'auto-size-padding', type: Number }) autoSizePadding = 0;
2022-08-05 13:17:58 +00:00
async connectedCallback() {
super.connectedCallback();
// Start the positioner after the first update
await this.updateComplete;
this.start();
}
disconnectedCallback() {
this.stop();
}
2023-01-03 20:04:07 +00:00
async updated(changedProps: Map<string, unknown>) {
super.updated(changedProps);
// Start or stop the positioner when active changes
if (changedProps.has('active')) {
if (this.active) {
this.start();
} else {
this.stop();
}
}
// Update the anchor when anchor changes
if (changedProps.has('anchor')) {
this.handleAnchorChange();
}
// All other properties will trigger a reposition when active
if (this.active) {
await this.updateComplete;
this.reposition();
}
}
private async handleAnchorChange() {
2022-08-05 13:17:58 +00:00
await this.stop();
2022-08-15 20:26:10 +00:00
if (this.anchor && typeof this.anchor === 'string') {
// Locate the anchor by id
const root = this.getRootNode() as Document | ShadowRoot;
this.anchorEl = root.getElementById(this.anchor);
} else if (this.anchor instanceof HTMLElement) {
// Use the anchor's reference
this.anchorEl = this.anchor;
} else {
// Look for a slotted anchor
this.anchorEl = this.querySelector<HTMLElement>('[slot="anchor"]');
}
// If the anchor is a <slot>, we'll use the first assigned element as the target since slots use `display: contents`
// and positioning can't be calculated on them
2022-08-15 20:26:10 +00:00
if (this.anchorEl instanceof HTMLSlotElement) {
this.anchorEl = this.anchorEl.assignedElements({ flatten: true })[0] as HTMLElement;
}
2022-08-15 20:26:10 +00:00
if (!this.anchorEl) {
throw new Error(
'Invalid anchor element: no anchor could be found using the anchor slot or the anchor attribute.'
);
2022-08-05 13:17:58 +00:00
}
this.start();
}
private start() {
// We can't start the positioner without an anchor
2022-08-15 20:26:10 +00:00
if (!this.anchorEl) {
2022-08-05 13:17:58 +00:00
return;
}
2022-08-15 20:26:10 +00:00
this.cleanup = autoUpdate(this.anchorEl, this.popup, () => {
2022-08-05 13:17:58 +00:00
this.reposition();
});
}
private async stop(): Promise<void> {
return new Promise(resolve => {
if (this.cleanup) {
this.cleanup();
this.cleanup = undefined;
2022-08-09 19:27:34 +00:00
this.removeAttribute('data-current-placement');
2022-08-10 15:01:13 +00:00
this.style.removeProperty('--auto-size-available-width');
this.style.removeProperty('--auto-size-available-height');
2022-08-05 13:17:58 +00:00
requestAnimationFrame(() => resolve());
} else {
resolve();
}
});
}
2022-12-06 16:18:14 +00:00
/** Forces the popup to recalculate and reposition itself. */
2022-08-05 13:17:58 +00:00
reposition() {
2022-08-09 20:00:43 +00:00
// Nothing to do if the popup is inactive or the anchor doesn't exist
2022-08-15 20:26:10 +00:00
if (!this.active || !this.anchorEl) {
2022-08-05 13:17:58 +00:00
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 })
];
2022-08-19 13:17:44 +00:00
// First we sync width/height
if (this.sync) {
middleware.push(
size({
apply: ({ rects }) => {
const syncWidth = this.sync === 'width' || this.sync === 'both';
const syncHeight = this.sync === 'height' || this.sync === 'both';
this.popup.style.width = syncWidth ? `${rects.reference.width}px` : '';
this.popup.style.height = syncHeight ? `${rects.reference.height}px` : '';
}
})
);
} else {
// Cleanup styles if we're not matching width/height
this.popup.style.width = '';
this.popup.style.height = '';
}
// Then we flip
2022-08-05 13:17:58 +00:00
if (this.flip) {
middleware.push(
flip({
2022-08-05 20:11:39 +00:00
boundary: this.flipBoundary,
2022-08-05 13:17:58 +00:00
// @ts-expect-error - We're converting a string attribute to an array here
2022-08-22 21:11:21 +00:00
fallbackPlacements: this.flipFallbackPlacements,
2022-08-22 21:15:27 +00:00
fallbackStrategy: this.flipFallbackStrategy === 'best-fit' ? 'bestFit' : 'initialPlacement',
2022-08-05 20:11:39 +00:00
padding: this.flipPadding
2022-08-05 13:17:58 +00:00
})
);
}
2022-08-10 15:01:13 +00:00
// Then we shift
2022-08-05 13:17:58 +00:00
if (this.shift) {
2022-08-05 20:11:39 +00:00
middleware.push(
shift({
boundary: this.shiftBoundary,
padding: this.shiftPadding
})
);
}
2022-08-10 15:01:13 +00:00
// Now we adjust the size as needed
if (this.autoSize) {
middleware.push(
size({
boundary: this.autoSizeBoundary,
padding: this.autoSizePadding,
apply: ({ availableWidth, availableHeight }) => {
2022-08-19 18:21:30 +00:00
if (this.autoSize === 'vertical' || this.autoSize === 'both') {
this.style.setProperty('--auto-size-available-height', `${availableHeight}px`);
2022-08-19 18:32:53 +00:00
} else {
this.style.removeProperty('--auto-size-available-height');
2022-08-19 18:21:30 +00:00
}
if (this.autoSize === 'horizontal' || this.autoSize === 'both') {
this.style.setProperty('--auto-size-available-width', `${availableWidth}px`);
2022-08-19 18:32:53 +00:00
} else {
this.style.removeProperty('--auto-size-available-width');
2022-08-19 18:21:30 +00:00
}
2022-08-10 15:01:13 +00:00
}
})
);
} else {
// Cleanup styles if we're no longer using auto-size
this.style.removeProperty('--auto-size-available-width');
this.style.removeProperty('--auto-size-available-height');
}
2022-08-05 13:17:58 +00:00
// Finally, we add an arrow
if (this.arrow) {
middleware.push(
arrow({
element: this.arrowEl,
2022-08-05 20:11:39 +00:00
padding: this.arrowPadding
2022-08-05 13:17:58 +00:00
})
);
}
2022-08-15 20:26:10 +00:00
computePosition(this.anchorEl, this.popup, {
2022-08-05 13:17:58 +00:00
placement: this.placement,
middleware,
strategy: this.strategy
}).then(({ x, y, middlewareData, placement }) => {
2022-09-02 15:28:49 +00:00
//
// Even though we have our own localization utility, it uses different heuristics to determine RTL. Because of
// that, we'll use the same approach that Floating UI uses.
//
// Source: https://github.com/floating-ui/floating-ui/blob/cb3b6ab07f95275730d3e6e46c702f8d4908b55c/packages/dom/src/utils/getDocumentRect.ts#L31
//
const isRtl = getComputedStyle(this).direction === 'rtl';
2022-08-05 13:17:58 +00:00
const staticSide = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[placement.split('-')[0]]!;
2022-08-09 19:27:34 +00:00
this.setAttribute('data-current-placement', placement);
2022-08-08 15:10:53 +00:00
Object.assign(this.popup.style, {
2022-08-05 13:17:58 +00:00
left: `${x}px`,
top: `${y}px`
});
if (this.arrow) {
2022-08-24 19:06:16 +00:00
const arrowX = middlewareData.arrow!.x;
const arrowY = middlewareData.arrow!.y;
let top = '';
let right = '';
let bottom = '';
let left = '';
if (this.arrowPlacement === 'start') {
// Start
2022-09-02 15:28:49 +00:00
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
2022-08-25 20:09:27 +00:00
top = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
2022-09-02 15:28:49 +00:00
right = isRtl ? value : '';
left = isRtl ? '' : value;
2022-08-24 19:06:16 +00:00
} else if (this.arrowPlacement === 'end') {
// End
2022-09-02 15:28:49 +00:00
const value = typeof arrowX === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
right = isRtl ? '' : value;
left = isRtl ? value : '';
2022-08-25 20:09:27 +00:00
bottom = typeof arrowY === 'number' ? `calc(${this.arrowPadding}px - var(--arrow-padding-offset))` : '';
2022-08-24 19:06:16 +00:00
} else if (this.arrowPlacement === 'center') {
// Center
2022-08-25 20:09:27 +00:00
left = typeof arrowX === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
top = typeof arrowY === 'number' ? `calc(50% - var(--arrow-size-diagonal))` : '';
2022-08-24 19:06:16 +00:00
} else {
// Anchor (default)
left = typeof arrowX === 'number' ? `${arrowX}px` : '';
top = typeof arrowY === 'number' ? `${arrowY}px` : '';
}
2022-08-05 13:17:58 +00:00
Object.assign(this.arrowEl.style, {
2022-08-24 19:06:16 +00:00
top,
right,
bottom,
left,
2022-08-25 20:09:27 +00:00
[staticSide]: 'calc(var(--arrow-size-diagonal) * -1)'
2022-08-05 13:17:58 +00:00
});
}
});
2022-09-16 20:21:40 +00:00
this.emit('sl-reposition');
2022-08-05 13:17:58 +00:00
}
render() {
return html`
2022-08-15 20:26:10 +00:00
<slot name="anchor" @slotchange=${this.handleAnchorChange}></slot>
2022-08-05 13:17:58 +00:00
<div
part="popup"
class=${classMap({
popup: true,
'popup--active': this.active,
'popup--fixed': this.strategy === 'fixed',
'popup--has-arrow': this.arrow
})}
>
<slot></slot>
${this.arrow ? html`<div part="arrow" class="popup__arrow" role="presentation"></div>` : ''}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sl-popup': SlPopup;
}
}