@telekom/scale-components
Version:
Scale is the digital design system for Telekom products and experiences.
668 lines (667 loc) • 19.3 kB
JavaScript
/**
* @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
}]; }
}