@limetech/lime-elements
Version:
781 lines (780 loc) • 26 kB
JavaScript
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