UNPKG

@postnord/web-components

Version:
680 lines (679 loc) 29.3 kB
/*! * Built with Stencil * By PostNord. */ import { h, Host, Mixin } from "@stencil/core"; import { animateHeightFactory } from "../../../globals/mixins/index"; import { awaitTopbar, en, getMenuWidth, getTotalHeightOffset, isSmallScreen, ripple, uuidv4 } from "../../../index"; import { translations } from "./translation"; import { chevron_down, chevron_left, chevron_right, open_in_new } from "pn-design-assets/pn-assets/icons.js"; /** * Create a list of actions in an accessible way. * * Option types include: * * - Regular button, click and it will collapse the menu, * - Checkbox/radio, toggle the option on/off * - Link, behaves like a regular `a[href]` element. * * You can group these actions in groups and/or sub menus. * * - `group`, an array of options. The label will act as a title for the items. * - `options`, an array of options. These items will appear in a sub-menu that can be toggled. * * @since v7.13.0 * @see {@link PnActionMenuItem} */ export class PnActionMenu extends Mixin(animateHeightFactory) { constructor() { super(); } id = `pn-action-menu-${uuidv4()}`; menuButtonId = `${this.id}-button`; menuListId = `${this.id}-list`; menuTrigger; menuContainer; menuList; timeout; /** 16em */ menuWidth = 256; hostElement; smallMenu = null; upwards = false; activeSubmenu = []; /** Array of action menu options. @see {@link PnActionMenuItem} */ options = []; /** Set any prop from the `pn-button` component here. @see {@link Components.PnButton} */ button; /** Set a custom ID for the menu. */ menuId = this.id; /** Manually set the language. */ language = null; /** Open/close the action menu manually. @category Features */ open = false; /** Prefer that the menu open upwards, if there is enough space. @category Features */ menuUp = false; /** Prefer that the submenus opens to the left, if there is enough space. @category Features */ menuLeft = false; /** Emitted when the menu is opened or closed. */ menuToggle; /** Emitted when the menu is fully hidden/visible after the animation has played. */ menuVisible; /** Emitted when an option is clicked (button, link, input or submenus). */ menuOption; handleOptions() { this.setMenuLayout(); } openHandler() { this.setAnimationDuration(); requestAnimationFrame(() => { if (this.open && !this.isMoving()) { this.setOffset(); this.setMenuLayout(); } this.dropdownHandler(); this.menuToggle.emit({ open: this.open }); clearTimeout(this.timeout); this.timeout = setTimeout(() => { if (!this.open) { this.activeSubmenu = []; const subMenus = this.hostElement.querySelectorAll('[data-x]'); Array.from(subMenus).forEach(el => { el.removeAttribute('data-x'); el.removeAttribute('data-y'); }); } this.menuVisible.emit({ visible: this.open }); }, this.animationDuration); }); } handleMenuId() { this.menuButtonId = `${this.menuId}-button`; this.menuListId = `${this.menuId}-list`; } handleResize() { if (this.open) this.toggleActionMenu(false); } async componentWillLoad() { if (!this.options.length) console.warn(`${this.hostElement.localName}: No options set.`); this.handleMenuId(); this.setAnimationDuration(); if (this.language !== null) await awaitTopbar(this.hostElement); } componentDidLoad() { this.setOffset(); requestAnimationFrame(() => this.open && this.dropdownHandler()); } translate(prop) { return translations[prop][this.language || en]; } globalEvents = (event) => { const target = event.target; const isWithinActionMenu = target?.closest(this.hostElement.localName); if (!isWithinActionMenu) this.closeEachSubMenu(true); }; addGlobalEventListeners() { const root = this.hostElement.getRootNode(); root.addEventListener('click', this.globalEvents); } removeGlobalEventListeners() { const root = this.hostElement.getRootNode(); root.removeEventListener('click', this.globalEvents); } /** Open/close the action menu. */ toggleActionMenu(state) { this.open = state ?? !this.open; } setMenuLayout() { if (!this.menuContainer) return; if (!this.isMoving()) this.resetMaxHeight(); const measurements = this.getMenuMeasurements(); this.setDirection(measurements); this.setMenuSize(measurements); this.setMaxHeight(measurements); } setDirection({ hUp, hDown, sUp, sDown }) { const fitsUpwards = sUp > hUp; const fitsDownards = sDown > hDown; const moreSpaceDown = sDown > sUp; this.upwards = (this.menuUp && fitsUpwards) || (!fitsDownards && !moreSpaceDown); } setMenuSize({ hUp, hDown, sUp, sDown }) { const isWidthSmall = isSmallScreen(); const menuFits = this.upwards ? sUp > hUp : sDown > hDown; const isSmall = isWidthSmall || !menuFits; if (isSmall !== this.smallMenu) { this.smallMenu = isSmall; } } setMaxHeight({ hUp, hDown, sUp, sDown }) { if (this.smallMenu) { const heightUp = hUp > sUp ? hUp - 16 : hUp; const heightDown = hDown > sDown ? hDown - 8 : hDown; this.hostElement.style.setProperty('--pn-menu-height', `${this.upwards ? heightUp : heightDown}px`); } else this.resetMaxHeight(); } resetMaxHeight() { this.hostElement.style.setProperty('--pn-menu-height', 'unset'); } getRect(element) { return element.getBoundingClientRect(); } getMenuMeasurements() { const allLists = Array.from(this.menuContainer.querySelectorAll('menu')); const maxHeight = Math.max(...allLists.map(el => el.scrollHeight)); const rectButton = this.getRect(this.menuTrigger); const rectMenu = this.getRect(this.menuContainer); /** Measurements upwards. */ const sUp = rectButton.top - getTotalHeightOffset(); const hUp = sUp > maxHeight ? maxHeight : sUp; /** Measurements downwards. */ const sDown = innerHeight - rectMenu.top; const hDown = sDown > maxHeight ? maxHeight : sDown; return { hUp, hDown, sUp, sDown, }; } setOffset() { const data = this.getRect(this.hostElement); const sideMenuWidth = getMenuWidth(); // Calculate potential menu position const menuLeft = data.left; const menuRight = data.left + this.menuWidth; // Define boundaries const leftBoundary = sideMenuWidth + 16; // Left menu + buffer const rightBoundary = innerWidth - 16; // Right edge - buffer let offset = 0; // Check if menu would overlap left menu or go off left edge if (menuLeft < leftBoundary) { offset = leftBoundary - menuLeft; } // Check if menu would go off right edge else if (menuRight > rightBoundary) { offset = rightBoundary - menuRight; } if (this.menuContainer) this.menuContainer.style.setProperty('--pn-action-menu-offset', `${offset}px`); } getListId(option) { return `pn-menu-${option.value}-list`; } getButtonId(option) { return `pn-menu-${option.value}-button`; } getTriggerIcon() { if (this.button?.icon) return; return chevron_down; } /** Set the path of open sub menus. */ getSubMenuPath(options, value, path = []) { for (const item of options) { const newPath = [...path, item.value]; if (item.value === value) return newPath; if (item.options || item.group) { const result = this.getSubMenuPath(item.options || item.group, value, newPath); if (result) return result; } } return null; } closeEachSubMenu(preventFocus = false) { if (!preventFocus) this.menuTrigger?.querySelector('button')?.focus({ preventScroll: true }); const interval = setInterval(() => { if (this.smallMenu || this.activeSubmenu.length === 0) { clearInterval(interval); this.toggleActionMenu(false); } else { this.activeSubmenu.pop(); this.activeSubmenu = [...this.activeSubmenu]; } }, 150); } escButton(event, option) { const { key } = event; if (key === 'Escape') { event.preventDefault(); event.stopImmediatePropagation(); this.isSubmenuActive(option.value) ? this.toggleSub(option) : this.toggleActionMenu(); } } // Animation Start dropdownHandler() { if (this.open) { this.addGlobalEventListeners(); this.openDropdown(this.menuContainer, this.menuList.clientHeight); } else { this.removeGlobalEventListeners(); this.closeDropdown(this.menuContainer, this.menuList.clientHeight); } } // Animation end async optionSelect(option, click) { const type = option?.options?.length ? 'submenu' : !!option.input ? 'input' : option.href ? 'link' : 'button'; const isSubMenu = type === 'submenu'; if (isSubMenu) this.toggleSub(option); else if (type === 'button') this.closeEachSubMenu(); else if (type === 'input') option.checked = click.target.checked; this.menuOption.emit({ option, type, click, open: isSubMenu ? this.isSubmenuActive(option.value) : null, }); const target = click.target; const element = target.localName === 'input' ? target.nextElementSibling : target.className === 'pn-action-menu-button' ? target : target.closest('.pn-action-menu-button'); const { x, width, y, top } = this.getRect(element); const clientCor = { clientX: x + width - 24, clientY: y - top }; ripple(click.type === 'click' ? click : clientCor, element); this.menuContainer.scrollTo({ top: 0 }); } /** Toggle individual sub-menus inside the action menu by using the `option['value']`. */ toggleSub(option) { const { value } = option; const path = this.getSubMenuPath(this.options, value); const isActive = this.activeSubmenu.includes(value); isActive && path.splice(this.activeSubmenu.indexOf(value), 1); const item = this.hostElement.querySelector(`#${this.getListId(option)}`); const data = this.getRect(item); const spaceLeft = data.left; const spaceRight = innerWidth - data.right - 8; const fitsLeft = spaceLeft > data.width; const fitsRight = spaceRight > data.width; const climbUp = this.menuUp && data.top - data.height > 0; const yDirection = climbUp ? 'top' : 'bottom'; if (!isActive && !item.dataset.x) item.setAttribute('data-x', this.menuLeft && fitsLeft ? 'left' : fitsRight ? 'right' : fitsLeft ? 'left' : 'center'); if (!isActive && !item.dataset.y) item.setAttribute('data-y', yDirection); if (JSON.stringify(path) === JSON.stringify(this.activeSubmenu)) this.activeSubmenu = this.activeSubmenu.filter(item => item !== value); else this.activeSubmenu = [...path]; } /** Check if a sub-menu is active. */ isSubmenuActive(value) { return this.activeSubmenu.includes(value); } isMenuActive() { const isRootSubInsideGroup = this.options.find(({ value }) => this.activeSubmenu.every(val => val === value)); return this.smallMenu && (this.activeSubmenu?.length === 0 || isRootSubInsideGroup); } isCurrentSubMenu(value) { return Boolean(this.activeSubmenu[this.activeSubmenu.length - 1] === value); } getOptionTrailing(option) { const useButtonIcon = option.options?.length && chevron_right; const useTrailingIcon = option.trailingIcon; const useLinkIcon = option.target === '_blank' && open_in_new; /** If the user has defined a trialing icon, use it first. */ const icon = useButtonIcon || useTrailingIcon || useLinkIcon; if (icon) { return h("pn-icon", { icon: icon, color: "blue700", "data-suffix": true, "data-active": this.isSubmenuActive(option.value) }); } if (option.suffix) { return h("span", { class: "pn-action-menu-item-suffix" }, option.suffix); } } renderCheckbox(option) { const id = `pn-menu-${option.value}-label`; const idHelper = `pn-menu-${option.value}-helpertext`; return (h("div", { class: "pn-action-menu-item-content" }, h("input", { type: option.input, id: id, class: "pn-action-menu-input", name: option.name, value: option.value, checked: option.checked, disabled: option.disabled, "aria-describedby": option.helpertext ? idHelper : null, onInput: event => this.optionSelect(option, event), tabIndex: this.open ? null : -1 }), h("div", { class: "pn-action-menu-button" }, !!option.icon && h("pn-icon", { icon: option.icon, color: "blue700" }), h("div", { class: "pn-action-menu-item-text" }, h("label", { htmlFor: id, class: "pn-action-menu-item-label" }, option.label), option.helpertext && (h("p", { id: idHelper, class: "pn-action-menu-item-helpertext" }, h("span", null, option.helpertext)))), option.input === 'checkbox' ? (h("div", { class: "pn-action-menu-checkbox" }, h("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none" }, h("polyline", { class: "pn-action-menu-checkbox-checkmark-path", points: "4,12 9,17 20,6", "stroke-width": "3" })))) : (h("div", { class: "pn-action-menu-radio" }, h("div", { class: "pn-action-menu-radio-outer" }, h("div", { class: "pn-action-menu-radio-inner" }))))))); } renderButton(option) { const isCloseButton = option.label === 'BACK'; const isSub = !!option?.options?.length; const isGroup = !!option?.group?.length; const isLink = !!option.href && !isSub && !isGroup; const appendedId = isCloseButton ? '-close' : ''; const hasIcon = isCloseButton || !!option.icon; const trailingObject = isCloseButton ? null : this.getOptionTrailing(option); const open = this.isSubmenuActive(option.value); const subMenuAttrs = isSub ? { 'aria-haspopup': 'true', 'aria-controls': open ? this.getListId(option) : null, 'aria-expanded': open?.toString(), } : {}; const attrs = isLink ? { href: option.href, target: option.target, } : { disabled: option.disabled, }; const Tag = isLink ? 'a' : 'button'; return isGroup ? (h("div", { class: "pn-action-menu-group-label" }, h("p", { class: "pn-action-menu-p" }, option.label), option.helpertext && h("p", { class: "pn-action-menu-group-helpertext" }, option.helpertext))) : (h("div", { class: "pn-action-menu-item-content", "data-close": isCloseButton }, h(Tag, { id: this.getButtonId(option) + appendedId, class: "pn-action-menu-button", ...subMenuAttrs, ...attrs, tabIndex: this.open ? null : -1, onClick: event => this.optionSelect(option, event), onKeyDown: (event) => this.escButton(event, option) }, hasIcon && h("pn-icon", { icon: isCloseButton ? chevron_left : option.icon, color: "blue700" }), h("div", { class: "pn-action-menu-item-text" }, h("span", { class: "pn-action-menu-item-label" }, isCloseButton ? this.translate('BACK') : option.label), option.helpertext && !isLink && h("span", { class: "pn-action-menu-item-helpertext" }, option.helpertext)), trailingObject))); } renderSub(option) { return (h("menu", { id: this.getListId(option), "aria-labelledby": this.getButtonId(option), class: "pn-action-menu-sub", "data-open": this.isSubmenuActive(option.value)?.toString(), "data-current": this.isCurrentSubMenu(option.value) }, this.smallMenu && (h("li", { class: "pn-action-menu-item" }, this.renderButton({ label: 'BACK', value: option.value, options: option.options, }))), option.options.map(item => this.renderMenuItem(item)))); } renderGroup(option) { return h("menu", { class: "pn-action-menu-group" }, option.group.map(item => this.renderMenuItem(item))); } renderMenuItem(option) { const isSub = !!option?.options?.length; const isGroup = !isSub && !!option?.group?.length; const isCheckbox = !!option.input && !isSub && !isGroup; return (h("li", { class: "pn-action-menu-item", "data-group": isGroup, "data-sub": isSub }, isCheckbox ? this.renderCheckbox(option) : this.renderButton(option), isSub ? this.renderSub(option) : isGroup && this.renderGroup(option))); } renderMenu() { return (h("div", { id: this.menuListId, class: "pn-action-menu-container", role: "region", "aria-labelledby": this.menuButtonId, "data-open": this.open, "data-upwards": this.upwards, "data-small": this.smallMenu, style: { height: '0px' }, ref: el => (this.menuContainer = el) }, h("menu", { class: "pn-action-menu-list", "data-current": this.isMenuActive(), ref: el => (this.menuList = el) }, this.options?.map(option => this.renderMenuItem(option))))); } render() { return (h(Host, { key: 'd77e6c7f658e2552260f5a8a30034a8d68d9bfa2' }, h("div", { key: '3d2584a7c12abf5948cec24c967ab573d33dafa0', id: this.menuId !== this.id ? this.menuId : null, class: "pn-action-menu" }, h("pn-button", { key: 'f8460fca3784d2cb04ac55bfa0070935006c864d', icon: this.getTriggerIcon(), ...this.button, buttonId: this.menuButtonId, tooltipUp: !this.upwards, ariahaspopup: "true", ariacontrols: this.open ? this.menuListId : null, ariaexpanded: this.open.toString(), "data-default-icon": !this.button?.icon, onPnClick: () => this.toggleActionMenu(), ref: el => (this.menuTrigger = el) }), this.renderMenu()))); } static get is() { return "pn-action-menu"; } static get originalStyleUrls() { return { "$": ["pn-action-menu.scss"] }; } static get styleUrls() { return { "$": ["pn-action-menu.css"] }; } static get properties() { return { "options": { "type": "unknown", "mutable": false, "complexType": { "original": "PnActionMenuItem[]", "resolved": "PnActionMenuItem[]", "references": { "PnActionMenuItem": { "location": "import", "path": "@/index", "id": "src/index.ts::PnActionMenuItem", "referenceLocation": "PnActionMenuItem" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "see", "text": "{@link PnActionMenuItem }" }], "text": "Array of action menu options." }, "getter": false, "setter": false, "defaultValue": "[]" }, "button": { "type": "unknown", "mutable": false, "complexType": { "original": "Components.PnButton", "resolved": "PnButton", "references": { "Components": { "location": "import", "path": "@/index", "id": "src/index.ts::Components", "referenceLocation": "Components" } } }, "required": false, "optional": false, "docs": { "tags": [{ "name": "see", "text": "{@link Components.PnButton }" }], "text": "Set any prop from the `pn-button` component here." }, "getter": false, "setter": false }, "menuId": { "type": "string", "mutable": false, "complexType": { "original": "string", "resolved": "string", "references": {} }, "required": false, "optional": true, "docs": { "tags": [], "text": "Set a custom ID for the menu." }, "getter": false, "setter": false, "reflect": false, "attribute": "menu-id", "defaultValue": "this.id" }, "language": { "type": "string", "mutable": true, "complexType": { "original": "PnLanguages", "resolved": "\"\" | \"da\" | \"en\" | \"fi\" | \"no\" | \"sv\"", "references": { "PnLanguages": { "location": "import", "path": "@/index", "id": "src/index.ts::PnLanguages", "referenceLocation": "PnLanguages" } } }, "required": false, "optional": true, "docs": { "tags": [], "text": "Manually set the language." }, "getter": false, "setter": false, "reflect": false, "attribute": "language", "defaultValue": "null" }, "open": { "type": "boolean", "mutable": true, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Open/close the action menu manually." }, "getter": false, "setter": false, "reflect": true, "attribute": "open", "defaultValue": "false" }, "menuUp": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Prefer that the menu open upwards, if there is enough space." }, "getter": false, "setter": false, "reflect": false, "attribute": "menu-up", "defaultValue": "false" }, "menuLeft": { "type": "boolean", "mutable": false, "complexType": { "original": "boolean", "resolved": "boolean", "references": {} }, "required": false, "optional": true, "docs": { "tags": [{ "name": "category", "text": "Features" }], "text": "Prefer that the submenus opens to the left, if there is enough space." }, "getter": false, "setter": false, "reflect": false, "attribute": "menu-left", "defaultValue": "false" } }; } static get states() { return { "smallMenu": {}, "upwards": {}, "activeSubmenu": {} }; } static get events() { return [{ "method": "menuToggle", "name": "menuToggle", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the menu is opened or closed." }, "complexType": { "original": "{ open: boolean }", "resolved": "{ open: boolean; }", "references": {} } }, { "method": "menuVisible", "name": "menuVisible", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when the menu is fully hidden/visible after the animation has played." }, "complexType": { "original": "{ visible: boolean }", "resolved": "{ visible: boolean; }", "references": {} } }, { "method": "menuOption", "name": "menuOption", "bubbles": true, "cancelable": true, "composed": true, "docs": { "tags": [], "text": "Emitted when an option is clicked (button, link, input or submenus)." }, "complexType": { "original": "{\n /** Which type of menu item was clicked. */\n type: 'button' | 'link' | 'input' | 'submenu';\n /** If its an `input` type (checkbox/radio), it will be a generic `Event` (ChangeEvent) instead of `MouseEvent`. */\n click: MouseEvent | Event;\n /** The full {@link PnActionMenuItem} object. */\n option: PnActionMenuItem;\n /** If the submenu is open/closed. */\n open?: Boolean;\n }", "resolved": "{ type: \"button\" | \"input\" | \"link\" | \"submenu\"; click: Event | MouseEvent; option: PnActionMenuItem; open?: Boolean; }", "references": { "MouseEvent": { "location": "global", "id": "global::MouseEvent" }, "Event": { "location": "import", "path": "@stencil/core", "id": "node_modules::Event", "referenceLocation": "Event" }, "PnActionMenuItem": { "location": "import", "path": "@/index", "id": "src/index.ts::PnActionMenuItem", "referenceLocation": "PnActionMenuItem" }, "Boolean": { "location": "global", "id": "global::Boolean" } } } }]; } static get elementRef() { return "hostElement"; } static get watchers() { return [{ "propName": "options", "methodName": "handleOptions" }, { "propName": "open", "methodName": "openHandler" }, { "propName": "menuId", "methodName": "handleMenuId" }]; } static get listeners() { return [{ "name": "resize", "method": "handleResize", "target": "window", "capture": false, "passive": true }]; } }