UNPKG

media-chrome

Version:

Custom elements (web components) for making audio and video player controls that look great in your website or app.

491 lines (408 loc) • 13.9 kB
import { globalThis, document } from '../utils/server-safe-globals.js'; import { InvokeEvent } from '../utils/events.js'; import { getDocumentOrShadowRoot, containsComposedNode, } from '../utils/element-utils.js'; import type MediaChromeMenu from './media-chrome-menu.js'; const template: HTMLTemplateElement = document.createElement('template'); template.innerHTML = /*html*/ ` <style> :host { transition: var(--media-menu-item-transition, background .15s linear, opacity .2s ease-in-out ); outline: var(--media-menu-item-outline, 0); outline-offset: var(--media-menu-item-outline-offset, -1px); cursor: var(--media-cursor, pointer); display: flex; align-items: center; align-self: stretch; justify-self: stretch; white-space: nowrap; white-space-collapse: collapse; text-wrap: nowrap; padding: .4em .8em .4em 1em; } :host(:focus-visible) { box-shadow: var(--media-menu-item-focus-shadow, inset 0 0 0 2px rgb(27 127 204 / .9)); outline: var(--media-menu-item-hover-outline, 0); outline-offset: var(--media-menu-item-hover-outline-offset, var(--media-menu-item-outline-offset, -1px)); } :host(:hover) { cursor: var(--media-cursor, pointer); background: var(--media-menu-item-hover-background, rgb(92 92 102 / .5)); outline: var(--media-menu-item-hover-outline); outline-offset: var(--media-menu-item-hover-outline-offset, var(--media-menu-item-outline-offset, -1px)); } :host([aria-checked="true"]) { background: var(--media-menu-item-checked-background); } :host([hidden]) { display: none; } :host([disabled]) { pointer-events: none; color: rgba(255, 255, 255, .3); } slot:not([name]) { width: 100%; } slot:not([name="submenu"]) { display: inline-flex; align-items: center; transition: inherit; opacity: var(--media-menu-item-opacity, 1); } slot[name="description"] { justify-content: end; } slot[name="description"] > span { display: inline-block; margin-inline: 1em .2em; max-width: var(--media-menu-item-description-max-width, 100px); text-overflow: ellipsis; overflow: hidden; font-size: .8em; font-weight: 400; text-align: right; position: relative; top: .04em; } slot[name="checked-indicator"] { display: none; } :host(:is([role="menuitemradio"],[role="menuitemcheckbox"])) slot[name="checked-indicator"] { display: var(--media-menu-item-checked-indicator-display, inline-block); } ${/* For all slotted icons in prefix and suffix. */ ''} svg, img, ::slotted(svg), ::slotted(img) { height: var(--media-menu-item-icon-height, var(--media-control-height, 24px)); fill: var(--media-icon-color, var(--media-primary-color, rgb(238 238 238))); display: block; } ${ /* Only for indicator icons like checked-indicator or captions-indicator. */ '' } [part~="indicator"], ::slotted([part~="indicator"]) { fill: var(--media-menu-item-indicator-fill, var(--media-icon-color, var(--media-primary-color, rgb(238 238 238)))); height: var(--media-menu-item-indicator-height, 1.25em); margin-right: .5ch; } [part~="checked-indicator"] { visibility: hidden; } :host([aria-checked="true"]) [part~="checked-indicator"] { visibility: visible; } </style> <slot name="checked-indicator"> <svg aria-hidden="true" viewBox="0 1 24 24" part="checked-indicator indicator"> <path d="m10 15.17 9.193-9.191 1.414 1.414-10.606 10.606-6.364-6.364 1.414-1.414 4.95 4.95Z"/> </svg> </slot> <slot name="prefix"></slot> <slot></slot> <slot name="description"></slot> <slot name="suffix"></slot> <slot name="submenu"></slot> `; 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 template = template; static get observedAttributes() { return [ Attributes.TYPE, Attributes.DISABLED, Attributes.CHECKED, Attributes.VALUE, ]; } #dirty = false; #ownerElement; constructor() { super(); if (!this.shadowRoot) { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.attachShadow({ mode: 'open' }); // @ts-ignore this.shadowRoot.append(this.constructor.template.content.cloneNode(true)); } this.shadowRoot.addEventListener('slotchange', this); } 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(); } disconnectedCallback(): void { this.disable(); this.#reset(); this.#ownerElement = null; } 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 = () => { 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) { const { key } = event; if (!this.keysUsed.includes(key)) { this.removeEventListener('keyup', this.#handleKeyUp); return; } this.handleClick(event); } #handleKeyDown(event) { 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;