UNPKG

@limetech/lime-elements

Version:
781 lines (780 loc) • 26 kB
import { h, } from '@stencil/core'; import { createRandomString } from '../../util/random-string'; import { zipObject, isFunction } from 'lodash-es'; import { ARROW_DOWN, ARROW_LEFT, ARROW_RIGHT, ARROW_UP, TAB, } from '../../util/keycodes'; const DEFAULT_ROOT_BREADCRUMBS_ITEM = { text: '', icon: { name: 'home', }, type: 'icon-only', }; /** * @slot trigger - Element to use as a trigger for the menu. * @exampleComponent limel-example-menu-basic * @exampleComponent limel-example-menu-disabled * @exampleComponent limel-example-menu-open-direction * @exampleComponent limel-example-menu-surface-width * @exampleComponent limel-example-menu-separators * @exampleComponent limel-example-menu-icons * @exampleComponent limel-example-menu-badge-icons * @exampleComponent limel-example-menu-grid * @exampleComponent limel-example-menu-hotkeys * @exampleComponent limel-example-menu-secondary-text * @exampleComponent limel-example-menu-notification * @exampleComponent limel-example-menu-sub-menus * @exampleComponent limel-example-menu-sub-menu-lazy-loading * @exampleComponent limel-example-menu-sub-menu-lazy-loading-infinite * @exampleComponent limel-example-menu-searchable * @exampleComponent limel-example-menu-composite */ export class Menu { constructor() { this.renderLoader = () => { if (!this.loadingSubItems && !this.loading) { return; } const cssProperties = this.getCssProperties(); return (h("div", { style: { width: cssProperties['--menu-surface-width'], display: 'flex', 'align-items': 'center', 'justify-content': 'center', padding: '0.5rem 0', } }, h("limel-spinner", { size: "mini", limeBranded: false }))); }; this.renderBreadcrumb = () => { const breadcrumbsItems = this.getBreadcrumbsItems(); if (breadcrumbsItems.length === 0) { return; } return (h("limel-breadcrumbs", { style: { 'border-bottom': 'solid 1px rgb(var(--contrast-500))', 'flex-shrink': '0', }, onSelect: this.handleBreadcrumbsSelect, items: breadcrumbsItems })); }; this.handleBreadcrumbsSelect = (event) => { if (!event.detail.menuItem) { this.currentSubMenu = null; this.clearSearch(); this.navigateMenu.emit(null); this.setFocus(); return; } this.handleSelect(event.detail.menuItem); }; this.renderSearchField = () => { if (!this.searcher) { return; } return (h("limel-input-field", { tabindex: "0", ref: this.setSearchElement, type: "search", leadingIcon: "search", style: { padding: '0.25rem', 'box-sizing': 'border-box', }, value: this.searchValue, onChange: this.handleTextInput, onKeyDown: this.handleInputKeyDown })); }; this.renderEmptyMessage = () => { var _a; if (this.loading || this.loadingSubItems || !this.emptyResultMessage || !Array.isArray(this.searchResults) || ((_a = this.searchResults) === null || _a === void 0 ? void 0 : _a.length)) { return null; } return (h("p", { style: { padding: '0 1rem', 'text-align': 'center', } }, this.emptyResultMessage)); }; this.renderMenuList = () => { let items = this.visibleItems; if (this.loadingSubItems || this.loading) { items = []; } return (h("limel-menu-list", { style: { 'overflow-y': 'auto', 'flex-grow': '1', }, class: { 'has-grid-layout has-interactive-items': this.gridLayout, }, items: items, badgeIcons: this.badgeIcons, onSelect: this.onSelect, ref: this.setListElement, onKeyDown: this.handleMenuKeyDown })); }; this.handleTextInput = async (event) => { event.stopPropagation(); const query = event.detail; this.searchValue = query; if (query === '') { this.searchResults = null; this.loadingSubItems = false; return; } this.loadingSubItems = true; const result = await this.searcher(query); if (this.searchValue !== query) { return; } this.searchResults = result; this.loadingSubItems = false; }; // Key handler for the input search field // Will change focus to the first/last item in the dropdown // list to enable selection with the keyboard this.handleInputKeyDown = (event) => { const isForwardTab = event.key === TAB && !event.altKey && !event.metaKey && !event.shiftKey; const isUp = event.key === ARROW_UP; const isDown = event.key === ARROW_DOWN; if (!isForwardTab && !isUp && !isDown) { return; } if (!this.list) { return; } event.stopPropagation(); event.preventDefault(); if (isForwardTab || isDown) { const listItems = this.list.shadowRoot.querySelectorAll('.mdc-deprecated-list-item'); const listElement = listItems[0]; listElement === null || listElement === void 0 ? void 0 : listElement.focus(); return; } if (isUp) { const listItems = this.list.shadowRoot.querySelectorAll('.mdc-deprecated-list-item'); const listElement = [...listItems].at(-1); listElement === null || listElement === void 0 ? void 0 : listElement.focus(); } }; // Key handler for the menu list // Will change focus to the search field if using shift+tab // And can go forward/back with righ/left arrow keys this.handleMenuKeyDown = (event) => { var _a; const isBackwardTab = event.key === TAB && !event.altKey && !event.metaKey && event.shiftKey; const isLeft = event.key === ARROW_LEFT; const isRight = event.key === ARROW_RIGHT; if (!isBackwardTab && !isLeft && !isRight) { return; } if (isBackwardTab) { event.stopPropagation(); event.preventDefault(); (_a = this.searchInput) === null || _a === void 0 ? void 0 : _a.focus(); } else if (!this.gridLayout) { const currentItem = this.getCurrentItem(); event.stopPropagation(); event.preventDefault(); if (isRight) { this.goForward(currentItem); } else if (isLeft) { this.goBack(); } } }; this.clearSearch = () => { this.searchValue = ''; this.searchResults = null; this.loadingSubItems = false; }; this.getCurrentItem = () => { var _a, _b, _c; const activeItem = (_b = (_a = this.list) === null || _a === void 0 ? void 0 : _a.shadowRoot) === null || _b === void 0 ? void 0 : _b.querySelector('[role="menuitem"][tabindex="0"]'); const attrIndex = (_c = activeItem === null || activeItem === void 0 ? void 0 : activeItem.attributes) === null || _c === void 0 ? void 0 : _c.getNamedItem('data-index'); const dataIndex = Number.parseInt((attrIndex === null || attrIndex === void 0 ? void 0 : attrIndex.value) || '0', 10); return this.visibleItems[dataIndex]; }; this.goForward = (currentItem) => { this.handleSelect(currentItem, false); }; this.goBack = () => { if (!this.currentSubMenu) { // Already in the root of the menu return; } const parent = this.currentSubMenu.parentItem; if (!parent) { // If only one step down, go to the root of the menu. // No need to load a sub-menu. this.currentSubMenu = null; this.clearSearch(); this.navigateMenu.emit(null); this.setFocus(); return; } this.handleSelect(parent); }; this.setTriggerAttributes = (element) => { const attributes = { 'aria-haspopup': true, 'aria-expanded': this.open, 'aria-controls': this.portalId, disabled: this.disabled, role: 'button', }; for (const [key, value] of Object.entries(attributes)) { if (value) { element.setAttribute(key, String(value)); } else { element.removeAttribute(key); } } }; this.onClose = () => { this.cancel.emit(); this.open = false; this.currentSubMenu = null; }; this.onTriggerClick = (event) => { event.stopPropagation(); if (this.disabled) { return; } this.open = !this.open; }; this.handleSelect = async (menuItem, selectOnEmptyChildren = true) => { if (Array.isArray(menuItem === null || menuItem === void 0 ? void 0 : menuItem.items) && menuItem.items.length > 0) { this.selectedMenuItem = menuItem; this.clearSearch(); this.currentSubMenu = menuItem; this.navigateMenu.emit(menuItem); this.setFocus(); return; } else if (isFunction(menuItem === null || menuItem === void 0 ? void 0 : menuItem.items)) { const menuLoader = menuItem.items; this.selectedMenuItem = menuItem; this.loadingSubItems = true; const subItems = await menuLoader(menuItem); if (this.selectedMenuItem !== menuItem) { return; } menuItem.items = subItems; this.loadingSubItems = false; if (subItems === null || subItems === void 0 ? void 0 : subItems.length) { this.currentSubMenu = menuItem; this.clearSearch(); this.navigateMenu.emit(menuItem); this.setFocus(); return; } } if (!selectOnEmptyChildren) { return; } this.selectedMenuItem = menuItem; this.loadingSubItems = false; this.select.emit(menuItem); this.open = false; this.currentSubMenu = null; this.setFocus(); }; this.onSelect = (event) => { event.stopPropagation(); this.handleSelect(event.detail); }; this.setListElement = (element) => { this.list = element; }; this.setFocus = () => { setTimeout(() => { if (this.searchInput && this.searcher) { const observer = new IntersectionObserver(() => { observer.unobserve(this.searchInput); if (this.searchInput === window.document.activeElement) { return; } this.searchInput.focus(); }); observer.observe(this.searchInput); } else if (this.list) { const observer = new IntersectionObserver(() => { observer.unobserve(this.list); this.focusMenuItem(); }); observer.observe(this.list); } }, 0); }; this.setSearchElement = (element) => { this.searchInput = element; }; this.focusMenuItem = () => { var _a; if (!this.list) { return; } const activeElement = this.list.shadowRoot.activeElement; activeElement === null || activeElement === void 0 ? void 0 : activeElement.blur(); const menuItems = this.visibleItems.filter(this.isMenuItem); const selectedIndex = Math.max(menuItems.findIndex((item) => item.selected), 0); const menuElements = [ ...this.list.shadowRoot.querySelectorAll('[role="menuitem"]'), ]; (_a = menuElements[selectedIndex]) === null || _a === void 0 ? void 0 : _a.focus(); }; this.renderNotificationBadge = () => { if (this.items.some(this.hasNotificationBadge)) { return h("limel-badge", null); } }; this.hasNotificationBadge = (item) => this.isMenuItem(item) && item.badge !== undefined; this.setTriggerRef = (elm) => { this.triggerElement = elm; }; this.items = []; this.disabled = false; this.openDirection = 'bottom-start'; this.surfaceWidth = 'inherit-from-items'; this.open = false; this.badgeIcons = false; this.gridLayout = false; this.loading = false; this.currentSubMenu = undefined; this.rootItem = DEFAULT_ROOT_BREADCRUMBS_ITEM; this.searcher = undefined; this.emptyResultMessage = undefined; this.loadingSubItems = undefined; this.searchValue = undefined; this.searchResults = undefined; this.portalId = createRandomString(); } componentDidRender() { const slotElement = this.host.shadowRoot.querySelector('slot'); // eslint-disable-next-line unicorn/no-array-for-each slotElement.assignedElements().forEach(this.setTriggerAttributes); } render() { const cssProperties = this.getCssProperties(); const dropdownZIndex = getComputedStyle(this.host).getPropertyValue('--dropdown-z-index'); const menuSurfaceWidth = this.getMenuSurfaceWidth(cssProperties['--menu-surface-width']); return (h("div", { class: "mdc-menu-surface--anchor", onClick: this.onTriggerClick }, h("slot", { ref: this.setTriggerRef, name: "trigger" }), this.renderNotificationBadge(), h("limel-portal", { visible: this.open, containerId: this.portalId, openDirection: this.openDirection, position: "absolute", containerStyle: { 'z-index': dropdownZIndex } }, h("limel-menu-surface", { open: this.open, onDismiss: this.onClose, style: Object.assign(Object.assign({}, cssProperties), { '--menu-surface-width': menuSurfaceWidth, '--limel-menu-surface-display': 'flex', '--limel-menu-surface-flex-direction': 'column' }), class: { 'has-grid-layout': this.gridLayout, } }, this.renderSearchField(), this.renderBreadcrumb(), this.renderLoader(), this.renderEmptyMessage(), this.renderMenuList())))); } itemsWatcher() { this.clearSearch(); this.setFocus(); } openWatcher(newValue) { const opened = newValue; if (opened) { this.setFocus(); } else { this.clearSearch(); } } getBreadcrumbsItems() { const breadCrumbItems = []; let currentItem = this.currentSubMenu; while (currentItem) { breadCrumbItems.push({ text: currentItem.text, icon: currentItem.icon, menuItem: currentItem, }); currentItem = currentItem.parentItem; } if (breadCrumbItems.length > 0 || this.rootItem !== DEFAULT_ROOT_BREADCRUMBS_ITEM) { breadCrumbItems.push(this.rootItem); } return breadCrumbItems.reverse(); } getCssProperties() { const propertyNames = [ '--menu-surface-width', '--list-grid-item-max-width', '--list-grid-item-min-width', '--list-grid-gap', '--notification-badge-background-color', '--notification-badge-text-color', ]; const style = getComputedStyle(this.host); const values = propertyNames.map((property) => { return style.getPropertyValue(property); }); return zipObject(propertyNames, values); } isMenuItem(item) { return !('separator' in item); } getMenuSurfaceWidth(customWidth) { var _a, _b, _c, _d; if (customWidth) { return customWidth; } if (this.surfaceWidth === 'inherit-from-trigger') { const assignedTriggers = (_a = this.triggerElement) === null || _a === void 0 ? void 0 : _a.assignedElements(); if (!(assignedTriggers === null || assignedTriggers === void 0 ? void 0 : assignedTriggers.length) || !((_b = assignedTriggers[0]) === null || _b === void 0 ? void 0 : _b.clientWidth)) { return ''; } return `${assignedTriggers[0].clientWidth}px`; } else if (this.surfaceWidth === 'inherit-from-menu') { if (!((_c = this.host) === null || _c === void 0 ? void 0 : _c.clientWidth)) { return ''; } return `${(_d = this.host) === null || _d === void 0 ? void 0 : _d.clientWidth}px`; } return ''; } get visibleItems() { var _a; if (Array.isArray(this.searchResults) && this.searchValue) { return this.searchResults; } else if (Array.isArray((_a = this.currentSubMenu) === null || _a === void 0 ? void 0 : _a.items)) { return this.currentSubMenu.items.map((item) => (Object.assign(Object.assign({}, item), { parentItem: this.currentSubMenu }))); } return this.items; } static get is() { return "limel-menu"; } static get encapsulation() { return "shadow"; } static get originalStyleUrls() { return { "$": ["menu.scss"] }; } static get styleUrls() { return { "$": ["menu.css"] }; } static get properties() { return { "items": { "type": "unknown", "mutable": false, "complexType": { "original": "Array<MenuItem | ListSeparator>", "resolved": "(ListSeparator | MenuItem<any>)[]", "references": { "Array": { "location": "global" }, "MenuItem": { "location": "import", "path": "./menu.types" }, "ListSeparator": { "location": "import", "path": "../list/list-item.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "A list of items and separators to show in the menu." }, "defaultValue": "[]" }, "disabled": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Sets the disabled state of the menu." }, "attribute": "disabled", "reflect": true, "defaultValue": "false" }, "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.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Decides the menu's location in relation to its trigger" }, "attribute": "open-direction", "reflect": true, "defaultValue": "'bottom-start'" }, "surfaceWidth": { "type": "string", "mutable": false, "complexType": { "original": "SurfaceWidth", "resolved": "\"inherit-from-items\" | \"inherit-from-menu\" | \"inherit-from-trigger\"", "references": { "SurfaceWidth": { "location": "import", "path": "./menu.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "Decides the width of menu's dropdown" }, "attribute": "surface-width", "reflect": true, "defaultValue": "'inherit-from-items'" }, "open": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Sets the open state of the menu." }, "attribute": "open", "reflect": true, "defaultValue": "false" }, "badgeIcons": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Defines whether the menu should show badges." }, "attribute": "badge-icons", "reflect": true, "defaultValue": "false" }, "gridLayout": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [], "text": "Renders list items in a grid layout, rather than a vertical list" }, "attribute": "grid-layout", "reflect": true, "defaultValue": "false" }, "loading": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": ":::warning Internal Use Only\nThis property is for internal use only. We need it for now, but want to\nfind a better implementation of the functionality it currently enables.\nIf and when we do so, this property will be removed without prior\nnotice. If you use it, your code _will_ break in the future.\n:::" }, "attribute": "loading", "reflect": true, "defaultValue": "false" }, "currentSubMenu": { "type": "unknown", "mutable": true, "complexType": { "original": "MenuItem", "resolved": "MenuItem<any>", "references": { "MenuItem": { "location": "import", "path": "./menu.types" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "internal", "text": undefined }], "text": ":::warning Internal Use Only\nThis property is for internal use only. We need it for now, but want to\nfind a better implementation of the functionality it currently enables.\nIf and when we do so, this property will be removed without prior\nnotice. If you use it, your code _will_ break in the future.\n:::" } }, "rootItem": { "type": "unknown", "mutable": false, "complexType": { "original": "BreadcrumbsItem", "resolved": "BreadcrumbsItem", "references": { "BreadcrumbsItem": { "location": "import", "path": "../breadcrumbs/breadcrumbs.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "A root breadcrumb item to show above the menu items.\nClicking it navigates back from a sub-menu to the root menu." }, "defaultValue": "DEFAULT_ROOT_BREADCRUMBS_ITEM" }, "searcher": { "type": "unknown", "mutable": false, "complexType": { "original": "MenuSearcher", "resolved": "(query: string) => Promise<(ListSeparator | MenuItem<any>)[]>", "references": { "MenuSearcher": { "location": "import", "path": "./menu.types" } } }, "required": false, "optional": false, "docs": { "tags": [], "text": "A search function that takes a search-string as an argument,\nand returns a promise that will eventually be resolved with\nan array of `MenuItem`:s.\n\nSee the docs for the type `MenuSearcher` for type information on\nthe searcher function itself." } }, "emptyResultMessage": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Message to display when search returns 0 results." }, "attribute": "empty-result-message", "reflect": false } }; } static get states() { return { "loadingSubItems": {}, "searchValue": {}, "searchResults": {} }; } static get events() { return [{ "method": "cancel", "name": "cancel", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Is emitted when the menu is cancelled." }, "complexType": { "original": "void", "resolved": "void", "references": {} } }, { "method": "select", "name": "select", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Is emitted when a menu item is selected." }, "complexType": { "original": "MenuItem", "resolved": "MenuItem<any>", "references": { "MenuItem": { "location": "import", "path": "./menu.types" } } } }, { "method": "navigateMenu", "name": "navigateMenu", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Is emitted when a menu item with a sub-menu is selected." }, "complexType": { "original": "MenuItem | null", "resolved": "MenuItem<any>", "references": { "MenuItem": { "location": "import", "path": "./menu.types" } } } }]; } static get elementRef() { return "host"; } static get watchers() { return [{ "propName": "items", "methodName": "itemsWatcher" }, { "propName": "open", "methodName": "openWatcher" }]; } } //# sourceMappingURL=menu.js.map