@limetech/lime-elements
Version:
442 lines (441 loc) • 13.2 kB
JavaScript
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