UNPKG

@logo-elements/overlay

Version:

<logo-elements-overlay> is a Web Component meant for internal use in Logo Elements web components to overlay items.

1,012 lines (884 loc) 29.9 kB
/** * @license * Copyright LOGO YAZILIM SANAYİ VE TİCARET A.Ş. All Rights Reserved. * * Save to the extent permitted by law, you may not use, copy, modify, * distribute or create derivative works of this material or any part * of it without the prior written consent of LOGO YAZILIM SANAYİ VE TİCARET A.Ş. Limited. * Any reproduction of this material must contain this notice. */ import { FlattenedNodesObserver } from '@polymer/polymer/lib/utils/flattened-nodes-observer.js'; import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js'; import { templatize } from '@polymer/polymer/lib/utils/templatize.js'; import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; import { isIOS } from '@logo-elements/component-base/src/browser-utils.js'; import { DirMixin } from '@logo-elements/component-base/src/dir-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { FocusablesHelper } from './logo-elements-focusables-helper.js'; /** * * `<logo-elements-overlay>` is a Web Component for creating overlays. The content of the overlay * can be populated in two ways: imperatively by using renderer callback function and * declaratively by using Polymer's Templates. * * ### Rendering * * By default, the overlay uses the content provided by using the renderer callback function. * * The renderer function provides `root`, `owner`, `model` arguments when applicable. * Generate DOM content by using `model` object properties if needed, append it to the `root` * element and control the state of the host element by accessing `owner`. Before generating new * content, users are able to check if there is already content in `root` for reusing it. * * ```html * <logo-elements-overlay id="overlay"></logo-elements-overlay> * ``` * ```js * const overlay = document.querySelector('#overlay'); * overlay.renderer = function(root) { * root.textContent = "Overlay content"; * }; * ``` * * Renderer is called on the opening of the overlay and each time the related model is updated. * DOM generated during the renderer call can be reused * in the next renderer call and will be provided with the `root` argument. * On first call it will be empty. * * **NOTE:** when the renderer property is defined, the `<template>` content is not used. * * ### Templating * * Alternatively, the content can be provided with Polymer Template. * Overlay finds the first child template and uses that in case renderer callback function * is not provided. You can also set a custom template using the `template` property. * * After the content from the template is stamped, the `content` property * points to the content container. * * The overlay provides `forwardHostProp` when calling * `Polymer.Templatize.templatize` for the template, so that the bindings * from the parent scope propagate to the content. You can also pass * custom `instanceProps` object using the `instanceProps` property. * * ```html * <logo-elements-overlay> * <template>Overlay content</template> * </logo-elements-overlay> * ``` * * **NOTE:** when using `instanceProps`: because of the Polymer limitation, * every template can only be templatized once, so it is important * to set `instanceProps` before the `template` is assigned to the overlay. * * ### Styling * * To style the overlay content, use styles in the parent scope: * * - If the overlay is used in a component, then the component styles * apply the overlay content. * - If the overlay is used in the global DOM scope, then global styles * apply to the overlay content. * * See examples for styling the overlay content in the live demos. * * The following Shadow DOM parts are available for styling the overlay component itself: * * Part name | Description * -----------|---------------------------------------------------------| * `backdrop` | Backdrop of the overlay * `overlay` | Container for position/sizing/alignment of the content * `content` | Content of the overlay * * The following state attributes are available for styling: * * Attribute | Description | Part * ---|---|--- * `opening` | Applied just after the overlay is attached to the DOM. You can apply a CSS @keyframe animation for this state. | `:host` * `closing` | Applied just before the overlay is detached from the DOM. You can apply a CSS @keyframe animation for this state. | `:host` * * The following custom CSS properties are available for styling: * * Custom CSS property | Description | Default value * ---|---|--- * `--logo-elements-overlay-viewport-bottom` | Bottom offset of the visible viewport area | `0` or detected offset * * @fires {CustomEvent} opened-changed - Fired when the `opened` property changes. * * @extends HTMLElement * @mixes ThemableMixin * @mixes DirMixin */ class OverlayElement extends ThemableMixin(DirMixin(PolymerElement)) { static get template() { return html` <style> :host { z-index: 200; position: fixed; top: 0; right: 0; bottom: var(--logo-elements-overlay-viewport-bottom); left: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; margin: auto; pointer-events: none; -webkit-tap-highlight-color: transparent; --logo-elements-overlay-viewport-bottom: 0; } :host([hidden]), :host(:not([opened]):not([closing])) { display: none !important; } [part='overlay'] { -webkit-overflow-scrolling: touch; overflow: auto; pointer-events: auto; max-width: 100%; box-sizing: border-box; -webkit-tap-highlight-color: initial; } [part='backdrop'] { z-index: -1; content: ''; background: rgba(0, 0, 0, 0.5); position: fixed; top: 0; left: 0; bottom: 0; right: 0; pointer-events: auto; } </style> <div id="backdrop" part="backdrop" hidden$="[[!withBackdrop]]"></div> <div part="overlay" id="overlay" tabindex="0"> <div part="content" id="content"> <slot></slot> </div> </div> `; } static get is() { return 'logo-elements-overlay'; } static get properties() { return { /** * When true, the overlay is visible and attached to body. */ opened: { type: Boolean, notify: true, observer: '_openedChanged', reflectToAttribute: true }, /** * Owner element passed with renderer function * @type {HTMLElement} */ owner: Element, /** * Custom function for rendering the content of the overlay. * Receives three arguments: * * - `root` The root container DOM element. Append your content to it. * - `owner` The host element of the renderer function. * - `model` The object with the properties related with rendering. * @type {OverlayRenderer | null | undefined} */ renderer: Function, /** * The template of the overlay content. * @type {HTMLTemplateElement | null | undefined} */ template: { type: Object, notify: true }, /** * Optional argument for `Polymer.Templatize.templatize`. */ instanceProps: { type: Object }, /** * References the content container after the template is stamped. * @type {!HTMLElement | undefined} */ content: { type: Object, notify: true }, /** * When true the overlay has backdrop on top of content when opened. * @type {boolean} */ withBackdrop: { type: Boolean, value: false, reflectToAttribute: true }, /** * Object with properties that is passed to `renderer` function */ model: Object, /** * When true the overlay won't disable the main content, showing * it doesn’t change the functionality of the user interface. * @type {boolean} */ modeless: { type: Boolean, value: false, reflectToAttribute: true, observer: '_modelessChanged' }, /** * When set to true, the overlay is hidden. This also closes the overlay * immediately in case there is a closing animation in progress. * @type {boolean} */ hidden: { type: Boolean, reflectToAttribute: true, observer: '_hiddenChanged' }, /** * When true move focus to the first focusable element in the overlay, * or to the overlay if there are no focusable elements. * @type {boolean} */ focusTrap: { type: Boolean, value: false }, /** * Set to true to enable restoring of focus when overlay is closed. * @type {boolean} */ restoreFocusOnClose: { type: Boolean, value: false }, /** @private */ _mouseDownInside: { type: Boolean }, /** @private */ _mouseUpInside: { type: Boolean }, /** @private */ _instance: { type: Object }, /** @private */ _originalContentPart: Object, /** @private */ _contentNodes: Array, /** @private */ _oldOwner: Element, /** @private */ _oldModel: Object, /** @private */ _oldTemplate: Object, /** @private */ _oldInstanceProps: Object, /** @private */ _oldRenderer: Object, /** @private */ _oldOpened: Boolean }; } static get observers() { return ['_templateOrRendererChanged(template, renderer, owner, model, instanceProps, opened)']; } constructor() { super(); this._boundMouseDownListener = this._mouseDownListener.bind(this); this._boundMouseUpListener = this._mouseUpListener.bind(this); this._boundOutsideClickListener = this._outsideClickListener.bind(this); this._boundKeydownListener = this._keydownListener.bind(this); this._observer = new FlattenedNodesObserver(this, (info) => { this._setTemplateFromNodes(info.addedNodes); }); // Listener for preventing closing of the paper-dialog and all components extending `iron-overlay-behavior`. this._boundIronOverlayCanceledListener = this._ironOverlayCanceled.bind(this); /* c8 ignore next 3 */ if (isIOS) { this._boundIosResizeListener = () => this._detectIosNavbar(); } } /** @protected */ ready() { super.ready(); this._observer.flush(); // Need to add dummy click listeners to this and the backdrop or else // the document click event listener (_outsideClickListener) may never // get invoked on iOS Safari (reproducible in <logo-elements-dialog> // and <logo-elements-context-menu>). this.addEventListener('click', () => {}); this.$.backdrop.addEventListener('click', () => {}); } /** @private */ _detectIosNavbar() { /* c8 ignore next 15 */ if (!this.opened) { return; } const innerHeight = window.innerHeight; const innerWidth = window.innerWidth; const landscape = innerWidth > innerHeight; const clientHeight = document.documentElement.clientHeight; if (landscape && clientHeight > innerHeight) { this.style.setProperty('--logo-elements-overlay-viewport-bottom', clientHeight - innerHeight + 'px'); } else { this.style.setProperty('--logo-elements-overlay-viewport-bottom', '0'); } } /** * @param {!Array<!Element>} nodes * @protected */ _setTemplateFromNodes(nodes) { this.template = nodes.filter((node) => node.localName && node.localName === 'template')[0] || this.template; } /** * @param {Event=} sourceEvent * @event logo-elements-overlay-close * fired before the `logo-elements-overlay` will be closed. If canceled the closing of the overlay is canceled as well. */ close(sourceEvent) { var evt = new CustomEvent('logo-elements-overlay-close', { bubbles: true, cancelable: true, detail: { sourceEvent: sourceEvent } }); this.dispatchEvent(evt); if (!evt.defaultPrevented) { this.opened = false; } } /** @protected */ connectedCallback() { super.connectedCallback(); /* c8 ignore next 3 */ if (this._boundIosResizeListener) { this._detectIosNavbar(); window.addEventListener('resize', this._boundIosResizeListener); } } /** @protected */ disconnectedCallback() { super.disconnectedCallback(); /* c8 ignore next 3 */ if (this._boundIosResizeListener) { window.removeEventListener('resize', this._boundIosResizeListener); } } /** * Requests an update for the content of the overlay. * While performing the update, it invokes the renderer passed in the `renderer` property. * * It is not guaranteed that the update happens immediately (synchronously) after it is requested. */ requestContentUpdate() { if (this.renderer) { this.renderer.call(this.owner, this.content, this.owner, this.model); } } /** @private */ _ironOverlayCanceled(event) { event.preventDefault(); } /** @private */ _mouseDownListener(event) { this._mouseDownInside = event.composedPath().indexOf(this.$.overlay) >= 0; } /** @private */ _mouseUpListener(event) { this._mouseUpInside = event.composedPath().indexOf(this.$.overlay) >= 0; } /** * We need to listen on 'click' / 'tap' event and capture it and close the overlay before * propagating the event to the listener in the button. Otherwise, if the clicked button would call * open(), this would happen: https://www.youtube.com/watch?v=Z86V_ICUCD4 * * @event logo-elements-overlay-outside-click * fired before the `logo-elements-overlay` will be closed on outside click. If canceled the closing of the overlay is canceled as well. * * @private */ _outsideClickListener(event) { if (event.composedPath().indexOf(this.$.overlay) !== -1 || this._mouseDownInside || this._mouseUpInside) { this._mouseDownInside = false; this._mouseUpInside = false; return; } if (!this._last) { return; } const evt = new CustomEvent('logo-elements-overlay-outside-click', { bubbles: true, cancelable: true, detail: { sourceEvent: event } }); this.dispatchEvent(evt); if (this.opened && !evt.defaultPrevented) { this.close(event); } } /** * @event logo-elements-overlay-escape-press * fired before the `logo-elements-overlay` will be closed on ESC button press. If canceled the closing of the overlay is canceled as well. * * @private */ _keydownListener(event) { if (!this._last) { return; } // TAB if (event.key === 'Tab' && this.focusTrap && !event.defaultPrevented) { // if only tab key is pressed, cycle forward, else cycle backwards. this._cycleTab(event.shiftKey ? -1 : 1); event.preventDefault(); // ESC } else if (event.key === 'Escape' || event.key === 'Esc') { const evt = new CustomEvent('logo-elements-overlay-escape-press', { bubbles: true, cancelable: true, detail: { sourceEvent: event } }); this.dispatchEvent(evt); if (this.opened && !evt.defaultPrevented) { this.close(event); } } } /** @protected */ _ensureTemplatized() { this._setTemplateFromNodes(Array.from(this.children)); } /** * @event logo-elements-overlay-open * fired after the `logo-elements-overlay` is opened. * * @private */ _openedChanged(opened, wasOpened) { if (!this._instance) { this._ensureTemplatized(); } if (opened) { // Store focused node. this.__restoreFocusNode = this._getActiveElement(); this._animatedOpening(); afterNextRender(this, () => { if (this.focusTrap && !this.contains(document.activeElement)) { this._cycleTab(0, 0); } const evt = new CustomEvent('logo-elements-overlay-open', { bubbles: true }); this.dispatchEvent(evt); }); if (!this.modeless) { this._addGlobalListeners(); } } else if (wasOpened) { this._animatedClosing(); if (!this.modeless) { this._removeGlobalListeners(); } } } /** @private */ _hiddenChanged(hidden) { if (hidden && this.hasAttribute('closing')) { this._flushAnimation('closing'); } } /** * @return {boolean} * @protected */ _shouldAnimate() { const name = getComputedStyle(this).getPropertyValue('animation-name'); const hidden = getComputedStyle(this).getPropertyValue('display') === 'none'; return !hidden && name && name != 'none'; } /** * @param {string} type * @param {Function} callback * @protected */ _enqueueAnimation(type, callback) { const handler = `__${type}Handler`; const listener = (event) => { if (event && event.target !== this) { return; } callback(); this.removeEventListener('animationend', listener); delete this[handler]; }; this[handler] = listener; this.addEventListener('animationend', listener); } /** * @param {string} type * @protected */ _flushAnimation(type) { const handler = `__${type}Handler`; if (typeof this[handler] === 'function') { this[handler](); } } /** @protected */ _animatedOpening() { if (this.parentNode === document.body && this.hasAttribute('closing')) { this._flushAnimation('closing'); } this._attachOverlay(); if (!this.modeless) { this._enterModalState(); } this.setAttribute('opening', ''); const finishOpening = () => { document.addEventListener('iron-overlay-canceled', this._boundIronOverlayCanceledListener); this.removeAttribute('opening'); }; if (this._shouldAnimate()) { this._enqueueAnimation('opening', finishOpening); } else { finishOpening(); } } /** @protected */ _attachOverlay() { this._placeholder = document.createComment('logo-elements-overlay-placeholder'); this.parentNode.insertBefore(this._placeholder, this); document.body.appendChild(this); this.bringToFront(); } /** @protected */ _animatedClosing() { if (this.hasAttribute('opening')) { this._flushAnimation('opening'); } if (this._placeholder) { this._exitModalState(); if (this.restoreFocusOnClose && this.__restoreFocusNode) { // If the activeElement is `<body>` or inside the overlay, // we are allowed to restore the focus. In all the other // cases focus might have been moved elsewhere by another // component or by the user interaction (e.g. click on a // button outside the overlay). const activeElement = this._getActiveElement(); if (activeElement === document.body || this._deepContains(activeElement)) { this.__restoreFocusNode.focus(); } this.__restoreFocusNode = null; } this.setAttribute('closing', ''); const finishClosing = () => { document.removeEventListener('iron-overlay-canceled', this._boundIronOverlayCanceledListener); this._detachOverlay(); this.shadowRoot.querySelector('[part="overlay"]').style.removeProperty('pointer-events'); this.removeAttribute('closing'); }; if (this._shouldAnimate()) { this._enqueueAnimation('closing', finishClosing); } else { finishClosing(); } } } /** @protected */ _detachOverlay() { this._placeholder.parentNode.insertBefore(this, this._placeholder); this._placeholder.parentNode.removeChild(this._placeholder); } /** * Returns all attached overlays in visual stacking order. * @private */ static get __attachedInstances() { return Array.from(document.body.children) .filter((el) => el instanceof OverlayElement && !el.hasAttribute('closing')) .sort((a, b) => a.__zIndex - b.__zIndex || 0); } /** * returns true if this is the last one in the opened overlays stack * @return {boolean} * @protected */ get _last() { return this === OverlayElement.__attachedInstances.pop(); } /** @private */ _modelessChanged(modeless) { if (!modeless) { if (this.opened) { this._addGlobalListeners(); this._enterModalState(); } } else { this._removeGlobalListeners(); this._exitModalState(); } } /** @protected */ _addGlobalListeners() { document.addEventListener('mousedown', this._boundMouseDownListener); document.addEventListener('mouseup', this._boundMouseUpListener); // Firefox leaks click to document on contextmenu even if prevented // https://bugzilla.mozilla.org/show_bug.cgi?id=990614 document.documentElement.addEventListener('click', this._boundOutsideClickListener, true); document.addEventListener('keydown', this._boundKeydownListener); } /** @protected */ _enterModalState() { if (document.body.style.pointerEvents !== 'none') { // Set body pointer-events to 'none' to disable mouse interactions with // other document nodes. this._previousDocumentPointerEvents = document.body.style.pointerEvents; document.body.style.pointerEvents = 'none'; } // Disable pointer events in other attached overlays OverlayElement.__attachedInstances.forEach((el) => { if (el !== this) { el.shadowRoot.querySelector('[part="overlay"]').style.pointerEvents = 'none'; } }); } /** @protected */ _removeGlobalListeners() { document.removeEventListener('mousedown', this._boundMouseDownListener); document.removeEventListener('mouseup', this._boundMouseUpListener); document.documentElement.removeEventListener('click', this._boundOutsideClickListener, true); document.removeEventListener('keydown', this._boundKeydownListener); } /** @protected */ _exitModalState() { if (this._previousDocumentPointerEvents !== undefined) { // Restore body pointer-events document.body.style.pointerEvents = this._previousDocumentPointerEvents; delete this._previousDocumentPointerEvents; } // Restore pointer events in the previous overlay(s) const instances = OverlayElement.__attachedInstances; let el; // Use instances.pop() to ensure the reverse order while ((el = instances.pop())) { if (el === this) { // Skip the current instance continue; } el.shadowRoot.querySelector('[part="overlay"]').style.removeProperty('pointer-events'); if (!el.modeless) { // Stop after the last modal break; } } } /** @protected */ _removeOldContent() { if (!this.content || !this._contentNodes) { return; } this._observer.disconnect(); this._contentNodes.forEach((node) => { if (node.parentNode === this.content) { this.content.removeChild(node); } }); if (this._originalContentPart) { // Restore the original <div part="content"> this.$.content.parentNode.replaceChild(this._originalContentPart, this.$.content); this.$.content = this._originalContentPart; this._originalContentPart = undefined; } this._observer.connect(); this._contentNodes = undefined; this.content = undefined; } /** * @param {!HTMLTemplateElement} template * @param {object} instanceProps * @protected */ _stampOverlayTemplate(template, instanceProps) { this._removeOldContent(); if (!template._Templatizer) { template._Templatizer = templatize(template, this, { instanceProps: instanceProps, forwardHostProp: function (prop, value) { if (this._instance) { this._instance.forwardHostProp(prop, value); } } }); } this._instance = new template._Templatizer({}); this._contentNodes = Array.from(this._instance.root.childNodes); const templateRoot = template._templateRoot || (template._templateRoot = template.getRootNode()); if (templateRoot !== document) { if (!this.$.content.shadowRoot) { this.$.content.attachShadow({ mode: 'open' }); } let scopeCssText = Array.from(templateRoot.querySelectorAll('style')).reduce( (result, style) => result + style.textContent, '' ); // The overlay root’s :host styles should not apply inside the overlay scopeCssText = scopeCssText.replace(/:host/g, ':host-nomatch'); if (scopeCssText) { // Append a style to the content shadowRoot const style = document.createElement('style'); style.textContent = scopeCssText; this.$.content.shadowRoot.appendChild(style); this._contentNodes.unshift(style); } this.$.content.shadowRoot.appendChild(this._instance.root); this.content = this.$.content.shadowRoot; } else { this.appendChild(this._instance.root); this.content = this; } } /** @private */ _removeNewRendererOrTemplate(template, oldTemplate, renderer, oldRenderer) { if (template !== oldTemplate) { this.template = undefined; } else if (renderer !== oldRenderer) { this.renderer = undefined; } } /** @private */ _templateOrRendererChanged(template, renderer, owner, model, instanceProps, opened) { if (template && renderer) { this._removeNewRendererOrTemplate(template, this._oldTemplate, renderer, this._oldRenderer); throw new Error('You should only use either a renderer or a template for overlay content'); } const ownerOrModelChanged = this._oldOwner !== owner || this._oldModel !== model; this._oldModel = model; this._oldOwner = owner; const templateOrInstancePropsChanged = this._oldInstanceProps !== instanceProps || this._oldTemplate !== template; this._oldInstanceProps = instanceProps; this._oldTemplate = template; const rendererChanged = this._oldRenderer !== renderer; this._oldRenderer = renderer; const openedChanged = this._oldOpened !== opened; this._oldOpened = opened; if (rendererChanged) { this.content = this; this.content.innerHTML = ''; // Whenever a Lit-based renderer is used, it assigns a Lit part to the node it was rendered into. // When clearing the rendered content, this part needs to be manually disposed of. // Otherwise, using a Lit-based renderer on the same node will throw an exception or render nothing afterward. delete this.content._$litPart$; } if (template && templateOrInstancePropsChanged) { this._stampOverlayTemplate(template, instanceProps); } else if (renderer && (rendererChanged || openedChanged || ownerOrModelChanged)) { if (opened) { this.requestContentUpdate(); } } } /** * @param {Element} element * @return {boolean} * @protected */ _isFocused(element) { return element && element.getRootNode().activeElement === element; } /** * @param {Element[]} elements * @return {number} * @protected */ _focusedIndex(elements) { elements = elements || this._getFocusableElements(); return elements.indexOf(elements.filter(this._isFocused).pop()); } /** * @param {number} increment * @param {number | undefined} index * @protected */ _cycleTab(increment, index) { const focusableElements = this._getFocusableElements(); if (index === undefined) { index = this._focusedIndex(focusableElements); } index += increment; // rollover to first item if (index >= focusableElements.length) { index = 0; // go to last item } else if (index < 0) { index = focusableElements.length - 1; } focusableElements[index].focus(); } /** * @return {!Array<!HTMLElement>} * @protected */ _getFocusableElements() { // collect all focusable elements return FocusablesHelper.getTabbableNodes(this.$.overlay); } /** * @return {!Element} * @protected */ _getActiveElement() { // document.activeElement can be null // https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement let active = document.activeElement || document.body; while (active.shadowRoot && active.shadowRoot.activeElement) { active = active.shadowRoot.activeElement; } return active; } /** * @param {!Node} node * @return {boolean} * @protected */ _deepContains(node) { if (this.contains(node)) { return true; } let n = node; const doc = node.ownerDocument; // walk from node to `this` or `document` while (n && n !== doc && n !== this) { n = n.parentNode || n.host; } return n === this; } /** * Brings the overlay as visually the frontmost one */ bringToFront() { let zIndex = ''; const frontmost = OverlayElement.__attachedInstances.filter((o) => o !== this).pop(); if (frontmost) { const frontmostZIndex = frontmost.__zIndex; zIndex = frontmostZIndex + 1; } this.style.zIndex = zIndex; this.__zIndex = zIndex || parseFloat(getComputedStyle(this).zIndex); } } if (!customElements.get(OverlayElement.is)) { customElements.define(OverlayElement.is, OverlayElement); } // customElements.define(OverlayElement.is, OverlayElement); export { OverlayElement };