UNPKG

@telekom/scale-components

Version:

Scale is the digital design system for Telekom products and experiences.

386 lines (379 loc) 21.6 kB
import { proxyCustomElement, HTMLElement, createEvent, h, Host } from '@stencil/core/internal/client'; import { c as classnames } from './index2.js'; import { e as emitEvent } from './utils.js'; import { d as defineCustomElement$2 } from './action-circle-close.js'; /** * Copy/pasted from https://github.com/andreasbm/focus-trap */ /** * Traverses the slots of the open shadowroots and returns all children matching the query. * We need to traverse each child-depth one at a time because if an element should be skipped * (for example because it is hidden) we need to skip all of it's children. If we use querySelectorAll("*") * the information of whether the children is within a hidden parent is lost. * @param {ShadowRoot | HTMLElement} root * @param skipNode * @param isMatch * @param {number} maxDepth * @param {number} depth * @returns {HTMLElement[]} */ function queryShadowRoot(root, skipNode, isMatch, maxDepth = 20, depth = 0) { const matches = []; // If the depth is above the max depth, abort the searching here. if (depth >= maxDepth) { return matches; } // Traverses a slot element const traverseSlot = ($slot) => { // Only check nodes that are of the type Node.ELEMENT_NODE // Read more here https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType const assignedNodes = $slot .assignedNodes() .filter((node) => node.nodeType === 1); if (assignedNodes.length > 0) { const $slotParent = assignedNodes[0].parentElement; return queryShadowRoot($slotParent, skipNode, isMatch, maxDepth, depth + 1); } return []; }; // Go through each child and continue the traversing if necessary // Even though the typing says that children can't be undefined, Edge 15 sometimes gives an undefined value. // Therefore we fallback to an empty array if it is undefined. const children = Array.from(root.children || []); for (const $child of children) { // Check if the element and its descendants should be skipped if (skipNode($child)) { // console.log('-- SKIP', $child); continue; } // console.log('$child', $child); // If the element matches we always add it if (isMatch($child)) { matches.push($child); } if ($child.shadowRoot != null) { // If the element has a shadow root we need to traverse it matches.push(...queryShadowRoot($child.shadowRoot, skipNode, isMatch, maxDepth, depth + 1)); } else if ($child.tagName === 'SLOT') { // If the child is a slot we need to traverse each assigned node matches.push(...traverseSlot($child)); } else { // Traverse the children of the element matches.push(...queryShadowRoot($child, skipNode, isMatch, maxDepth, depth + 1)); } } return matches; } /** * Returns whether the element is hidden. * @param $elem */ function isHidden($elem) { return ($elem.hasAttribute('hidden') || ($elem.hasAttribute('aria-hidden') && $elem.getAttribute('aria-hidden') !== 'false') || // A quick and dirty way to check whether the element is hidden. // For a more fine-grained check we could use "window.getComputedStyle" but we don't because of bad performance. // If the element has visibility set to "hidden" or "collapse", display set to "none" or opacity set to "0" through CSS // we won't be able to catch it here. We accept it due to the huge performance benefits. $elem.style.display === `none` || $elem.style.opacity === `0` || $elem.style.visibility === `hidden` || $elem.style.visibility === `collapse`); // If offsetParent is null we can assume that the element is hidden // https://stackoverflow.com/questions/306305/what-would-make-offsetparent-null // || $elem.offsetParent == null; } /** * Returns whether the element is disabled. * @param $elem */ function isDisabled($elem) { return ($elem.hasAttribute('disabled') || ($elem.hasAttribute('aria-disabled') && $elem.getAttribute('aria-disabled') !== 'false')); } /** * Determines whether an element is focusable. * Read more here: https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus/1600194#1600194 * Or here: https://stackoverflow.com/questions/18261595/how-to-check-if-a-dom-element-is-focusable * @param $elem */ function isFocusable($elem) { // Discard elements that are removed from the tab order. if ($elem.getAttribute('tabindex') === '-1' || isHidden($elem) || isDisabled($elem)) { return false; } return ( // At this point we know that the element can have focus (eg. won't be -1) if the tabindex attribute exists $elem.hasAttribute('tabindex') || // Anchor tags or area tags with a href set (($elem instanceof HTMLAnchorElement || $elem instanceof HTMLAreaElement) && $elem.hasAttribute('href')) || // Form elements which are not disabled $elem instanceof HTMLButtonElement || $elem instanceof HTMLInputElement || $elem instanceof HTMLTextAreaElement || $elem instanceof HTMLSelectElement || // IFrames $elem instanceof HTMLIFrameElement); } /** * @license * Scale https://github.com/telekom/scale * * Copyright (c) 2021 Egor Kirpichev and contributors, Deutsche Telekom AG * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * @see https://www.youtube.com/watch?v=9-6CKCz58A8 */ function animateTo(element, keyframes, options) { const anim = element.animate(keyframes, Object.assign(Object.assign({}, options), { fill: 'both' })); anim.addEventListener('finish', () => { // @ts-ignore anim.commitStyles(); anim.cancel(); }); return anim; } const keyframeDefaults = { easing: 'cubic-bezier(0.390, 0.575, 0.565, 1.000)', }; const KEYFRAMES = { fadeIn: [ Object.assign(Object.assign({ offset: 0 }, keyframeDefaults), { opacity: 0 }), Object.assign(Object.assign({ offset: 1 }, keyframeDefaults), { opacity: 1 }), ], fadeOut: [ Object.assign(Object.assign({ offset: 0 }, keyframeDefaults), { opacity: 1 }), Object.assign(Object.assign({ offset: 1 }, keyframeDefaults), { opacity: 0 }), ], fadeInTop: [ Object.assign(Object.assign({ offset: 0 }, keyframeDefaults), { opacity: 0, /** * we are not using transform here to avoid breaking positioning for nested fixed elements (i.e. a flyout menu in an animated modal) * see 'fixed' section @link https://developer.mozilla.org/en-US/docs/Web/CSS/position */ top: '-3rem' }), Object.assign(Object.assign({ offset: 1 }, keyframeDefaults), { opacity: 1, top: 0 }), ], }; const modalCss = ":host{--spacing-x:var(--telekom-spacing-composition-space-06);--background-overlay:var(\n --telekom-color-background-backdrop,\n rgba(108, 108, 108, 0.7)\n );--max-height-window:calc(\n 100vh - (2 * var(--telekom-spacing-composition-space-19))\n );--radius-window:var(--telekom-radius-large);--box-shadow-window:var(--telekom-shadow-top);--background-window:var(--telekom-color-background-surface);--color-window:var(--telekom-color-text-and-icon-standard);--size-window-small:calc(\n (6 * var(--telekom-spacing-unit-x14, 3.5rem)) +\n (5 * var(--telekom-spacing-composition-space-10))\n );--size-window-default:calc(\n (8 * var(--telekom-spacing-unit-x14, 3.5rem)) +\n (7 * var(--telekom-spacing-composition-space-10))\n );--size-window-large:calc(\n (12 * var(--telekom-spacing-unit-x14, 3.5rem)) +\n (11 * var(--telekom-spacing-composition-space-10))\n );--spacing-x-header:var(--telekom-spacing-composition-space-08);--spacing-y-header:var(--telekom-spacing-composition-space-08);--border-bottom-header-has-scroll:var(--telekom-line-weight-standard) solid\n var(--telekom-color-ui-subtle);--font-family-heading:var(--telekom-typography-font-family-sans);--font-size-heading:var(--telekom-typography-font-size-callout);--font-weight-heading:var(--telekom-typography-font-weight-extra-bold);--spacing-close-button:var(--telekom-spacing-composition-space-04);--radius-close-button:var(--telekom-radius-standard);--transition-close-button:all var(--telekom-motion-duration-transition)\n var(--telekom-motion-easing-standard);--box-shadow-close-button-focus:0 0 0 var(--telekom-line-weight-highlight)\n var(--telekom-color-functional-focus-standard);--color-close-button:var(--telekom-color-text-and-icon-standard);--color-close-button-hover:var(--telekom-color-primary-hovered);--color-close-button-active:var(--telekom-color-primary-pressed);--spacing-x-body-wrapper:var(--telekom-spacing-composition-space-08);--spacing-y-body:var(--telekom-spacing-composition-space-08);--spacing-actions:var(--telekom-spacing-composition-space-06)\n var(--telekom-spacing-composition-space-08)\n var(--telekom-spacing-composition-space-08);--spacing-x-actions-slotted:var(--telekom-spacing-composition-space-04);--background-actions-has-scroll:var(\n --telekom-color-background-surface-subtle\n )}.modal{top:0;left:0;width:100%;bottom:0;display:none;z-index:100;position:fixed;background:var(--background-overlay);box-sizing:border-box;align-items:center;justify-content:center;padding-left:var(--spacing-x);padding-right:var(--spacing-x)}.modal.modal--is-open{display:flex}.modal__backdrop{top:0;left:0;width:100%;height:100%;z-index:0;position:absolute}.modal__window{width:100%;height:auto;display:flex;z-index:1;position:relative;overflow-y:auto;flex-direction:column;background-color:var(--background-window);color:var(--color-window);max-height:var(--max-height-window);min-height:var(--min-height-window);border-radius:var(--radius-window);box-shadow:var(--box-shadow-window)}.modal__window .modal__body-wrapper{overflow-y:auto;flex-shrink:1;flex-grow:1}.modal--size-small .modal__window{max-width:var(--size-window-small)}.modal--size-default .modal__window{max-width:var(--size-window-default)}.modal--size-large .modal__window{max-width:var(--size-window-large)}@media (max-height: 30em){.modal__window{max-height:calc(100vh - var(--telekom-spacing-composition-space-08))}}.modal__window:after{top:0;left:0;width:100%;border:1px solid transparent;height:100%;content:'';display:block;position:absolute;box-sizing:border-box;pointer-events:none;border-radius:var(--radius-window)}.modal__header{display:flex;align-items:flex-start;flex-shrink:0;justify-content:space-between;margin-left:var(--spacing-x-header);margin-right:var(--spacing-x-header);padding-top:var(--spacing-y-header);padding-bottom:var(--spacing-y-header)}.modal--has-scroll .modal__header{border-bottom:var(--border-bottom-header-has-scroll)}.modal__heading{margin:0;font-family:var(--font-family-heading);font-size:var(--font-size-heading);font-weight:var(--font-weight-heading)}.modal__close-button{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;padding:var(--spacing-close-button);margin-bottom:calc(-2 * var(--spacing-close-button));border:0;border-radius:var(--radius-close-button);outline:none;color:var(--color-close-button);background:transparent;transition:var(--transition-close-button);transform:translate(\n var(--spacing-close-button),\n calc(-1 * var(--spacing-close-button))\n );appearance:none;cursor:pointer;user-select:none}.modal__close-button:focus{box-shadow:var(--box-shadow-close-button-focus)}.modal__close-button:hover{color:var(--color-close-button-hover)}.modal__close-button:active{color:var(--color-close-button-active)}.modal__body-wrapper{padding-left:var(--spacing-x-body-wrapper);padding-right:var(--spacing-x-body-wrapper)}.modal--has-body .modal__body-wrapper{min-height:var(--telekom-spacing-related-lg)}.modal--has-body .modal__body{margin-top:0;margin-bottom:0}.modal--has-body:not(.modal--has-actions) .modal__body{margin-bottom:var(--spacing-y-body)}.modal--has-scroll.modal--has-body .modal__body{margin-top:var(--spacing-y-body);margin-bottom:var(--spacing-y-body)}.modal--has-body .modal__body ::slotted(*){font:var(--telekom-text-style-body)}.modal--has-body .modal__body ::slotted(*:not([slot]):first-child){margin-top:0}.modal--has-body .modal__body ::slotted(*:not([slot]):last-of-type){margin-bottom:0}.modal__actions{display:none;flex-shrink:0;justify-content:flex-end;padding:var(--spacing-actions)}.modal__actions ::slotted(*){margin-left:var(--spacing-x-actions-slotted)}.modal--has-actions .modal__actions{display:flex}.modal--align-actions-left .modal__actions{justify-content:flex-start}.modal--has-scroll .modal__actions{background-color:var(--background-actions-has-scroll)}"; const supportsResizeObserver = 'ResizeObserver' in window; const Modal = /*@__PURE__*/ proxyCustomElement(class extends HTMLElement { constructor() { super(); this.__registerHost(); this.__attachShadow(); this.scaleOpen = createEvent(this, "scale-open", 7); this.scaleOpenLegacy = createEvent(this, "scaleOpen", 7); this.scaleBeforeClose = createEvent(this, "scale-before-close", 7); this.scaleBeforeCloseLegacy = createEvent(this, "scaleBeforeClose", 7); this.scaleClose = createEvent(this, "scale-close", 7); this.scaleCloseLegacy = createEvent(this, "scaleClose", 7); /** (optional) Modal size */ this.size = 'default'; /** (optional) If `true`, the Modal is open. */ this.opened = false; /** (optional) Transition duration */ this.duration = 200; /** (optional) Label for close button */ this.closeButtonLabel = 'Close'; /** (optional) title for close button */ this.closeButtonTitle = 'Close'; /** (optional) hide close button */ this.omitCloseButton = false; /** (optional) Alignment of action buttons */ this.alignActions = 'right'; /** (optional) allow to inject css style {overflow: hidden} to body when modal is open */ this.allowInjectingStyleToBody = false; /** What actually triggers opening/closing the modal */ this.isOpen = this.opened || false; /** Check wheter there are actions slots, style accordingly */ this.hasActionsSlot = false; /** Check wheter there's content in the body, style accordingly */ this.hasBody = false; /** Useful for toggling scroll-specific styles */ this.hasScroll = false; /** store document body original overflow style if applicable, this is useful when modal opens and inject overflow style to body */ this.bodyOverflowValue = ''; this.focusableElements = []; this.handleKeypress = (event) => { if (!this.isOpen) { return; } if (event.key === 'Escape') { this.emitBeforeClose('ESCAPE_KEY'); } }; this.handleTopFocus = () => { this.attemptFocus(this.getLastFocusableElement()); }; this.handleBottomFocus = () => { this.attemptFocus(this.getFirstFocusableElement()); }; } disconnectedCallback() { if (this.resizeObserver) { this.resizeObserver.disconnect(); } } /** * Set `hasActionsSlot` and `hasBody`. */ componentWillRender() { const actionSlots = this.hostElement.querySelectorAll('[slot="action"]'); const bodySlot = Array.from(this.hostElement.shadowRoot.querySelectorAll('slot')).find((x) => !x.name); this.hasActionsSlot = actionSlots.length > 0; if (bodySlot != null) { this.hasBody = bodySlot.assignedElements().length > 0; } } emitBeforeClose(trigger) { const emittedEvents = emitEvent(this, 'scaleBeforeClose', { trigger }); const prevented = emittedEvents.some((event) => event.defaultPrevented); if (!prevented) { this.opened = false; } } componentDidLoad() { // Query all focusable elements and store them in `focusableElements`. // Needed for the "focus trap" functionality. this.focusableElements = queryShadowRoot(this.hostElement.shadowRoot, (el) => isHidden(el) || el.matches('[data-focus-trap-edge]'), isFocusable); // Set `hasScroll` state dynamically on resize. if (supportsResizeObserver) { // @ts-ignore this.resizeObserver = new ResizeObserver(() => { this.setHasScroll(); }); this.resizeObserver.observe(this.modalBody); } this.setHasScroll(); } setHasScroll() { const container = this.modalBody; this.hasScroll = container.scrollHeight > container.clientHeight; } getFirstFocusableElement() { return this.focusableElements[0]; } getLastFocusableElement() { return this.focusableElements[this.focusableElements.length - 1]; } attemptFocus(element) { if (element == null) { this.closeButton.focus(); return; } element.focus(); } openedChanged(newValue) { if (newValue === true) { this.open(); if (this.allowInjectingStyleToBody) { this.bodyOverflowValue = document.body.style.overflow; // The following style will disable body from scrolling when modal is open document.body.style.setProperty('overflow', 'hidden'); } } else { this.close(); if (this.allowInjectingStyleToBody) { // remove injected overflow style or set it to original value document.body.style.setProperty('overflow', this.bodyOverflowValue); } } } open() { this.isOpen = true; try { animateTo(this.modalWindow, KEYFRAMES.fadeInTop, { duration: this.duration, delay: this.duration * 0.5, }); const anim = animateTo(this.modalContainer, KEYFRAMES.fadeIn, { duration: this.duration, }); anim.addEventListener('finish', () => { this.attemptFocus(this.getFirstFocusableElement()); emitEvent(this, 'scaleOpen'); }); this.hostElement.addEventListener('keydown', this.handleKeypress); } catch (err) { emitEvent(this, 'scaleOpen'); } } close() { try { const anim = animateTo(this.modalContainer, KEYFRAMES.fadeOut, { duration: this.duration, }); anim.addEventListener('finish', () => { this.isOpen = false; emitEvent(this, 'scaleClose'); }); this.hostElement.removeEventListener('keydown', this.handleKeypress); } catch (err) { this.isOpen = false; emitEvent(this, 'scaleClose'); } } render() { return (h(Host, null, this.styles && h("style", null, this.styles), h("div", { ref: (el) => (this.modalContainer = el), class: this.getCssClassMap(), part: classnames('base', this.isOpen && 'open') }, h("div", { class: "modal__backdrop", part: "backdrop", onClick: () => this.emitBeforeClose('BACKDROP') }), h("div", { "data-focus-trap-edge": true, onFocus: this.handleTopFocus, tabindex: "0" }), h("div", { class: "modal__window", part: classnames('window', this.size && `size-${this.size}`), ref: (el) => (this.modalWindow = el), role: "dialog", "aria-modal": "true", "aria-label": this.heading }, h("div", { class: "modal__header", part: classnames('header', this.hasScroll && 'has-scroll') }, h("h2", { class: "modal__heading", part: "heading" }, this.heading), !this.omitCloseButton && (h("button", { ref: (el) => (this.closeButton = el), class: "modal__close-button", part: "close-button", onClick: () => this.emitBeforeClose('CLOSE_BUTTON'), "aria-label": this.closeButtonLabel, title: this.closeButtonTitle }, h("slot", { name: "close-icon" }, h("scale-icon-action-circle-close", { decorative: true }))))), h("div", { ref: (el) => (this.modalBody = el), class: "modal__body-wrapper", part: classnames('body-wrapper', this.hasBody && 'has-body') }, h("div", { class: "modal__body", part: classnames('body', this.hasBody && 'has-body') }, h("slot", null))), h("div", { class: "modal__actions", part: classnames('actions', `align-${this.alignActions}`, this.hasActionsSlot && 'has-actions', this.hasScroll && 'has-scroll') }, h("slot", { name: "action" }))), h("div", { "data-focus-trap-edge": true, onFocus: this.handleBottomFocus, tabindex: "0" })))); } getCssClassMap() { return classnames('modal', this.isOpen && 'modal--is-open', this.hasActionsSlot && 'modal--has-actions', `modal--align-actions-${this.alignActions}`, this.hasScroll && 'modal--has-scroll', this.hasBody && 'modal--has-body', this.size && `modal--size-${this.size}`); } get hostElement() { return this; } static get watchers() { return { "opened": ["openedChanged"] }; } static get style() { return modalCss; } }, [1, "scale-modal", { "heading": [1], "size": [1], "opened": [1540], "duration": [2], "closeButtonLabel": [1, "close-button-label"], "closeButtonTitle": [1, "close-button-title"], "omitCloseButton": [4, "omit-close-button"], "alignActions": [1, "align-actions"], "styles": [1], "allowInjectingStyleToBody": [4, "allow-injecting-style-to-body"], "isOpen": [32], "hasActionsSlot": [32], "hasBody": [32], "hasScroll": [32], "bodyOverflowValue": [32] }]); function defineCustomElement$1() { if (typeof customElements === "undefined") { return; } const components = ["scale-modal", "scale-icon-action-circle-close"]; components.forEach(tagName => { switch (tagName) { case "scale-modal": if (!customElements.get(tagName)) { customElements.define(tagName, Modal); } break; case "scale-icon-action-circle-close": if (!customElements.get(tagName)) { defineCustomElement$2(); } break; } }); } const ScaleModal = Modal; const defineCustomElement = defineCustomElement$1; export { ScaleModal, defineCustomElement };