import {classMap} from "lit/directives/class-map.js"; import {type CSSResultGroup, unsafeCSS} from 'lit'; import {getTextContent, HasSlotController} from '../../internal/slot'; import {html, literal} from "lit/static-html.js"; import {ifDefined} from "lit/directives/if-defined.js"; import {LocalizeController} from '../../utilities/localize'; import {property, query} from 'lit/decorators.js'; import {SubmenuController} from './submenu-controller'; import {watch} from '../../internal/watch'; import ZincElement from '../../internal/zinc-element'; import ZnIcon from "../icon"; import ZnPopup from "../popup"; import type ZnDropdown from "../dropdown"; import styles from './menu-item.scss'; /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/menu-item * @status experimental * @since 1.0 * * @dependency zn-example * * @event zn-event-name - Emitted as an example. * * @slot - The default slot. * @slot example - An example slot. * * @csspart base - The component's base wrapper. * * @cssproperty --example - An example CSS custom property. */ export default class ZnMenuItem extends ZincElement { static styles: CSSResultGroup = unsafeCSS(styles); static dependencies = { 'zn-icon': ZnIcon, 'zn-popup': ZnPopup }; private cachedTextLabel: string; @query('slot:not([name])') defaultSlot: HTMLSlotElement; @query('.menu-item') menuItem: HTMLElement; /** The type of menu item to render. To use `checked`, this value must be set to `checkbox`. */ @property() type: 'normal' | 'checkbox' = 'normal'; /** Draws the item in a checked state. */ @property({type: Boolean, reflect: true}) checked = false; @property({attribute: 'checked-position', reflect: true}) checkedPosition: 'left' | 'right' = 'left'; /** A unique value to store in the menu item. This can be used as a way to identify menu items when selected. */ @property() value = ''; /** Draws the menu item in a loading state. */ @property({type: Boolean, reflect: true}) loading = false; /** Draws the menu item in a disabled state, preventing selection. */ @property({type: Boolean, reflect: true}) disabled = false; @property() color: string; // Link Specific @property() href: string; @property({attribute: "data-path"}) dataPath: string; @property() target: '_self' | '_blank' | '_parent' | '_top' | string; @property({attribute: 'data-target'}) dataTarget: 'modal' | 'slide' | string; @property() rel: string = 'noreferrer noopener'; @property({attribute: 'gaid'}) gaid: string; @property({type: Boolean, reflect: true}) confirm = false; /** Removes all padding from the menu item. */ @property({type: Boolean, reflect: true}) flush = false; /** Removes horizontal (left/right) padding only. Ignored if flush is set. */ @property({type: Boolean, reflect: true, attribute: 'flush-x'}) flushX = false; /** Removes vertical (top/bottom) padding only. Ignored if flush is set. */ @property({type: Boolean, reflect: true, attribute: 'flush-y'}) flushY = false; /** Removes the border from the menu item. */ @property({type: Boolean, reflect: true, attribute: 'no-border'}) noBorder = false; /** Marks the menu item as currently active/selected. */ @property({type: Boolean, reflect: true}) active = false; private readonly localize = new LocalizeController(this); private readonly hasSlotController = new HasSlotController(this, 'submenu'); private submenuController: SubmenuController = new SubmenuController(this, this.hasSlotController, this.localize); connectedCallback() { super.connectedCallback(); this.addEventListener('click', this.handleHostClick); this.addEventListener('mouseover', this.handleMouseOver); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener('click', this.handleHostClick); this.removeEventListener('mouseover', this.handleMouseOver); } private handleDefaultSlotChange() { const textLabel = this.getTextLabel(); // Ignore the first time the label is set if (typeof this.cachedTextLabel === 'undefined') { this.cachedTextLabel = textLabel; return; } // When the label changes, emit a slotchange event so parent controls see it if (textLabel !== this.cachedTextLabel) { this.cachedTextLabel = textLabel; this.emit('slotchange', {bubbles: true, composed: false, cancelable: false}); } } private handleHostClick = (event: MouseEvent) => { // Prevent the click event from being emitted when the button is disabled or loading if (this.disabled) { event.preventDefault(); event.stopImmediatePropagation(); } if (!this.isSubmenu()) { const composedPath = event.composedPath(); const closestMenu: Element | null = composedPath.find((el: Element) => el?.getAttribute?.('role') === 'menu') as Element; if (this.confirm) return; (closestMenu?.closest('zn-dropdown') as ZnDropdown | null)?.hide(); this.emit('zn-menu-select', {detail: {value: this.value, element: this}}); } }; private handleMouseOver = (event: MouseEvent) => { this.focus(); event.stopPropagation(); }; @watch('checked') handleCheckedChange() { // For proper accessibility, users have to use type="checkbox" to use the checked attribute if (this.checked && this.type !== 'checkbox') { this.checked = false; console.error('The checked attribute can only be used on menu items with type="checkbox"', this); return; } // Only checkbox types can receive the aria-checked attribute if (this.type === 'checkbox') { this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); } else { this.removeAttribute('aria-checked'); } } @watch('disabled') handleDisabledChange() { this.setAttribute('aria-disabled', this.disabled ? 'true' : 'false'); } @watch('type') handleTypeChange() { if (this.type === 'checkbox') { this.setAttribute('role', 'menuitemcheckbox'); this.setAttribute('aria-checked', this.checked ? 'true' : 'false'); } else { this.setAttribute('role', 'menuitem'); this.removeAttribute('aria-checked'); } } /** Returns a text label based on the contents of the menu item's default slot. */ getTextLabel() { return getTextContent(this.defaultSlot); } isSubmenu() { return this.hasSlotController.test('submenu'); } private _isLink() { return this.href !== undefined || this.dataPath !== undefined; } render() { const isRtl = this.localize.dir() === 'rtl'; const isSubmenuExpanded = this.submenuController.isExpanded(); const isLink = this._isLink(); const tag = isLink ? literal`a` : literal`div`; return html` <${tag} id="anchor" part="base" data-path=${ifDefined(this.dataPath)} href=${ifDefined(this.href)} target=${ifDefined(isLink ? this.target : undefined)} data-target=${ifDefined(isLink ? this.dataTarget : undefined)} rel=${ifDefined(isLink ? this.rel : undefined)} gaid=${ifDefined(this.gaid)} class=${classMap({ 'menu-item': true, 'menu-item--rtl': isRtl, 'menu-item--checked': this.checked, 'menu-item--disabled': this.disabled, 'menu-item--loading': this.loading, 'menu-item--has-submenu': this.isSubmenu(), 'menu-item--submenu-expanded': isSubmenuExpanded, 'menu-item--primary': this.color === 'primary', 'menu-item--secondary': this.color === 'secondary', 'menu-item--error': this.color === 'error', 'menu-item--info': this.color === 'info', 'menu-item--success': this.color === 'success', 'menu-item--warning': this.color === 'warning', 'menu-item--transparent': this.color === 'transparent', 'menu-item--flush': this.flush, 'menu-item--flush-x': this.flushX && !this.flush, 'menu-item--flush-y': this.flushY && !this.flush, 'menu-item--no-border': this.noBorder, 'menu-item--active': this.active, })} ?aria-haspopup="${this.isSubmenu()}" ?aria-expanded="${isSubmenuExpanded}"> ${this.checkedPosition === 'left' ? html` ` : ''} ${this.checkedPosition === 'right' ? html` ` : ''} ${this.submenuController.renderSubmenu()} ${this.loading ? html` ` : ''} `; } }