UNPKG

@limetech/lime-elements

Version:
442 lines (441 loc) • 13.2 kB
import { h } from '@stencil/core'; import { createPopper, } from '@popperjs/core'; const IS_VISIBLE_CLASS = 'is-visible'; const IS_HIDING_CLASS = 'is-hiding'; const hideAnimationDuration = 300; /** * The portal component provides a way to render children into a DOM node that * exist outside the DOM hierarchy of the parent component. * * When the limel-portal component is used, it creates a new DOM node (a div element) * and appends it to a parent element (by default, the body of the document). * The child elements of the limel-portal are then moved from * their original location in the DOM to this new div element. * * This technique is often used to overcome CSS stacking context issues, * or to render UI elements like modals, dropdowns, tooltips, etc., * that need to visually "break out" of their container. * * Using this component, we ensure that the content is always rendered in the * correct position, and never covers its own trigger, or another component * that is opened in the stacking layer. This way, we don't need to worry about * z-indexes, or other stacking context issues. * * :::important * There are some caveats when using this component * * 1. Events might not bubble up as expected since the content is moved out to * another DOM node. * 2. Any styling that is applied to content from the parent will be lost, if the * content is just another web-component it will work without any issues. * Alternatively, use the `style=""` html attribute. * 3. Any component that is placed inside the container must have a style of * `max-height: inherit`. This ensures that its placement is calculated * correctly in relation to the trigger, and that it never covers its own * trigger. * 4. When the node is moved in the DOM, `disconnectedCallback` and * `connectedCallback` will be invoked, so if `disconnectedCallback` is used * to do any tear-down, the appropriate setup will have to be done again on * `connectedCallback`. * ::: * * @slot - Content to put inside the portal * @private * @exampleComponent limel-example-portal-basic */ export class Portal { constructor() { this.loaded = false; this.openDirection = 'bottom'; this.position = 'absolute'; this.containerId = undefined; this.containerStyle = {}; this.inheritParentWidth = false; this.visible = false; this.anchor = null; this.parents = new WeakMap(); } disconnectedCallback() { this.removeContainer(); this.destroyPopper(); if (this.observer && this.container) { this.observer.unobserve(this.container); } this.container = null; } connectedCallback() { if (!this.loaded) { return; } if (this.visible) { this.init(); } } componentDidLoad() { this.loaded = true; this.connectedCallback(); } init() { if (!this.host.isConnected) { return; } this.createContainer(); this.hideContainer(); this.attachContainer(); this.styleContainer(); if (this.visible) { this.createPopper(); this.showContainer(); } if ('ResizeObserver' in window) { this.observer = new ResizeObserver(() => { if (this.popperInstance) { this.styleContainer(); this.popperInstance.update(); } }); this.observer.observe(this.container); } } render() { return h("slot", null); } onVisible() { if (!this.container && this.visible) { this.init(); return; } if (!this.visible) { this.animateHideAndCleanup(); return; } this.styleContainer(); this.createPopper(); requestAnimationFrame(() => { this.showContainer(); }); } createContainer() { const slot = this.host.shadowRoot.querySelector('slot'); const content = (slot.assignedElements && slot.assignedElements()) || []; this.container = document.createElement('div'); this.container.setAttribute('id', this.containerId); this.container.setAttribute('class', 'limel-portal--container'); Object.assign(this.container, { portalSource: this.host, }); // eslint-disable-next-line unicorn/no-array-for-each content.forEach((element) => { this.parents.set(element, element.parentElement); this.container.append(element); }); } attachContainer() { this.getParent().append(this.container); } removeContainer() { if (!this.container) { return; } // eslint-disable-next-line unicorn/no-array-for-each [...this.container.children].forEach((element) => { const parent = this.parents.get(element); if (!parent) { return; } parent.append(element); }); this.container.remove(); } hideContainer() { if (!this.container) { return; } this.container.classList.remove(IS_VISIBLE_CLASS); } showContainer() { this.container.classList.add(IS_VISIBLE_CLASS); } animateHideAndCleanup() { if (!this.container) { return; } this.container.classList.add(IS_HIDING_CLASS); this.styleContainer(); setTimeout(() => { this.container.classList.remove(IS_HIDING_CLASS); if (!this.visible) { this.container.classList.remove(IS_VISIBLE_CLASS); this.destroyPopper(); } }, hideAnimationDuration); } styleContainer() { this.setContainerWidth(); this.setContainerHeight(); this.setContainerStyles(); } setContainerWidth() { const hostWidth = this.host.getBoundingClientRect().width; if (this.inheritParentWidth) { const containerWidth = this.getContentWidth(this.container); let width = containerWidth; if (hostWidth > 0) { width = hostWidth; } this.container.style.width = `${width}px`; } } getContentWidth(element) { if (!element) { return null; } const width = element.getBoundingClientRect().width; if (width !== 0) { return width; } const elementContent = element.querySelector('*'); return this.getContentWidth(elementContent); } setContainerStyles() { for (const property of Object.keys(this.containerStyle)) { this.container.style[property] = this.containerStyle[property]; } } createPopper() { const config = this.createPopperConfig(); this.popperInstance = createPopper(this.anchor || this.host, this.container, config); } destroyPopper() { var _a; (_a = this.popperInstance) === null || _a === void 0 ? void 0 : _a.destroy(); this.popperInstance = null; } createPopperConfig() { const placement = this.getPlacement(this.openDirection); const flipPlacement = this.getFlipPlacement(this.openDirection); return { strategy: this.position, placement: placement, modifiers: [ { name: 'flip', options: { fallbackPlacements: [flipPlacement], }, }, ], }; } getPlacement(direction) { const placements = { 'left-start': 'left-start', left: 'left', 'left-end': 'left-end', 'right-start': 'right-start', right: 'right', 'right-end': 'right-end', 'top-start': 'top-start', top: 'top', 'top-end': 'top-end', 'bottom-start': 'bottom-start', bottom: 'bottom', 'bottom-end': 'bottom-end', }; return placements[direction]; } getFlipPlacement(direction) { const flipPlacements = { 'left-start': 'right-start', left: 'right', 'left-end': 'right-end', 'right-start': 'left-start', right: 'left', 'right-end': 'left-end', 'top-start': 'bottom-start', top: 'bottom', 'top-end': 'bottom-end', 'bottom-start': 'top-start', bottom: 'top', 'bottom-end': 'top-end', }; return flipPlacements[direction]; } setContainerHeight() { const viewHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); const { top, bottom } = this.host.getBoundingClientRect(); const spaceAboveTopOfSurface = Math.max(top, 0); const spaceBelowTopOfSurface = Math.max(viewHeight - bottom, 0); const extraCosmeticSpace = 16; const maxHeight = Math.max(spaceAboveTopOfSurface, spaceBelowTopOfSurface) - extraCosmeticSpace; this.container.style.maxHeight = `${maxHeight}px`; } // Returns the parent element where the content of the portal will be moved to. // It needs to have styling of the portal container. getParent() { let element = this.anchor || this.host; while (element) { const parent = element.closest('.limel-portal--parent'); if (parent) { return parent; } element = element.getRootNode().host; } return document.body; } static get is() { return "limel-portal"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["portal.scss"] }; } static get styleUrls() { return { "$": ["portal.css"] }; } static get properties() { return { "openDirection": { "type": "string", "mutable": false, "complexType": { "original": "OpenDirection", "resolved": "\"bottom\" | \"bottom-end\" | \"bottom-start\" | \"left\" | \"left-end\" | \"left-start\" | \"right\" | \"right-end\" | \"right-start\" | \"top\" | \"top-end\" | \"top-start\"", "references": { "OpenDirection": { "location": "import", "path": "../menu/menu.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Decides which direction the portal content should open." }, "attribute": "open-direction", "reflect": true, "defaultValue": "'bottom'" }, "position": { "type": "string", "mutable": false, "complexType": { "original": "'fixed' | 'absolute'", "resolved": "\"absolute\" | \"fixed\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Position of the content." }, "attribute": "position", "reflect": true, "defaultValue": "'absolute'" }, "containerId": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "A unique ID." }, "attribute": "container-id", "reflect": true }, "containerStyle": { "type": "unknown", "mutable": false, "complexType": { "original": "object", "resolved": "object", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Dynamic styling that can be applied to the container holding the content." }, "defaultValue": "{}" }, "inheritParentWidth": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Used to make a dropdown have the same width as the trigger, for example\nin `limel-picker`." }, "attribute": "inherit-parent-width", "reflect": true, "defaultValue": "false" }, "visible": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "True if the content within the portal should be visible.\n\nIf the content is from within a dialog for instance, this can be set to\ntrue from false when the dialog opens to position the content properly." }, "attribute": "visible", "reflect": true, "defaultValue": "false" }, "anchor": { "type": "unknown", "mutable": false, "complexType": { "original": "HTMLElement", "resolved": "HTMLElement", "references": { "HTMLElement": { "location": "global" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "The element that the content should be positioned relative to.\nDefaults to the limel-portal element." }, "defaultValue": "null" } }; } static get elementRef() { return "host"; } static get watchers() { return [{ "propName": "visible", "methodName": "onVisible" }]; } } //# sourceMappingURL=portal.js.map