media-chrome
Version:
Custom elements (web components) for making audio and video player controls that look great in your website or app.
997 lines (837 loc) • 29.9 kB
text/typescript
import { MediaStateReceiverAttributes } from '../constants.js';
import { globalThis, document } from '../utils/server-safe-globals.js';
import { computePosition } from '../utils/anchor-utils.js';
import { observeResize, unobserveResize } from '../utils/resize-observer.js';
import { ToggleEvent, InvokeEvent } from '../utils/events.js';
import {
getActiveElement,
containsComposedNode,
closestComposedNode,
insertCSSRule,
getMediaController,
getAttributeMediaController,
getDocumentOrShadowRoot,
namedNodeMapToObject,
} from '../utils/element-utils.js';
import MediaChromeMenuItem from './media-chrome-menu-item.js';
import MediaController from '../media-controller.js';
export function createMenuItem({
type,
text,
value,
checked,
}: {
type?: string;
text: string;
value: string;
checked: boolean;
}) {
const item = document.createElement(
'media-chrome-menu-item'
) as MediaChromeMenuItem;
item.type = type ?? '';
item.part.add('menu-item');
if (type) item.part.add(type);
item.value = value;
item.checked = checked;
const label = document.createElement('span');
label.textContent = text;
item.append(label);
return item;
}
export function createIndicator(el: HTMLElement, name: string) {
let customIndicator = el.querySelector(`:scope > [slot="${name}"]`);
// Chaining slots
if (customIndicator?.nodeName == 'SLOT')
// @ts-ignore
customIndicator = customIndicator.assignedElements({ flatten: true })[0];
if (customIndicator) {
// @ts-ignore
customIndicator = customIndicator.cloneNode(true);
return customIndicator;
}
const fallbackIndicator = el.shadowRoot.querySelector(
`[name="${name}"] > svg`
);
if (fallbackIndicator) {
return fallbackIndicator.cloneNode(true);
}
// Return an empty string if no indicator is found to use the slot fallback.
return '';
}
function getTemplateHTML(_attrs: Record<string, string>) {
return /*html*/ `
<style>
:host {
font: var(--media-font,
var(--media-font-weight, normal)
var(--media-font-size, 14px) /
var(--media-text-content-height, var(--media-control-height, 24px))
var(--media-font-family, helvetica neue, segoe ui, roboto, arial, sans-serif));
color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
--_menu-bg: rgb(20 20 30 / .8);
background: var(--media-menu-background, var(--media-control-background, var(--media-secondary-color, var(--_menu-bg))));
border-radius: var(--media-menu-border-radius);
border: var(--media-menu-border, none);
display: var(--media-menu-display, inline-flex) !important;
${/* ^^Prevent override by Tailwind CSS causing the menu to not hide properly. */ ''}
transition: var(--media-menu-transition-in,
visibility 0s,
opacity .2s ease-out,
transform .15s ease-out,
left .2s ease-in-out,
min-width .2s ease-in-out,
min-height .2s ease-in-out
) !important;
${/* ^^Prevent transition override by media-container */ ''}
visibility: var(--media-menu-visibility, visible);
opacity: var(--media-menu-opacity, 1);
max-height: var(--media-menu-max-height, var(--_menu-max-height, 300px));
transform: var(--media-menu-transform-in, translateY(0) scale(1));
flex-direction: column;
${/* Prevent overflowing a flex container */ ''}
min-height: 0;
position: relative;
bottom: var(--_menu-bottom);
box-sizing: border-box;
}
@-moz-document url-prefix() {
:host{
--_menu-bg: rgb(20 20 30);
}
}
:host([hidden]) {
transition: var(--media-menu-transition-out,
visibility .15s ease-in,
opacity .15s ease-in,
transform .15s ease-in
) !important;
visibility: var(--media-menu-hidden-visibility, hidden);
opacity: var(--media-menu-hidden-opacity, 0);
max-height: var(--media-menu-hidden-max-height,
var(--media-menu-max-height, var(--_menu-max-height, 300px)));
transform: var(--media-menu-transform-out, translateY(2px) scale(.99));
pointer-events: none;
}
:host([slot="submenu"]) {
background: none;
width: 100%;
min-height: 100%;
position: absolute;
bottom: 0;
right: -100%;
}
#container {
display: flex;
flex-direction: column;
min-height: 0;
transition: transform .2s ease-out;
transform: translate(0, 0);
}
#container.has-expanded {
transition: transform .2s ease-in;
transform: translate(-100%, 0);
}
button {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
outline: inherit;
display: inline-flex;
align-items: center;
}
slot[name="header"][hidden] {
display: none;
}
slot[name="header"] > *,
slot[name="header"]::slotted(*) {
padding: .4em .7em;
border-bottom: 1px solid rgb(255 255 255 / .25);
cursor: var(--media-cursor, default);
}
slot[name="header"] > button[part~="back"],
slot[name="header"]::slotted(button[part~="back"]) {
cursor: var(--media-cursor, pointer);
}
svg[part~="back"] {
height: var(--media-menu-icon-height, var(--media-control-height, 24px));
fill: var(--media-icon-color, var(--media-primary-color, rgb(238 238 238)));
display: block;
margin-right: .5ch;
}
slot:not([name]) {
gap: var(--media-menu-gap);
flex-direction: var(--media-menu-flex-direction, column);
overflow: var(--media-menu-overflow, hidden auto);
display: flex;
min-height: 0;
}
:host([role="menu"]) slot:not([name]) {
padding-block: .4em;
}
slot:not([name])::slotted([role="menu"]) {
background: none;
}
media-chrome-menu-item > span {
margin-right: .5ch;
max-width: var(--media-menu-item-max-width);
text-overflow: ellipsis;
overflow: hidden;
}
</style>
<style id="layout-row" media="width:0">
slot[name="header"] > *,
slot[name="header"]::slotted(*) {
padding: .4em .5em;
}
slot:not([name]) {
gap: var(--media-menu-gap, .25em);
flex-direction: var(--media-menu-flex-direction, row);
padding-inline: .5em;
}
media-chrome-menu-item {
padding: .3em .5em;
}
media-chrome-menu-item[aria-checked="true"] {
background: var(--media-menu-item-checked-background, rgb(255 255 255 / .2));
}
${/* In row layout hide the checked indicator completely. */ ''}
media-chrome-menu-item::part(checked-indicator) {
display: var(--media-menu-item-checked-indicator-display, none);
}
</style>
<div id="container" part="container">
<slot name="header" hidden>
<button part="back button" aria-label="Back to previous menu">
<slot name="back-icon">
<svg aria-hidden="true" viewBox="0 0 20 24" part="back indicator">
<path d="m11.88 17.585.742-.669-4.2-4.665 4.2-4.666-.743-.669-4.803 5.335 4.803 5.334Z"/>
</svg>
</slot>
<slot name="title"></slot>
</button>
</slot>
<slot></slot>
</div>
<slot name="checked-indicator" hidden></slot>
`;
}
export const Attributes = {
STYLE: 'style',
HIDDEN: 'hidden',
DISABLED: 'disabled',
ANCHOR: 'anchor',
} as const;
/**
* @extends {HTMLElement}
*
* @slot - Default slotted elements.
* @slot header - An element shown at the top of the menu.
* @slot checked-indicator - An icon element indicating a checked menu-item.
*
* @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).
*
* @cssproperty --media-primary-color - Default color of text / icon.
* @cssproperty --media-secondary-color - Default color of background.
* @cssproperty --media-text-color - `color` of text.
*
* @cssproperty --media-control-background - `background` of control.
* @cssproperty --media-menu-display - `display` of menu.
* @cssproperty --media-menu-layout - Set to `row` for a horizontal menu design.
* @cssproperty --media-menu-flex-direction - `flex-direction` of menu.
* @cssproperty --media-menu-gap - `gap` between menu items.
* @cssproperty --media-menu-background - `background` of menu.
* @cssproperty --media-menu-border-radius - `border-radius` of menu.
* @cssproperty --media-menu-border - `border` of menu.
* @cssproperty --media-menu-transition-in - `transition` of menu when showing.
* @cssproperty --media-menu-transition-out - `transition` of menu when hiding.
* @cssproperty --media-menu-visibility - `visibility` of menu when showing.
* @cssproperty --media-menu-hidden-visibility - `visibility` of menu when hiding.
* @cssproperty --media-menu-max-height - `max-height` of menu.
* @cssproperty --media-menu-hidden-max-height - `max-height` of menu when hiding.
* @cssproperty --media-menu-opacity - `opacity` of menu when showing.
* @cssproperty --media-menu-hidden-opacity - `opacity` of menu when hiding.
* @cssproperty --media-menu-transform-in - `transform` of menu when showing.
* @cssproperty --media-menu-transform-out - `transform` of menu when hiding.
*
* @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 text.
*
* @cssproperty --media-icon-color - `fill` color of icon.
* @cssproperty --media-menu-icon-height - `height` of icon.
* @cssproperty --media-menu-item-checked-indicator-display - `display` of check indicator.
* @cssproperty --media-menu-item-checked-background - `background` of checked menu item.
* @cssproperty --media-menu-item-max-width - `max-width` of menu item text.
* @cssproperty --media-menu-overflow - `overflow` property of menu.
*/
class MediaChromeMenu extends globalThis.HTMLElement {
static shadowRootOptions = { mode: 'open' as ShadowRootMode };
static getTemplateHTML = getTemplateHTML;
static get observedAttributes(): string[] {
return [
Attributes.DISABLED,
Attributes.HIDDEN,
Attributes.STYLE,
Attributes.ANCHOR,
MediaStateReceiverAttributes.MEDIA_CONTROLLER,
];
}
static formatMenuItemText(text: string, _data?: any): string {
return text;
}
#mediaController: MediaController | null = null;
#previouslyFocused: HTMLElement | null = null;
#invokerElement: HTMLElement | null = null;
#previousItems = new Set<MediaChromeMenuItem>();
#mutationObserver: MutationObserver;
#isPopover = false;
#cssRule: CSSStyleRule | null = null;
container: HTMLElement;
defaultSlot: HTMLSlotElement;
constructor() {
super();
if (!this.shadowRoot) {
// Set up the Shadow DOM if not using Declarative Shadow DOM.
this.attachShadow((this.constructor as typeof MediaChromeMenu).shadowRootOptions);
const attrs = namedNodeMapToObject(this.attributes);
this.shadowRoot.innerHTML = (this.constructor as typeof MediaChromeMenu).getTemplateHTML(attrs);
}
this.container = this.shadowRoot.querySelector('#container') as HTMLElement;
this.defaultSlot = this.shadowRoot.querySelector(
'slot:not([name])'
) as HTMLSlotElement;
this.#mutationObserver = new MutationObserver(this.#handleMenuItems);
}
enable(): void {
this.addEventListener('click', this);
this.addEventListener('focusout', this);
this.addEventListener('keydown', this);
this.addEventListener('invoke', this);
this.addEventListener('toggle', this);
}
disable(): void {
this.removeEventListener('click', this);
this.removeEventListener('focusout', this);
this.removeEventListener('keyup', this);
this.removeEventListener('invoke', this);
this.removeEventListener('toggle', this);
}
handleEvent(event: Event): void {
switch (event.type) {
case 'slotchange':
this.#handleSlotChange(event as Event);
break;
case 'invoke':
this.#handleInvoke(event as InvokeEvent);
break;
case 'click':
this.#handleClick(event as MouseEvent);
break;
case 'toggle':
this.#handleToggle(event as ToggleEvent);
break;
case 'focusout':
this.#handleFocusOut(event as FocusEvent);
break;
case 'keydown':
this.#handleKeyDown(event as KeyboardEvent);
break;
}
}
connectedCallback(): void {
this.#mutationObserver.observe(this.defaultSlot, { childList: true });
this.#cssRule = insertCSSRule(this.shadowRoot, ':host');
this.#updateLayoutStyle();
if (!this.hasAttribute('disabled')) {
this.enable();
}
if (!this.role) {
// set menu role on the media-chrome-menu element itself
// this is to make sure that SRs announce items as being part
// of a menu when focused
this.role = 'menu';
}
this.#mediaController = getAttributeMediaController(this);
this.#mediaController?.associateElement?.(this);
if (!this.hidden) {
observeResize(getBoundsElement(this), this.#handleBoundsResize);
observeResize(this, this.#handleMenuResize);
}
// Required when using declarative shadow DOM.
this.#toggleHeader();
this.shadowRoot.addEventListener('slotchange', this);
}
disconnectedCallback(): void {
this.#mutationObserver.disconnect();
unobserveResize(getBoundsElement(this), this.#handleBoundsResize);
unobserveResize(this, this.#handleMenuResize);
this.disable();
// Use cached mediaController, getRootNode() doesn't work if disconnected.
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
this.#previouslyFocused = null;
this.#invokerElement = null;
this.shadowRoot.removeEventListener('slotchange', this);
}
attributeChangedCallback(
attrName: string,
oldValue: string | null,
newValue: string | null
): void {
if (attrName === Attributes.HIDDEN && newValue !== oldValue) {
if (!this.#isPopover) this.#isPopover = true;
if (this.hidden) {
this.#handleClosed();
} else {
this.#handleOpen();
}
// Fire a toggle event from a submenu which can be used in a parent menu.
this.dispatchEvent(
new ToggleEvent({
oldState: this.hidden ? 'open' : 'closed',
newState: this.hidden ? 'closed' : 'open',
bubbles: true,
})
);
} else if (attrName === MediaStateReceiverAttributes.MEDIA_CONTROLLER) {
if (oldValue) {
this.#mediaController?.unassociateElement?.(this);
this.#mediaController = null;
}
if (newValue && this.isConnected) {
this.#mediaController = getAttributeMediaController(this);
this.#mediaController?.associateElement?.(this);
}
} else if (attrName === Attributes.DISABLED && newValue !== oldValue) {
if (newValue == null) {
this.enable();
} else {
this.disable();
}
} else if (attrName === Attributes.STYLE && newValue !== oldValue) {
this.#updateLayoutStyle();
}
}
formatMenuItemText(text: string, data?: any) {
return (this.constructor as typeof MediaChromeMenu).formatMenuItemText(
text,
data
);
}
get anchor() {
return this.getAttribute('anchor');
}
set anchor(value: string) {
this.setAttribute('anchor', `${value}`);
}
/**
* Returns the anchor element when it is a floating menu.
*/
get anchorElement() {
if (this.anchor) {
return getDocumentOrShadowRoot(this)?.querySelector<HTMLElement>(
`#${this.anchor}`
);
}
return null;
}
/**
* Returns the menu items.
*/
get items(): MediaChromeMenuItem[] {
return this.defaultSlot
.assignedElements({ flatten: true })
.filter(isMenuItem);
}
get radioGroupItems(): MediaChromeMenuItem[] {
return this.items.filter((item) => item.role === 'menuitemradio');
}
get checkedItems(): MediaChromeMenuItem[] {
return this.items.filter((item) => item.checked);
}
get value(): string {
return this.checkedItems[0]?.value ?? '';
}
set value(newValue: string) {
const item = this.items.find((item) => item.value === newValue);
if (!item) return;
this.#selectItem(item);
}
#handleSlotChange(event: Event) {
const slot = event.target as HTMLSlotElement;
// @ts-ignore
for (const node of slot.assignedNodes({ flatten: true }) as HTMLElement[]) {
// Remove all whitespace text nodes so the unnamed slot shows its fallback content.
if (node.nodeType === 3 && node.textContent.trim() === '') {
node.remove();
}
}
if (['header', 'title'].includes(slot.name)) {
this.#toggleHeader();
}
if (!slot.name) {
this.#handleMenuItems();
}
}
#toggleHeader() {
const header = this.shadowRoot.querySelector(
'slot[name="header"]'
) as HTMLSlotElement;
const title = this.shadowRoot.querySelector(
'slot[name="title"]'
) as HTMLSlotElement;
header.hidden =
title.assignedNodes().length === 0 && header.assignedNodes().length === 0;
}
/**
* Fires an event when a menu item is added or removed.
* This is needed to update the description slot of an ancestor menu item.
*/
#handleMenuItems = () => {
const previousItems = this.#previousItems;
const currentItems = new Set(this.items);
for (const item of previousItems) {
if (!currentItems.has(item)) {
this.dispatchEvent(new CustomEvent('removemenuitem', { detail: item }));
}
}
for (const item of currentItems) {
if (!previousItems.has(item)) {
this.dispatchEvent(new CustomEvent('addmenuitem', { detail: item }));
}
}
this.#previousItems = currentItems;
};
/**
* Sets the layout style for the menu.
* It can be a row or column layout. e.g. playback-rate-menu
*/
#updateLayoutStyle() {
const layoutRowStyle = this.shadowRoot.querySelector('#layout-row');
const menuLayout = getComputedStyle(this)
.getPropertyValue('--media-menu-layout')
?.trim();
layoutRowStyle.setAttribute('media', menuLayout === 'row' ? '' : 'width:0');
}
#handleInvoke(event: InvokeEvent) {
this.#invokerElement = event.relatedTarget as HTMLElement;
if (!containsComposedNode(this, event.relatedTarget)) {
this.hidden = !this.hidden;
}
}
#handleOpen() {
this.#invokerElement?.setAttribute('aria-expanded', 'true');
// Focus when the transition ends.
this.addEventListener('transitionend', () => this.focus(), { once: true });
// A resize callback is also fired when the menu is opened.
observeResize(getBoundsElement(this), this.#handleBoundsResize);
observeResize(this, this.#handleMenuResize);
}
#handleClosed() {
this.#invokerElement?.setAttribute('aria-expanded', 'false');
unobserveResize(getBoundsElement(this), this.#handleBoundsResize);
unobserveResize(this, this.#handleMenuResize);
}
#handleBoundsResize = () => {
this.#positionMenu();
this.#resizeMenu(false);
};
#handleMenuResize = () => {
this.#positionMenu();
};
/**
* Updates the popover menu position based on the anchor element.
* @param {number} [menuWidth]
*/
#positionMenu(menuWidth?: number) {
// Can't position if the menu doesn't have an anchor and isn't a child of a media controller.
if (this.hasAttribute('mediacontroller') && !this.anchor) return;
// If the menu is hidden or there is no anchor, skip updating the menu position.
if (this.hidden || !this.anchorElement) return;
const { x, y } = computePosition({
anchor: this.anchorElement,
floating: this,
placement: 'top-start',
});
menuWidth ??= this.offsetWidth;
const bounds = getBoundsElement(this);
const boundsRect = bounds.getBoundingClientRect();
const right = boundsRect.width - x - menuWidth;
const bottom = boundsRect.height - y - this.offsetHeight;
const { style } = this.#cssRule;
style.setProperty('position', 'absolute');
style.setProperty('right', `${Math.max(0, right)}px`);
style.setProperty('--_menu-bottom', `${bottom}px`);
// Determine the real bottom value that is used for the max-height calculation.
// `bottom` could have been overridden externally.
const computedStyle = getComputedStyle(this);
const isBottomCalc =
style.getPropertyValue('--_menu-bottom') === computedStyle.bottom;
const realBottom = isBottomCalc ? bottom : parseFloat(computedStyle.bottom);
const maxHeight =
boundsRect.height - realBottom - parseFloat(computedStyle.marginBottom);
// Safari required directly setting the element style property instead of
// updating the style node for the styles to be refreshed.
this.style.setProperty('--_menu-max-height', `${maxHeight}px`);
}
/**
* Resize this menu to fit the submenu.
* @param {boolean} animate
*/
#resizeMenu(animate: boolean) {
const expandedMenuItem = this.querySelector(
'[role="menuitem"][aria-haspopup][aria-expanded="true"]'
) as MediaChromeMenuItem;
const expandedSubmenu = expandedMenuItem?.querySelector(
'[role="menu"]'
) as MediaChromeMenu;
const { style } = this.#cssRule;
if (!animate) {
style.setProperty('--media-menu-transition-in', 'none');
}
if (expandedSubmenu) {
const height = expandedSubmenu.offsetHeight;
const width = Math.max(
expandedSubmenu.offsetWidth,
expandedMenuItem.offsetWidth
);
// Safari required directly setting the style property instead of
// updating the style node for the min-width or min-height to work.
this.style.setProperty('min-width', `${width}px`);
this.style.setProperty('min-height', `${height}px`);
this.#positionMenu(width);
} else {
this.style.removeProperty('min-width');
this.style.removeProperty('min-height');
this.#positionMenu();
}
style.removeProperty('--media-menu-transition-in');
}
focus() {
this.#previouslyFocused = getActiveElement();
if (this.items.length) {
this.#setTabItem(this.items[0]);
this.items[0].focus();
return;
}
// If there are no menu items, focus on the first focusable child.
const focusable = this.querySelector(
'[autofocus], [tabindex]:not([tabindex="-1"]), [role="menu"]'
) as HTMLElement;
focusable?.focus();
}
#handleClick(event: MouseEvent) {
// Prevent running this in a parent menu if the event target is a sub menu.
event.stopPropagation();
if (event.composedPath().includes(this.#backButtonElement)) {
this.#previouslyFocused?.focus();
this.hidden = true;
return;
}
const item = this.#getItem(event);
if (!item || item.hasAttribute('disabled')) return;
this.#setTabItem(item);
this.handleSelect(event);
}
get #backButtonElement() {
const headerSlot = this.shadowRoot.querySelector(
'slot[name="header"]'
) as HTMLSlotElement;
return headerSlot
.assignedElements({ flatten: true })
?.find((el) => el.matches('button[part~="back"]')) as HTMLElement;
}
handleSelect(event: MouseEvent | KeyboardEvent): void {
const item = this.#getItem(event);
if (!item) return;
this.#selectItem(item, item.type === 'checkbox');
// If the menu was opened by a click, close it when selecting an item.
if (this.#invokerElement && !this.hidden) {
this.#previouslyFocused?.focus();
this.hidden = true;
}
}
/**
* Handle the toggle event of submenus.
* Closes all other open submenus when opening a submenu.
* Resizes this menu to fit the submenu.
*
* @param {ToggleEvent} event
*/
#handleToggle(event: ToggleEvent): void {
// Only handle events of submenus.
if (event.target === this) return;
this.#checkSubmenuHasExpanded();
const menuItemsWithSubmenu = Array.from(
this.querySelectorAll('[role="menuitem"][aria-haspopup]')
) as MediaChromeMenuItem[];
// Close all other open submenus.
for (const item of menuItemsWithSubmenu) {
if (item.invokeTargetElement == event.target) continue;
if (
event.newState == 'open' &&
item.getAttribute('aria-expanded') == 'true' &&
!item.invokeTargetElement.hidden
) {
item.invokeTargetElement.dispatchEvent(
new InvokeEvent({ relatedTarget: item })
);
}
}
// Keep the aria-expanded attribute in sync with the hidden state of the submenu.
// This is needed when loading media-chrome with an unhidden submenu.
for (const item of menuItemsWithSubmenu) {
item.setAttribute('aria-expanded', `${!item.submenuElement.hidden}`);
}
this.#resizeMenu(true);
}
/**
* Check if any submenu is expanded and update the container class accordingly.
* When the CSS :has() selector is supported, this can be done with CSS only.
*/
#checkSubmenuHasExpanded() {
const selector = '[role="menuitem"] > [role="menu"]:not([hidden])';
const expandedMenuItem = this.querySelector(selector);
this.container.classList.toggle('has-expanded', !!expandedMenuItem);
}
#handleFocusOut(event: FocusEvent) {
if (!containsComposedNode(this, event.relatedTarget as Node)) {
if (this.#isPopover) {
this.#previouslyFocused?.focus();
}
// If the menu was opened by a click, close it when selecting an item.
if (
this.#invokerElement &&
this.#invokerElement !== event.relatedTarget &&
!this.hidden
) {
this.hidden = true;
}
}
}
get keysUsed() {
return [
'Enter',
'Escape',
'Tab',
' ',
'ArrowDown',
'ArrowUp',
'Home',
'End',
];
}
#handleKeyDown(event: KeyboardEvent) {
const { key, ctrlKey, altKey, metaKey } = event;
if (ctrlKey || altKey || metaKey) {
return;
}
if (!this.keysUsed.includes(key)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (key === 'Tab') {
if (this.#isPopover) {
// Close all menus when tabbing out.
this.hidden = true;
return;
}
// Move focus to the previous focusable element.
if (event.shiftKey) {
(this.previousElementSibling as HTMLElement)?.focus?.();
} else {
// Move focus to the next focusable element.
(this.nextElementSibling as HTMLElement)?.focus?.();
}
// Go back to the previous focused element.
this.blur();
} else if (key === 'Escape') {
// Go back to the previous menu or close the menu.
this.#previouslyFocused?.focus();
if (this.#isPopover) {
this.hidden = true;
}
} else if (key === 'Enter' || key === ' ') {
this.handleSelect(event);
} else {
this.handleMove(event);
}
}
#getItem(event: MouseEvent | KeyboardEvent) {
return event.composedPath().find((el) => {
return ['menuitemradio', 'menuitemcheckbox'].includes(
(el as HTMLElement).role
);
}) as MediaChromeMenuItem | undefined;
}
#getTabItem() {
return this.items.find((item) => item.tabIndex === 0);
}
#setTabItem(tabItem: MediaChromeMenuItem) {
for (const item of this.items) {
item.tabIndex = item === tabItem ? 0 : -1;
}
}
#selectItem(item: MediaChromeMenuItem, toggle?: boolean) {
const oldCheckedItems = [...this.checkedItems];
if (item.type === 'radio') {
this.radioGroupItems.forEach((el) => (el.checked = false));
}
if (toggle) {
item.checked = !item.checked;
} else {
item.checked = true;
}
if (this.checkedItems.some((opt, i) => opt != oldCheckedItems[i])) {
this.dispatchEvent(
new Event('change', { bubbles: true, composed: true })
);
}
}
handleMove(event: KeyboardEvent) {
const { key } = event;
const items = this.items;
const currentItem = this.#getItem(event) ?? this.#getTabItem() ?? items[0];
const currentIndex = items.indexOf(currentItem);
let index = Math.max(0, currentIndex);
if (key === 'ArrowDown') {
index++;
} else if (key === 'ArrowUp') {
index--;
} else if (event.key === 'Home') {
index = 0;
} else if (event.key === 'End') {
index = items.length - 1;
}
if (index < 0) {
index = items.length - 1;
}
if (index > items.length - 1) {
index = 0;
}
this.#setTabItem(items[index]);
items[index].focus();
}
}
function isMenuItem(element: any): element is MediaChromeMenuItem {
return ['menuitem', 'menuitemradio', 'menuitemcheckbox'].includes(
element?.role
);
}
function getBoundsElement(host: HTMLElement) {
return ((host.getAttribute('bounds')
? closestComposedNode(host, `#${host.getAttribute('bounds')}`)
: getMediaController(host) || host.parentElement) ?? host) as HTMLElement;
}
if (!globalThis.customElements.get('media-chrome-menu')) {
globalThis.customElements.define('media-chrome-menu', MediaChromeMenu);
}
export { MediaChromeMenu };
export default MediaChromeMenu;