UNPKG

@puzzleitc/puzzle-shell

Version:

The standard design for Puzzle tools

265 lines (260 loc) 9.9 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { LitElement, css, html } from "lit"; import { state } from "lit/decorators.js"; import { customElement } from "lit/decorators/custom-element.js"; import { property } from "lit/decorators/property.js"; import { classMap } from "lit/directives/class-map.js"; import { isNodeOrChild } from "../utils/dom"; import { navigateMenuWithKeyboard } from "../utils/menu"; import { theme } from "../utils/theme"; import "./Backdrop"; /** * Responsive menu component that is coupled with the topbar component * (for mobile menu toggling and actions display on desktop) and the * banner component (nav display on desktop) via events. * * @slot nav - Slot for application navigation that is visible in the * banner on desktop and moves into the hamburger menu on mobile * @slot items - Slot for menu content that is visible on the page on * desktop (typically on the left of the content) and moves into the * hamburger menu on mobile * @slot actions - Slot for application-wide menu actions (like help, * logout etc.) that are visible in the topbar on desktop and move * into the hamburger menu on mobile * @fires pzsh-menu-change * @fires pzsh-menu-nav-change */ let Menu = class Menu extends LitElement { constructor() { super(); this.open = false; /** * The selector for the scroll container which will be blocked from * scrolling while menu is open. */ this.scrollContainerSelector = "body"; this.available = false; this.hasNav = false; this.hasSubnav = false; this.toggleMenu = this.toggleMenu.bind(this); this.handleEvent = this.handleEvent.bind(this); this.actionsObserver = new MutationObserver((mutations) => mutations.forEach(this.handleActionsChange.bind(this))); } connectedCallback() { super.connectedCallback(); // Subscribe to menu toggle events from pzsh-topbar component document.addEventListener("pzsh-menu-toggle", this.toggleMenu, true); document.addEventListener("click", this.handleEvent); document.addEventListener("keydown", this.handleEvent); } disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener("pzsh-menu-toggle", this.toggleMenu, true); document.removeEventListener("click", this.handleEvent); document.removeEventListener("keydown", this.handleEvent); } toggleMenu(e) { e?.stopPropagation(); this.open = !this.open; this.toggleBackdrop(); this.triggerMenuChange(this.available, this.open); } toggleBackdrop() { const backdrop = document.querySelector("pzsh-backdrop"); if (backdrop) { backdrop.remove(); } if (this.open) { document .querySelector("body") ?.appendChild(document.createElement("pzsh-backdrop")); } // Prevent scroll container (<body> per default) from scrolling // while backdrop is visible; can be configured with the // `scrollContainerSelector` property const scrollContainer = document.querySelector(this.scrollContainerSelector); if (scrollContainer) { scrollContainer.style.overflowY = this.open ? "hidden" : "auto"; } } handleEvent(e) { this.handleMenuClose(e); this.handleMenuNavigation(e); } handleMenuClose(e) { if (this.open && ((e.type === "click" && !isNodeOrChild(e.target, "pzsh-topbar")) || (e.type === "keydown" && e instanceof KeyboardEvent && (e.key === "Escape" || e.key === "Tab")))) { this.toggleMenu(e); } } handleMenuNavigation(e) { if (this.open) { navigateMenuWithKeyboard(this.getMenuItems.bind(this), e); } } /** * Flatten all menu actions & dropdown items to an array */ getMenuItems() { const navItems = Array.from(this.querySelectorAll("[slot='nav'] pzsh-nav-item")); const actions = Array.from(this.querySelector("[slot='actions']")?.children || []); return [...navItems, ...actions].reduce((acc, c) => { if (c.nodeName.toLowerCase() === "pzsh-menu-dropdown") { return [ ...acc, ...Array.from(c.querySelector('[slot="items"]')?.children || []), ].filter((e) => e.nodeName.toLowerCase() !== "pzsh-menu-divider"); } acc.push(c); return acc; }, []); } handleSlotChange(e) { const slot = e.target; this.updateMenuAvailablity(); if (slot.getAttribute("name") === "nav") { this.updateNavAvailability(); // TODO: Observe dynamically changed nav nodes just like for actions } if (slot.getAttribute("name") === "actions") { // Observe dynamic adding/removing of slot node children // (actual menu actions) slot .assignedNodes() .forEach((node) => this.actionsObserver.observe(node, { childList: true })); } } handleActionsChange() { this.updateMenuAvailablity(); } updateMenuAvailablity() { const available = this.hasMenuItems(); if (available !== this.available) { this.triggerMenuChange(available, this.open); } this.available = available; } hasMenuItems() { const navSlot = this.shadowRoot?.querySelector('slot[name="nav"]'); const actionsSlot = this.shadowRoot?.querySelector('slot[name="actions"]'); const itemsSlot = this.shadowRoot?.querySelector('slot[name="items"]'); return (navSlot.assignedNodes().length > 0 || (actionsSlot.assignedNodes()[0] || undefined)?.children ?.length > 0 || itemsSlot.assignedNodes().length > 0); } /** * Emit an event for the pzsh-topbar component to show/hide the * hamburger menu button or update its open/closed state. */ triggerMenuChange(available, open) { this.dispatchEvent(new CustomEvent("pzsh-menu-change", { detail: { available, open } })); } updateNavAvailability() { const slot = this.shadowRoot?.querySelector('slot[name="nav"]'); const hasNav = slot.assignedNodes().length > 0; const hasSubnav = slot.assignedNodes()[0]?.querySelector("pzsh-subnav") != null; if (hasNav !== this.hasNav || hasSubnav !== this.hasSubnav) { this.triggerNavChange(hasNav, hasSubnav); } this.hasNav = hasNav; this.hasSubnav = hasSubnav; } /** * Emit an event for the pzsh-banner component to preserve spacing * for the absolute positioned nav. */ triggerNavChange(hasNav, hasSubnav) { this.dispatchEvent(new CustomEvent("pzsh-menu-nav-change", { detail: { hasNav, hasSubnav }, })); } render() { const menuClasses = { open: this.open, }; return html ` <nav class=${classMap(menuClasses)} @slotchange=${this.handleSlotChange} role="menu" > <slot name="nav"></slot> <slot name="actions"></slot> <slot name="items"></slot> </nav> `; } }; Menu.styles = [ theme, css ` nav { position: absolute; top: var(--pzsh-topbar-height); left: 0; right: 0; display: none; max-height: calc(100vh - var(--pzsh-topbar-height)); overflow: hidden auto; padding: calc(2 * var(--pzsh-spacer) - var(--pzsh-menu-item-gap)) calc(3 * var(--pzsh-spacer)) calc(2 * var(--pzsh-spacer)) calc(3 * var(--pzsh-spacer)); background-color: var(--pzsh-menu-bg); box-shadow: 1px 1px 10px rgba(0, 0, 0, 0.2); z-index: var(--pzsh-menu-z-index); } nav.open { display: block; } ::slotted([slot="actions"]) { display: flex; flex-direction: column; } @media (min-width: ${theme.breakpoint}px) { nav { display: block; /* Always visible even when "closed" */ position: static; padding: 0; background-color: transparent; box-shadow: none; } /* Display the nav on the desktop in the banner using absolute positioning */ ::slotted([slot="nav"]) { position: absolute; top: var(--pzsh-topbar-height); left: 0; right: 0; } /* Display the menu actions on desktop in the topbar using absolute positioning */ ::slotted([slot="actions"]) { position: absolute; top: 0; right: calc(6 * var(--pzsh-spacer)); z-index: var(--pzsh-menu-z-index); height: var(--pzsh-topbar-height); flex-direction: row; align-items: center; gap: calc(3 * var(--pzsh-spacer)); } } `, ]; __decorate([ state() ], Menu.prototype, "open", void 0); __decorate([ property({ type: String }) ], Menu.prototype, "scrollContainerSelector", void 0); Menu = __decorate([ customElement("pzsh-menu") ], Menu); export { Menu };