@postnord/web-components
Version:
PostNord Web Components
671 lines (670 loc) • 28.6 kB
JavaScript
/*!
* Built with Stencil
* By PostNord.
*/
import { h, Host } from "@stencil/core";
import { awaitTopbar, en, getMenuWidth, getTotalHeightOffset, isSmallScreen, reduceMotion, ripple, uuidv4, } from "../../../index";
import { translations } from "./translation";
import { angle_down, angle_left, angle_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.
*
* @see {@link PnActionMenuItem}
*/
export class PnActionMenu {
constructor() {
this.smallMenu = null;
this.upwards = false;
this.activeSubmenu = [];
this.isClosing = false;
this.isExpanding = false;
this.options = [];
this.button = undefined;
this.menuId = this.id;
this.language = null;
this.open = false;
this.menuUp = false;
this.menuLeft = false;
}
id = `pn-action-menu-${uuidv4()}`;
menuButtonId = `${this.id}-button`;
menuListId = `${this.id}-list`;
menuTrigger;
menuContainer;
menuList;
animation;
duration = 400;
animationDuration = this.duration;
timeout;
/** 16em */
menuWidth = 256;
hostElement;
/** 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.isClosing || this.isExpanding)) {
this.setOffset();
this.setMenuLayout();
}
this.gridHandler();
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();
if (this.open)
this.gridHandler();
}
translate(prop) {
return translations[prop][this.language || en];
}
/** Open/close the action menu. */
toggleActionMenu(state) {
this.open = state ?? !this.open;
}
setMenuLayout() {
if (!this.menuContainer)
return;
if (!(this.isClosing || this.isExpanding))
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');
}
setAnimationDuration() {
if (reduceMotion())
this.animationDuration = 0;
else
this.animationDuration = this.duration;
}
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 fullWidth = data.left - getMenuWidth() + this.menuWidth;
const isWide = fullWidth > innerWidth;
const offset = isWide ? fullWidth - innerWidth + 16 : 0;
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 angle_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() {
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
gridHandler() {
if (this.open)
this.openGrid();
else
this.closeGrid();
}
openGrid() {
const list = this.getRect(this.menuList);
const { clientHeight } = this.menuContainer;
const height = this.isClosing ? clientHeight : 0;
this.menuContainer.style.height = `${list.height}px`;
this.isExpanding = true;
this.animateGrid(true, `${height}px`, `${list.height}px`);
}
closeGrid() {
const list = this.getRect(this.menuList);
const { clientHeight } = this.menuContainer;
const height = this.isExpanding ? clientHeight : list.height;
this.menuContainer.style.height = `0px`;
this.isClosing = true;
this.animateGrid(false, `${height}px`, `0px`);
}
animateGrid(open, startHeight, endHeight) {
this.cancelAnimations();
this.animation = this.menuContainer.animate({
height: [startHeight, endHeight],
}, {
duration: this.animationDuration,
easing: 'cubic-bezier(0.6, 0, 0.2, 1)',
});
this.animation.onfinish = () => this.animationFinish();
this.animation.oncancel = () => (open ? (this.isExpanding = false) : (this.isClosing = false));
}
animationFinish() {
this.cancelAnimations();
this.menuContainer.style.height = this.isClosing ? '0px' : '';
this.isClosing = false;
this.isExpanding = false;
}
cancelAnimations() {
if (this.animation)
this.animation.cancel();
}
// 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 && angle_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 ? angle_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-moving": this.isClosing || this.isExpanding, "data-upwards": this.upwards, "data-small": this.smallMenu, style: { height: '0px' }, ref: el => (this.menuContainer = el) }, h("menu", { class: "pn-action-menu-list", ref: el => (this.menuList = el), "data-current": this.isMenuActive() }, this.options?.map(option => this.renderMenuItem(option)))));
}
render() {
return (h(Host, { key: '5a1638b9b0f880ebd9f3ebe2571df19c6b852baf' }, h("div", { key: '87bf62a943099ce0a556ade3ce3eb11eedb003b2', id: this.menuId !== this.id ? this.menuId : null, class: "pn-action-menu" }, h("pn-button", { key: 'a3024016a2a5962d9eefc3c4d617f946a3575c23', 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"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "see",
"text": "{@link PnActionMenuItem }"
}],
"text": "Array of action menu options."
},
"defaultValue": "[]"
},
"button": {
"type": "unknown",
"mutable": false,
"complexType": {
"original": "Components.PnButton",
"resolved": "PnButton",
"references": {
"Components": {
"location": "import",
"path": "@/index",
"id": "src/index.ts::Components"
}
}
},
"required": false,
"optional": false,
"docs": {
"tags": [{
"name": "see",
"text": "{@link Components.PnButton }"
}],
"text": "Set any prop from the `pn-button` component here."
}
},
"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."
},
"attribute": "menu-id",
"reflect": false,
"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"
}
}
},
"required": false,
"optional": true,
"docs": {
"tags": [],
"text": "Manually set the language."
},
"attribute": "language",
"reflect": false,
"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."
},
"attribute": "open",
"reflect": true,
"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."
},
"attribute": "menu-up",
"reflect": false,
"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."
},
"attribute": "menu-left",
"reflect": false,
"defaultValue": "false"
}
};
}
static get states() {
return {
"smallMenu": {},
"upwards": {},
"activeSubmenu": {},
"isClosing": {},
"isExpanding": {}
};
}
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"
},
"PnActionMenuItem": {
"location": "import",
"path": "@/index",
"id": "src/index.ts::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
}];
}
}
//# sourceMappingURL=pn-action-menu.js.map