UNPKG

@telekom/scale-components

Version:

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

668 lines (667 loc) 19.3 kB
/** * @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/. */ import { Component, Prop, h, Host, Method, Element, Event, Listen, State, Watch, } from '@stencil/core'; import classNames from 'classnames'; import { emitEvent } from '../../utils/utils'; const PAD = 10; const ITEM_ROLES = ['menuitem', 'menuitemcheckbox', 'menuitemradio']; export class MenuFlyoutList { constructor() { /** Used to force a re-render */ this.forceRender = 0; /** */ this.opened = false; /** (optional) Set preference for where the menu appears, space permitting */ this.direction = 'bottom-right'; /** */ this.active = false; /** (optional) Determines whether the flyout should close when a menu item is selected */ this.closeOnSelect = true; /** (optional) set to true when using in telekom-brand-header */ this.brandHeaderDropdown = false; /** Flags to know if content scrollable */ this.canScrollUp = false; this.canScrollDown = false; /** When menu off the screen horizontally */ this.flipHorizontal = false; /** When menu off the screen vertically */ this.flipVertical = false; /** Set true when resize or when opened */ this.needsCheckPlacement = true; this.handleScroll = () => { this.updateScrollIndicators(); }; this.handleWheel = (event) => { // TODO not sure this is doing anything atm this.stopWheelPropagation(event); }; } get triggerRect() { return this.trigger().getBoundingClientRect(); } componentDidRender() { if (this.opened && this.needsCheckPlacement) { this.setSize(); this.checkPlacement(); } } async open() { this.opened = true; emitEvent(this, 'scaleOpen', { list: this.hostElement }); } async close(silent = false) { if (this.active && silent !== true) { emitEvent(this, 'scaleClose', { list: this.hostElement }); } this.opened = false; } async setFocus() { if (this.focusedItemIndex != null) { this.focusItem(); } else { this.setInitialItemsFocus(); } } handleResize() { this.close(); } handleKeydown(event) { if (!this.active) { return; } if (!this.hostElement.querySelector('app-navigation-user-menu')) { event.preventDefault(); } if ('ArrowDown' === event.key) { this.shiftItemsFocus(); return; } if ('ArrowUp' === event.key) { this.shiftItemsFocus(-1); return; } if ('ArrowLeft' === event.key || 'Escape' === event.key) { this.close(); return; } if (' ' === event.key || 'Enter' === event.key || 'ArrowRight' === event.key) { const item = this.items[this.focusedItemIndex]; if (item != null) { item.triggerEvent(event, this.closeOnSelect); } } } /** * We handle item clicks here, to avoid setting up * listeners on every item */ handleClick(event) { const roleSelector = ITEM_ROLES.map((role) => `[role="${role}"]`).join(','); const item = event.target.closest(roleSelector); if (item != null) { event.stopImmediatePropagation(); item.triggerEvent(event, this.closeOnSelect); } } /** * Focus newly selected item */ handleScaleSelect({ detail }) { if (this.active && this.opened) { const index = this.items.findIndex((x) => x === detail.item); if (index != null) { this.focusedItemIndex = index; this.focusItem(); } } } /** * Set `active` to false when a descendant opens */ handleScaleOpen({ detail }) { if (detail.list !== this.hostElement) { this.active = false; } } openedChanged() { if (!this.opened) { this.active = false; this.focusedItemIndex = null; // Reset checks for boundary-aware placement this.needsCheckPlacement = true; this.flipHorizontal = false; this.flipVertical = false; this.hostElement.style.marginLeft = ''; this.hostElement.style.marginTop = ''; this.hostElement.style.marginRight = ''; this.hostElement.style.marginBottom = ''; if (this.trigger().tagName === 'SCALE-TELEKOM-NAV-ITEM') { this.trigger().style.color = 'var(--telekom-color-text-and-icon-standard)'; } } if (this.opened) { this.active = true; this.setFocus(); this.setWindowSize(); this.setPosition(); this.padForNonOverlayScrollbars(); this.updateScrollIndicators(); } this.updateTriggerAttributes(); } setInitialItemsFocus() { this.items = this.getListItems(); this.focusedItemIndex = -1; if (this.items.length > 0) { this.shiftItemsFocus(); } } shiftItemsFocus(direction = 1) { let nextIndex = this.focusedItemIndex + direction; if (nextIndex === this.items.length) { nextIndex = 0; } else if (nextIndex < 0) { nextIndex = this.items.length - 1; } this.focusedItemIndex = nextIndex; this.focusItem(); } focusItem() { window.requestAnimationFrame(() => { try { this.items[this.focusedItemIndex].focus(); } catch (err) { } }); } updateTriggerAttributes() { const trigger = this.trigger(); if (trigger && trigger.getAttribute('aria-haspopup') === 'true') { trigger.setAttribute('aria-expanded', String(this.opened)); } } setWindowSize() { this.windowWidth = window.innerWidth; this.windowHeight = window.innerHeight; } setPosition() { const { top, left } = this.triggerRect; this.hostElement.style.left = !this.brandHeaderDropdown ? `${left}px` : `${left - 4}px`; if (this.trigger().tagName === 'SCALE-TELEKOM-NAV-ITEM') { this.hostElement.style.top = `${top - 12}px`; this.hostElement.style.left = `${left - 24}px`; this.trigger().style.color = 'var(--telekom-color-text-and-icon-primary-standard)'; } else { this.hostElement.style.top = `${top}px`; } } setSize() { const { width, height } = this.triggerRect; this.hostElement.style.height = `${height}px`; this.hostElement.style.width = `${width}px`; if (this.brandHeaderDropdown) { this.base.style.width = `240px`; } } checkPlacement() { this.needsCheckPlacement = false; let isOutOfBounds = false; const rect = this.base.getBoundingClientRect(); // Check horizontal flips if (rect.left < PAD) { // console.log('off left edge'); isOutOfBounds = true; if (this.direction.includes('left')) { this.flipHorizontal = true; } } if (rect.right > this.windowWidth - PAD) { // console.log('off right edge'); isOutOfBounds = true; if (this.direction.includes('right')) { this.flipHorizontal = true; } } // Check vertical flips if (rect.top < PAD) { // console.log('off top edge'); isOutOfBounds = true; if (this.direction.includes('top')) { this.flipVertical = true; } } if (rect.bottom > this.windowHeight - PAD) { // console.log('off bottom edge'); isOutOfBounds = true; if (this.direction.includes('bottom')) { this.flipVertical = true; } } if (isOutOfBounds) { this.furtherAdjustPlacement(); } } furtherAdjustPlacement() { // Apply flip class changes immediately to avoid frame flash this.base.className = this.getCssClassMap(); // Force layout and style recalculation window.getComputedStyle(this.base); const rect = this.base.getBoundingClientRect(); // TODO: add more functionality for order of priority of which edge to snap to // Shift to be snapped to a padded edge // Note can't use transform as it creates // a relative parent for nested position fixed elements let left = 0; let top = 0; if (rect.left < PAD) { // console.log('still off left edge'); left = PAD - rect.left; } else if (rect.right > this.windowWidth - PAD) { // console.log('still off right edge'); left = this.windowWidth - PAD - rect.right; } if (rect.top < PAD) { // console.log('still off top edge'); top = PAD - rect.top; } else if (rect.bottom > this.windowHeight - PAD) { // console.log('still off bottom edge'); top = this.windowHeight - PAD - rect.bottom; } this.hostElement.style.marginLeft = `${left}px`; this.hostElement.style.marginTop = `${top}px`; this.hostElement.style.marginRight = `${-left}px`; this.hostElement.style.marginBottom = `${-top}px`; // Re-render visibly next frame with correct placement to update vdom setTimeout(() => this.forceRender++); } /** * Add scrollbar width to menu, to avoid horizontal scrollbars * or scrollbar forcing text-overflow. * (This affects Firefox and Safari, where non-overlay scrollbars * eat into content width rather than add) */ padForNonOverlayScrollbars() { this.base.style.paddingRight = `0px`; const scrollbarWidth = this.base.offsetWidth - this.base.clientWidth; this.base.style.paddingRight = `${scrollbarWidth}px`; } updateScrollIndicators() { // Reset this.canScrollDown = false; this.canScrollUp = false; const diff = this.list.scrollHeight - this.list.clientHeight; // Not scrollable if (diff) { if (this.list.scrollTop > 0) { this.canScrollUp = true; } if (this.list.scrollTop < diff) { this.canScrollDown = true; } } this.forceRender++; } /** * Check if going in a direction with content to reach, otherwise stop */ stopWheelPropagation(event) { // This is enough for Chrome event.stopPropagation(); // Needed for Safari and Firefox to prevent scrolling on non-scrollable lists if (!this.canScrollDown && !this.canScrollUp) { event.preventDefault(); } // Needed for Safari to prevent scrolling past the end of a scrollable list if (event.deltaY > 0 && !this.canScrollDown) { event.preventDefault(); } if (event.deltaY < 0 && !this.canScrollUp) { event.preventDefault(); } } getListItems() { return Array.from(this.hostElement.children).filter((el) => ITEM_ROLES.includes(el.getAttribute('role'))); } getCssClassMap() { return classNames('menu-flyout-list', `menu-flyout-list--direction-${this.direction}`, this.opened && 'menu-flyout-list--opened', this.canScrollUp && 'menu-flyout-list--can-scroll-up', this.canScrollDown && 'menu-flyout-list--can-scroll-down', this.flipHorizontal && `menu-flyout-list--flip-horizontal`, this.flipVertical && `menu-flyout-list--flip-vertical`, this.brandHeaderDropdown && `menu-flyout-list--brand-header-dropdown`); } render() { return (h(Host, { role: "menu" }, this.styles && h("style", null, this.styles), h("div", { class: this.getCssClassMap(), ref: (el) => (this.base = el), part: "base", style: { maxHeight: `calc(${this.windowHeight}px - 20px)` }, onWheelCapture: this.handleWheel }, h("div", { class: "menu-flyout-list__list", ref: (el) => (this.list = el), onScroll: this.handleScroll }, h("slot", null)), h("div", { "aria-hidden": "true", class: "menu-flyout-list__scroll-up-indicator" }), h("div", { "aria-hidden": "true", class: "menu-flyout-list__scroll-down-indicator" })))); } static get is() { return "scale-menu-flyout-list"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["menu-flyout-list.css"] }; } static get styleUrls() { return { "$": ["menu-flyout-list.css"] }; } static get properties() { return { "opened": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "" }, "attribute": "opened", "reflect": true, "defaultValue": "false" }, "trigger": { "type": "unknown", "mutable": false, "complexType": { "original": "() => HTMLElement", "resolved": "() => HTMLElement", "references": { "HTMLElement": { "location": "global" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "" } }, "direction": { "type": "string", "mutable": true, "complexType": { "original": "| 'bottom-right'\n | 'bottom-left'\n | 'top-right'\n | 'top-left'\n | 'right'\n | 'left'", "resolved": "\"bottom-left\" | \"bottom-right\" | \"left\" | \"right\" | \"top-left\" | \"top-right\"", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "(optional) Set preference for where the menu appears, space permitting" }, "attribute": "direction", "reflect": false, "defaultValue": "'bottom-right'" }, "active": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "" }, "attribute": "active", "reflect": true, "defaultValue": "false" }, "closeOnSelect": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "(optional) Determines whether the flyout should close when a menu item is selected" }, "attribute": "close-on-select", "reflect": false, "defaultValue": "true" }, "brandHeaderDropdown": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "(optional) set to true when using in telekom-brand-header" }, "attribute": "brand-header-dropdown", "reflect": false, "defaultValue": "false" }, "styles": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "(optional) Injected styles" }, "attribute": "styles", "reflect": false } }; } static get states() { return { "forceRender": {} }; } static get events() { return [{ "method": "scaleOpen", "name": "scale-open", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Event triggered when menu list opened" }, "complexType": { "original": "{\n list: HTMLElement;\n }", "resolved": "{ list: HTMLElement; }", "references": { "HTMLElement": { "location": "global" } } } }, { "method": "scaleOpenLegacy", "name": "scaleOpen", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [{ "name": "deprecated", "text": "in v3 in favor of kebab-case event names" }], "text": "" }, "complexType": { "original": "{\n list: HTMLElement;\n }", "resolved": "{ list: HTMLElement; }", "references": { "HTMLElement": { "location": "global" } } } }, { "method": "scaleClose", "name": "scale-close", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Event triggered when menu list closed" }, "complexType": { "original": "{\n list: HTMLElement;\n }", "resolved": "{ list: HTMLElement; }", "references": { "HTMLElement": { "location": "global" } } } }, { "method": "scaleCloseLegacy", "name": "scaleClose", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [{ "name": "deprecated", "text": "in v3 in favor of kebab-case event names" }], "text": "" }, "complexType": { "original": "{\n list: HTMLElement;\n }", "resolved": "{ list: HTMLElement; }", "references": { "HTMLElement": { "location": "global" } } } }]; } static get methods() { return { "open": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "", "tags": [] } }, "close": { "complexType": { "signature": "(silent?: boolean) => Promise<void>", "parameters": [{ "tags": [], "text": "" }], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "", "tags": [] } }, "setFocus": { "complexType": { "signature": "() => Promise<void>", "parameters": [], "references": { "Promise": { "location": "global" } }, "return": "Promise<void>" }, "docs": { "text": "", "tags": [] } } }; } static get elementRef() { return "hostElement"; } static get watchers() { return [{ "propName": "opened", "methodName": "openedChanged" }]; } static get listeners() { return [{ "name": "resize", "method": "handleResize", "target": "window", "capture": false, "passive": true }, { "name": "keydown", "method": "handleKeydown", "target": undefined, "capture": false, "passive": false }, { "name": "click", "method": "handleClick", "target": undefined, "capture": false, "passive": false }, { "name": "scale-select", "method": "handleScaleSelect", "target": undefined, "capture": false, "passive": false }, { "name": "scale-open", "method": "handleScaleOpen", "target": undefined, "capture": false, "passive": false }]; } }