import { MediaStateReceiverAttributes, MediaUIAttributes, } from './constants.js'; import MediaTooltip, { TooltipPlacement } from './media-tooltip.js'; import { getBooleanAttr, getOrInsertCSSRule, getStringAttr, namedNodeMapToObject, setBooleanAttr, setStringAttr, } from './utils/element-utils.js'; import { globalThis } from './utils/server-safe-globals.js'; const Attributes = { TOOLTIP_PLACEMENT: 'tooltipplacement', DISABLED: 'disabled', NO_TOOLTIP: 'notooltip', }; function getTemplateHTML(_attrs: Record, _props: Record = {}) { return /*html*/ ` ${this.getSlotTemplateHTML(_attrs, _props)} `; } function getSlotTemplateHTML(_attrs: Record, _props: Record) { return /*html*/ ` `; } function getTooltipContentHTML() { return ''; } /** * @extends {HTMLElement} * * @attr {boolean} disabled - The Boolean disabled attribute makes the element not mutable or focusable. * @attr {string} mediacontroller - The element `id` of the media controller to connect to (if not nested within). * @attr {('top'|'right'|'bottom'|'left'|'none')} tooltipplacement - The placement of the tooltip, defaults to "top" * @attr {boolean} notooltip - Hides the tooltip if this attribute is present * * @cssproperty --media-primary-color - Default color of text and icon. * @cssproperty --media-secondary-color - Default color of button background. * @cssproperty --media-text-color - `color` of button text. * @cssproperty --media-icon-color - `fill` color of button icon. * * @cssproperty --media-control-display - `display` property of control. * @cssproperty --media-control-background - `background` of control. * @cssproperty --media-control-hover-background - `background` of control hover state. * @cssproperty --media-control-padding - `padding` of control. * @cssproperty --media-control-height - `line-height` of control. * * @cssproperty --media-font - `font` shorthand property. * @cssproperty --media-font-weight - `font-weight` property. * @cssproperty --media-font-family - `font-family` property. * @cssproperty --media-font-size - `font-size` property. * @cssproperty --media-text-content-height - `line-height` of button text. * * @cssproperty --media-button-icon-width - `width` of button icon. * @cssproperty --media-button-icon-height - `height` of button icon. * @cssproperty --media-button-icon-transform - `transform` of button icon. * @cssproperty --media-button-icon-transition - `transition` of button icon. * @cssproperty --media-button-justify-content - `justify-content` property of button. * @cssproperty --media-button-padding - `padding` of button. * @cssproperty --media-cursor - `cursor` property. * @cssproperty --media-focus-box-shadow - `box-shadow` of focused control. */ class MediaChromeButton extends globalThis.HTMLElement { static shadowRootOptions = { mode: 'open' as ShadowRootMode }; static getTemplateHTML = getTemplateHTML; static getSlotTemplateHTML = getSlotTemplateHTML; static getTooltipContentHTML = getTooltipContentHTML; #mediaController; preventClick = false; tooltipEl: MediaTooltip = null; static get observedAttributes() { return [ 'disabled', Attributes.TOOLTIP_PLACEMENT, MediaStateReceiverAttributes.MEDIA_CONTROLLER, MediaUIAttributes.MEDIA_LANG ]; } constructor() { super(); if (!this.shadowRoot) { // Set up the Shadow DOM if not using Declarative Shadow DOM. this.attachShadow((this.constructor as typeof MediaChromeButton).shadowRootOptions); const attrs = namedNodeMapToObject(this.attributes); const html = (this.constructor as typeof MediaChromeButton).getTemplateHTML(attrs); // From MDN: setHTMLUnsafe should be used instead of ShadowRoot.innerHTML // when a string of HTML may contain declarative shadow roots. this.shadowRoot.setHTMLUnsafe ? this.shadowRoot.setHTMLUnsafe(html) : this.shadowRoot.innerHTML = html; } this.tooltipEl = this.shadowRoot.querySelector('media-tooltip'); } #clickListener = (e: MouseEvent) => { if (!this.preventClick) { this.handleClick(e); } // Timeout needed to wait for a new "tick" of event loop otherwise // measured position does not take into account the new tooltip content setTimeout(this.#positionTooltip, 0); }; #positionTooltip = () => { // Conditional chaining accounts for scenarios // where the tooltip element isn't yet defined. this.tooltipEl?.updateXOffset?.(); }; // NOTE: There are definitely some "false positive" cases with multi-key pressing, // but this should be good enough for most use cases. #keyupListener = (e: KeyboardEvent) => { const { key } = e; if (!this.keysUsed.includes(key)) { this.removeEventListener('keyup', this.#keyupListener); return; } if (!this.preventClick) { this.handleClick(e); } }; #keydownListener = (e: KeyboardEvent) => { const { metaKey, altKey, key } = e; if (metaKey || altKey || !this.keysUsed.includes(key)) { this.removeEventListener('keyup', this.#keyupListener); return; } this.addEventListener('keyup', this.#keyupListener, { once: true }); }; enable() { this.addEventListener('click', this.#clickListener); this.addEventListener('keydown', this.#keydownListener); this.tabIndex = 0; } disable() { this.removeEventListener('click', this.#clickListener); this.removeEventListener('keydown', this.#keydownListener); this.removeEventListener('keyup', this.#keyupListener); this.tabIndex = -1; } attributeChangedCallback(attrName, oldValue, newValue) { if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) { if (oldValue) { this.#mediaController?.unassociateElement?.(this); this.#mediaController = null; } if (newValue && this.isConnected) { // @ts-ignore this.#mediaController = this.getRootNode()?.getElementById(newValue); this.#mediaController?.associateElement?.(this); } } else if (attrName === 'disabled' && newValue !== oldValue) { if (newValue == null) { this.enable(); } else { this.disable(); } } else if ( attrName === Attributes.TOOLTIP_PLACEMENT && this.tooltipEl && newValue !== oldValue ) { this.tooltipEl.placement = newValue; } else if (attrName === MediaUIAttributes.MEDIA_LANG) { this.shadowRoot.querySelector('slot[name="tooltip-content"]').innerHTML = (this.constructor as typeof MediaChromeButton).getTooltipContentHTML(); } // The tooltips label, and subsequently it's size and position, are a function // of the buttons state, so we greedily assume we need account for any form // of state change by reacting to all attribute changes, even if sometimes the // update might be redundant this.#positionTooltip(); } connectedCallback() { const { style } = getOrInsertCSSRule(this.shadowRoot, ':host'); style.setProperty( 'display', `var(--media-control-display, var(--${this.localName}-display, inline-flex))` ); if (!this.hasAttribute('disabled')) { this.enable(); } else { this.disable(); } this.setAttribute('role', 'button'); const mediaControllerId = this.getAttribute( MediaStateReceiverAttributes.MEDIA_CONTROLLER ); if (mediaControllerId) { this.#mediaController = // @ts-ignore this.getRootNode()?.getElementById(mediaControllerId); this.#mediaController?.associateElement?.(this); } globalThis.customElements .whenDefined('media-tooltip') .then(() => this.#setupTooltip()); } // Called when we know the tooltip is ready / defined #setupTooltip() { this.addEventListener('mouseenter', this.#positionTooltip); this.addEventListener('focus', this.#positionTooltip); this.addEventListener('click', this.#clickListener); const initialPlacement = this.tooltipPlacement; if (initialPlacement && this.tooltipEl) { this.tooltipEl.placement = initialPlacement; } } disconnectedCallback() { this.disable(); // Use cached mediaController, getRootNode() doesn't work if disconnected. this.#mediaController?.unassociateElement?.(this); this.#mediaController = null; this.removeEventListener('mouseenter', this.#positionTooltip); this.removeEventListener('focus', this.#positionTooltip); this.removeEventListener('click', this.#clickListener); } get keysUsed() { return ['Enter', ' ']; } /** * Get or set tooltip placement */ get tooltipPlacement(): TooltipPlacement | undefined { return getStringAttr(this, Attributes.TOOLTIP_PLACEMENT); } set tooltipPlacement(value: TooltipPlacement | undefined) { setStringAttr(this, Attributes.TOOLTIP_PLACEMENT, value); } get mediaController(): string | undefined { return getStringAttr(this, MediaStateReceiverAttributes.MEDIA_CONTROLLER); } set mediaController(value: string | undefined) { setStringAttr(this, MediaStateReceiverAttributes.MEDIA_CONTROLLER, value); } get disabled(): boolean | undefined { return getBooleanAttr(this, Attributes.DISABLED); } set disabled(value: boolean | undefined) { setBooleanAttr(this, Attributes.DISABLED, value); } get noTooltip(): boolean | undefined { return getBooleanAttr(this, Attributes.NO_TOOLTIP); } set noTooltip(value: boolean | undefined) { setBooleanAttr(this, Attributes.NO_TOOLTIP, value); } /** * @abstract */ handleClick(_e: Event) {} } if (!globalThis.customElements.get('media-chrome-button')) { globalThis.customElements.define('media-chrome-button', MediaChromeButton); } export { MediaChromeButton }; export default MediaChromeButton;