import { globalThis, document } from '../utils/server-safe-globals.js'; import { InvokeEvent } from '../utils/events.js'; import { getDocumentOrShadowRoot, containsComposedNode, namedNodeMapToObject, } from '../utils/element-utils.js'; import type MediaChromeMenu from './media-chrome-menu.js'; function getTemplateHTML(_attrs: Record) { return /*html*/ ` ${this.getSuffixSlotInnerHTML(_attrs)} `; } function getSuffixSlotInnerHTML(_attrs: Record) { return ''; } export const Attributes = { TYPE: 'type', VALUE: 'value', CHECKED: 'checked', DISABLED: 'disabled', }; /** * @extends {HTMLElement} * @slot - Default slotted elements. * * @attr {(''|'radio'|'checkbox')} type - This attribute indicates the kind of command, and can be one of three values. * @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable. * * @cssproperty --media-menu-item-opacity - `opacity` of menu-item content. * @cssproperty --media-menu-item-transition - `transition` of menu-item. * @cssproperty --media-menu-item-checked-background - `background` of checked menu-item. * @cssproperty --media-menu-item-outline - `outline` menu-item. * @cssproperty --media-menu-item-outline-offset - `outline-offset` of menu-item. * @cssproperty --media-menu-item-hover-background - `background` of hovered menu-item. * @cssproperty --media-menu-item-hover-outline - `outline` of hovered menu-item. * @cssproperty --media-menu-item-hover-outline-offset - `outline-offset` of hovered menu-item. * @cssproperty --media-menu-item-focus-shadow - `box-shadow` of the :focus-visible state. * @cssproperty --media-menu-item-icon-height - `height` of icon. * @cssproperty --media-menu-item-description-max-width - `max-width` of description. * @cssproperty --media-menu-item-checked-indicator-display - `display` of checked indicator. * * @cssproperty --media-icon-color - `fill` color of icon. * @cssproperty --media-menu-icon-height - `height` of icon. * * @cssproperty --media-menu-item-indicator-fill - `fill` color of indicator icon. * @cssproperty --media-menu-item-indicator-height - `height` of menu-item indicator. */ class MediaChromeMenuItem extends globalThis.HTMLElement { static shadowRootOptions = { mode: 'open' as ShadowRootMode }; static getTemplateHTML = getTemplateHTML; static getSuffixSlotInnerHTML = getSuffixSlotInnerHTML; static get observedAttributes() { return [ Attributes.TYPE, Attributes.DISABLED, Attributes.CHECKED, Attributes.VALUE, ]; } #dirty = false; #ownerElement: MediaChromeMenu | null; constructor() { super(); if (!this.shadowRoot) { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.attachShadow((this.constructor as typeof MediaChromeMenuItem).shadowRootOptions); const attrs = namedNodeMapToObject(this.attributes); this.shadowRoot.innerHTML = (this.constructor as typeof MediaChromeMenuItem).getTemplateHTML(attrs); } } enable() { if (!this.hasAttribute('tabindex')) { this.setAttribute('tabindex', '-1'); } if (isCheckable(this) && !this.hasAttribute('aria-checked')) { this.setAttribute('aria-checked', 'false'); } this.addEventListener('click', this); this.addEventListener('keydown', this); } disable() { this.removeAttribute('tabindex'); this.removeEventListener('click', this); this.removeEventListener('keydown', this); this.removeEventListener('keyup', this); } handleEvent(event) { switch (event.type) { case 'slotchange': this.#handleSlotChange(event); break; case 'click': this.handleClick(event); break; case 'keydown': this.#handleKeyDown(event); break; case 'keyup': this.#handleKeyUp(event); break; } } attributeChangedCallback( attrName: string, oldValue: string | null, newValue: string | null ): void { if (attrName === Attributes.CHECKED && isCheckable(this) && !this.#dirty) { this.setAttribute('aria-checked', newValue != null ? 'true' : 'false'); } else if (attrName === Attributes.TYPE && newValue !== oldValue) { this.role = 'menuitem' + newValue; } else if (attrName === Attributes.DISABLED && newValue !== oldValue) { if (newValue == null) { this.enable(); } else { this.disable(); } } } connectedCallback(): void { if (!this.hasAttribute(Attributes.DISABLED)) { this.enable(); } this.role = 'menuitem' + this.type; this.#ownerElement = closestMenuItemsContainer(this, this.parentNode); this.#reset(); // Required when using declarative shadow DOM. if (this.submenuElement) { this.#submenuConnected(); } this.shadowRoot.addEventListener('slotchange', this); } disconnectedCallback(): void { this.disable(); this.#reset(); this.#ownerElement = null; this.shadowRoot.removeEventListener('slotchange', this); } get invokeTarget() { return this.getAttribute('invoketarget'); } set invokeTarget(value) { this.setAttribute('invoketarget', `${value}`); } /** * Returns the element with the id specified by the `invoketarget` attribute * or the slotted submenu element. */ get invokeTargetElement(): MediaChromeMenu | null { if (this.invokeTarget) { return getDocumentOrShadowRoot(this)?.querySelector( `#${this.invokeTarget}` ); } return this.submenuElement; } /** * Returns the slotted submenu element. */ get submenuElement(): MediaChromeMenu | null { /** @type {HTMLSlotElement} */ const submenuSlot: HTMLSlotElement = this.shadowRoot.querySelector( 'slot[name="submenu"]' ); return submenuSlot.assignedElements({ flatten: true, })[0] as MediaChromeMenu; } get type() { return this.getAttribute(Attributes.TYPE) ?? ''; } set type(val) { this.setAttribute(Attributes.TYPE, `${val}`); } get value() { return this.getAttribute(Attributes.VALUE) ?? this.text; } set value(val) { this.setAttribute(Attributes.VALUE, val); } get text() { return (this.textContent ?? '').trim(); } get checked() { if (!isCheckable(this)) return undefined; return this.getAttribute('aria-checked') === 'true'; } set checked(value) { if (!isCheckable(this)) return; this.#dirty = true; // Firefox doesn't support the property .ariaChecked. this.setAttribute('aria-checked', value ? 'true' : 'false'); if (value) { this.part.add('checked'); } else { this.part.remove('checked'); } } #handleSlotChange(event) { const slot = event.target; const isDefaultSlot = !slot?.name; if (isDefaultSlot) { for (const node of slot.assignedNodes({ flatten: true })) { // Remove all whitespace text nodes so the unnamed slot shows its fallback content. if (node instanceof Text && node.textContent.trim() === '') { node.remove(); } } } if (slot.name === 'submenu') { if (this.submenuElement) { this.#submenuConnected(); } else { this.#submenuDisconnected(); } } } async #submenuConnected() { this.setAttribute('aria-haspopup', 'menu'); this.setAttribute('aria-expanded', `${!this.submenuElement.hidden}`); this.submenuElement.addEventListener('change', this.#handleMenuItem); this.submenuElement.addEventListener('addmenuitem', this.#handleMenuItem); this.submenuElement.addEventListener( 'removemenuitem', this.#handleMenuItem ); this.#handleMenuItem(); } #submenuDisconnected() { this.removeAttribute('aria-haspopup'); this.removeAttribute('aria-expanded'); this.submenuElement.removeEventListener('change', this.#handleMenuItem); this.submenuElement.removeEventListener( 'addmenuitem', this.#handleMenuItem ); this.submenuElement.removeEventListener( 'removemenuitem', this.#handleMenuItem ); this.#handleMenuItem(); } /** * If there is a slotted submenu the fallback content of the description slot * is populated with the text of the first checked item. */ #handleMenuItem = () => { if (this.submenuElement.items) { this.setAttribute('submenusize', `${this.submenuElement.items.length}`); } const descriptionSlot = this.shadowRoot.querySelector( 'slot[name="description"]' ); const checkedItem = this.submenuElement.checkedItems?.[0]; const description = checkedItem?.dataset.description ?? checkedItem?.text; const span = document.createElement('span'); span.textContent = description ?? ''; descriptionSlot.replaceChildren(span); }; handleClick(event) { // Checkable menu items are handled in media-chrome-menu. if (isCheckable(this)) return; if (this.invokeTargetElement && containsComposedNode(this, event.target)) { this.invokeTargetElement.dispatchEvent( new InvokeEvent({ relatedTarget: this }) ); } } get keysUsed() { return ['Enter', ' ']; } #handleKeyUp = (event: KeyboardEvent) => { const { key } = event; if (!this.keysUsed.includes(key)) { this.removeEventListener('keyup', this.#handleKeyUp); return; } this.handleClick(event); } #handleKeyDown = (event: KeyboardEvent) => { const { metaKey, altKey, key } = event; if (metaKey || altKey || !this.keysUsed.includes(key)) { this.removeEventListener('keyup', this.#handleKeyUp); return; } this.addEventListener('keyup', this.#handleKeyUp, { once: true }); } #reset() { const items = this.#ownerElement?.radioGroupItems; if (!items) return; // Default to the last aria-checked element if there isn't an active element already. let checkedItem = items .filter((item) => item.getAttribute('aria-checked') === 'true') .pop(); // If there isn't an active element or a checked element, default to the first element. if (!checkedItem) checkedItem = items[0]; for (const item of items) { item.setAttribute('aria-checked', 'false'); } checkedItem?.setAttribute('aria-checked', 'true'); } } function isCheckable(item) { return item.type === 'radio' || item.type === 'checkbox'; } function closestMenuItemsContainer(childNode, parentNode) { if (!childNode) return null; const { host } = childNode.getRootNode(); if (!parentNode && host) return closestMenuItemsContainer(childNode, host); if (parentNode?.items) return parentNode; return closestMenuItemsContainer(parentNode, parentNode?.parentNode); } if (!globalThis.customElements.get('media-chrome-menu-item')) { globalThis.customElements.define( 'media-chrome-menu-item', MediaChromeMenuItem ); } export { MediaChromeMenuItem }; export default MediaChromeMenuItem;