feat(packages): add settings menu (#1615)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sam Potts
2026-06-10 13:54:45 +10:00
committed by GitHub
co-authored by Cursor
parent 62d0524b83
commit 00b6f0be1d
117 changed files with 4246 additions and 1208 deletions
@@ -1,19 +1,100 @@
import { CaptionsButtonCore, CaptionsButtonDataAttrs, type MediaTextTrackState } from '@videojs/core';
import { selectTextTrack } from '@videojs/core/dom';
import { applyElementProps, selectTextTrack, type UIEvent } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { isCaptionOrSubtitleTrack } from '@videojs/utils/dom';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { toggleCommandTarget } from '../command-for';
import { MediaButtonElement } from '../media-button-element';
function getCaptionTrackCount(state: MediaTextTrackState): number {
return state.textTrackList.filter(isCaptionOrSubtitleTrack).length;
}
export class CaptionsButtonElement extends MediaButtonElement<CaptionsButtonCore> {
static readonly tagName = 'media-captions-button';
static override properties = {
label: { type: String },
disabled: { type: Boolean },
commandfor: { type: String },
menuFor: { type: String, attribute: 'menu-for' },
} satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor' | 'menuFor'>;
commandfor: string | undefined = undefined;
menuFor: string | undefined = undefined;
#defaultCommandfor: string | undefined = undefined;
protected readonly core = new CaptionsButtonCore();
protected readonly stateAttrMap = CaptionsButtonDataAttrs;
protected readonly mediaState = new PlayerController(this, playerContext, selectTextTrack);
protected override readonly hotkeyAction = 'toggleSubtitles';
protected activate(state: MediaTextTrackState): void {
override connectedCallback(): void {
super.connectedCallback();
if (this.commandfor && this.commandfor !== this.menuFor) {
this.#defaultCommandfor = this.commandfor;
}
}
protected activate(state: MediaTextTrackState, event?: UIEvent): void {
if (this.menuFor && getCaptionTrackCount(state) > 1) {
if (event instanceof KeyboardEvent) {
toggleCommandTarget(this, this.menuFor);
}
return;
}
this.core.toggle(state);
}
protected override getIsButtonDisabled(): boolean {
const media = this.mediaState.value;
if (super.getIsButtonDisabled()) return true;
if (media && getCaptionTrackCount(media) === 0) return true;
return false;
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
if (changed.has('commandfor') && this.commandfor !== this.menuFor) {
this.#defaultCommandfor = this.commandfor;
}
if (changed.has('commandfor') || changed.has('menuFor')) {
this.#syncCommandFor();
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.mediaState.value;
if (!media) return;
this.#syncCommandFor(media);
if (this.menuFor && getCaptionTrackCount(media) > 1) {
applyElementProps(this, {
'aria-disabled': this.getIsButtonDisabled() ? 'true' : undefined,
});
}
}
#syncCommandFor(media?: MediaTextTrackState): void {
const state = media ?? this.mediaState.value;
const target = state && this.menuFor && getCaptionTrackCount(state) > 1 ? this.menuFor : this.#defaultCommandfor;
if (target) {
this.setAttribute('commandfor', target);
} else {
this.removeAttribute('commandfor');
}
}
}
export namespace CaptionsButtonElement {
export type State = CaptionsButtonCore.State;
}
@@ -0,0 +1,111 @@
import type { MediaTextTrackState } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { CaptionsButtonElement } from '../captions-button-element';
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
let error: unknown;
for (let index = 0; index < 10; index++) {
try {
assertion();
return;
} catch (caught) {
error = caught;
await nextFrame();
}
}
throw error;
}
function createTextTrackStore(textTrackList: MediaTextTrackState['textTrackList']): AnyPlayerStore {
return createStore<unknown>()<MediaTextTrackState>({
name: 'textTrack',
state: () => ({
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
textTrackList,
subtitlesShowing: false,
toggleSubtitles: vi.fn(),
selectSubtitlesTrack: vi.fn(),
}),
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
readonly #provider = new ContextProvider(this, { context: playerContext });
setStore(store: AnyPlayerStore): void {
this.#provider.setValue(store);
}
}
defineElement(CaptionsButtonElement.tagName, CaptionsButtonElement);
defineElement('test-captions-button-player', TestPlayerProviderElement);
describe('CaptionsButtonElement', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('restores the configured command target when captions no longer need a menu', async () => {
const provider = document.createElement('test-captions-button-player') as TestPlayerProviderElement;
const button = document.createElement(CaptionsButtonElement.tagName) as CaptionsButtonElement;
const textTrackList: MediaTextTrackState['textTrackList'] = [
{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' },
{ kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' },
];
button.commandfor = 'captions-toggle';
button.menuFor = 'captions-menu';
provider.setStore(createTextTrackStore(textTrackList));
provider.append(button);
document.body.append(provider);
await button.updateComplete;
await waitForAssertion(() => {
expect(button.getAttribute('commandfor')).toBe('captions-menu');
});
button.commandfor = 'captions-fallback';
await button.updateComplete;
await waitForAssertion(() => {
expect(button.getAttribute('commandfor')).toBe('captions-menu');
});
textTrackList.pop();
button.requestUpdate();
await waitForAssertion(() => {
expect(button.getAttribute('commandfor')).toBe('captions-fallback');
});
});
it('maps the menu-for attribute to menuFor', async () => {
const button = document.createElement(CaptionsButtonElement.tagName) as CaptionsButtonElement;
button.setAttribute('menu-for', 'captions-menu');
document.body.append(button);
await button.updateComplete;
expect(button.menuFor).toBe('captions-menu');
});
});
@@ -0,0 +1,148 @@
import { CAPTIONS_OFF_VALUE, CaptionsRadioGroupCore, CaptionsRadioGroupDataAttrs } from '@videojs/core';
import { applyStateDataAttrs, logMissingFeature, selectTextTrack } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element';
import { MenuRadioGroupElement } from '../menu/menu-radio-group-element';
import { MenuRadioItemElement } from '../menu/menu-radio-item-element';
export class CaptionsRadioGroupElement extends MenuRadioGroupElement {
static override readonly tagName = 'media-captions-radio-group';
static override properties = {
...MenuRadioGroupElement.properties,
disabled: { type: Boolean },
label: { type: String },
} satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>;
disabled = false;
label = '';
readonly #core = new CaptionsRadioGroupCore();
readonly #mediaState = new PlayerController(this, playerContext, selectTextTrack);
#tracksKey = '';
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
this.#disconnect = new AbortController();
this.addEventListener('value-change', this.#handleValueChange, { signal: this.#disconnect.signal });
if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) {
logMissingFeature(this.localName, this.#mediaState.displayName);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
protected override update(changed: PropertyValues): void {
const media = this.#mediaState.value;
let state: CaptionsRadioGroupCore.State | null = null;
if (media) {
this.#core.setProps({ disabled: this.disabled });
this.#core.setMedia(media);
state = this.#core.getState();
this.value = state.value;
this.label = this.label || 'Captions';
this.#syncContent(state);
}
super.update(changed);
if (state) applyStateDataAttrs(this, state, CaptionsRadioGroupDataAttrs);
}
#syncContent(state: CaptionsRadioGroupCore.State): void {
const template = this.#getTemplate();
const templateKey = template?.innerHTML ?? '';
const tracksKey = `${state.tracks.map((track) => track.value).join('|')}::${templateKey}`;
if (tracksKey !== this.#tracksKey) {
this.#tracksKey = tracksKey;
for (const child of [...this.children]) {
if (child instanceof HTMLTemplateElement) continue;
child.remove();
}
this.append(this.#createItem(CAPTIONS_OFF_VALUE, 'Off', template));
this.append(...state.tracks.map((track) => this.#createItem(track.value, track.label, template)));
}
for (const item of this.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)) {
const checked = item.value === this.value;
item.disabled = state.disabled;
for (const indicator of item.querySelectorAll<MenuItemIndicatorElement>(MenuItemIndicatorElement.tagName)) {
indicator.checked = checked;
}
}
}
#createItem(value: string, label: string, template: HTMLTemplateElement | null): MenuRadioItemElement {
const item = this.#createItemFromTemplate(template);
item.value = value;
item.setAttribute('data-track', value);
this.#setLabel(item, label);
return item;
}
#createItemFromTemplate(template: HTMLTemplateElement | null): MenuRadioItemElement {
if (!template) return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement;
const fragment = template.content.cloneNode(true) as DocumentFragment;
const root = fragment.firstElementChild;
if (!root || root.localName !== MenuRadioItemElement.tagName || root.nextElementSibling) {
return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement;
}
return root as MenuRadioItemElement;
}
#setLabel(item: MenuRadioItemElement, label: string): void {
const labelPart = item.querySelector<HTMLElement>('[data-part~="label"]');
if (labelPart) {
labelPart.textContent = label;
} else {
item.textContent = label;
}
}
#getTemplate(): HTMLTemplateElement | null {
for (const child of this.children) {
if (child instanceof HTMLTemplateElement) return child;
}
return null;
}
#handleValueChange = (event: Event): void => {
if (event.target !== this) return;
const media = this.#mediaState.value;
if (!media) return;
const { value } = (event as CustomEvent<{ value: string }>).detail;
this.#core.selectValue(media, value);
};
}
export namespace CaptionsRadioGroupElement {
export type State = CaptionsRadioGroupCore.State;
}
+12
View File
@@ -0,0 +1,12 @@
/** Toggle a popup host linked via `commandfor` (menu, popover, etc.). */
export function toggleCommandTarget(host: HTMLElement, commandfor: string): void {
const root = host.getRootNode() as Document | ShadowRoot;
const target =
('getElementById' in root ? root.getElementById(commandfor) : null) ??
root.querySelector<HTMLElement>(`#${CSS.escape(commandfor)}`);
if (!target || !('open' in target)) return;
const popup = target as { open: boolean };
popup.open = !popup.open;
}
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MenuElement } from '../../menu/menu-element';
import { PlaybackRateMenuElement } from '../../playback-rate-menu/playback-rate-menu-element';
import { PopoverElement } from '../../popover/popover-element';
import { TooltipElement } from '../../tooltip/tooltip-element';
import { ControlsElement } from '../controls-element';
@@ -98,12 +97,12 @@ afterEach(() => {
});
describe('ControlsElement', () => {
it('closes owned popovers, menus, playback-rate menus, and tooltips when controls hide', async () => {
it('closes owned popovers, menus, and tooltips when controls hide', async () => {
const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement;
const controls = createDefinedElement(ControlsElement);
const popover = createDefinedElement(PopoverElement);
const menu = createDefinedElement(MenuElement);
const playbackRateMenu = createDefinedElement(PlaybackRateMenuElement);
const playbackRateMenu = createDefinedElement(MenuElement);
const tooltip = createDefinedElement(TooltipElement);
const popoverClose = vi.spyOn(popover, 'close');
const menuClose = vi.spyOn(menu, 'close');
+18 -4
View File
@@ -5,7 +5,13 @@ import type {
MediaButtonComponent,
StateAttrMap,
} from '@videojs/core';
import { applyElementProps, applyStateDataAttrs, createButton, logMissingFeature } from '@videojs/core/dom';
import {
applyElementProps,
applyStateDataAttrs,
createButton,
logMissingFeature,
type UIEvent,
} from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import type { State } from '@videojs/store';
@@ -27,7 +33,15 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
protected abstract readonly stateAttrMap: StateAttrMap<InferComponentState<Core>>;
protected abstract readonly mediaState: PlayerController<any, InferMediaState<Core> | undefined>;
protected abstract activate(state: InferMediaState<Core>): void;
protected abstract activate(state: InferMediaState<Core>, event?: UIEvent): void;
protected getIsButtonDisabled(): boolean {
return this.disabled || !this.mediaState.value;
}
protected handleActivate(event: UIEvent): void {
this.activate(this.mediaState.value!, event);
}
/** Override to set the hotkey action name for `aria-keyshortcuts`. */
protected readonly hotkeyAction: string | undefined = undefined;
@@ -50,8 +64,8 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
this.#disconnect = new AbortController();
const buttonProps = createButton({
onActivate: () => this.activate(this.mediaState.value!),
isDisabled: () => this.disabled || !this.mediaState.value,
onActivate: (event) => this.handleActivate(event),
isDisabled: () => this.getIsButtonDisabled(),
});
applyElementProps(this, buttonProps, { signal: this.#disconnect.signal });
+12
View File
@@ -2,6 +2,8 @@ import type { MenuState, StateAttrMap } from '@videojs/core';
import type { MenuApi, NavigationState } from '@videojs/core/dom';
import { createContext } from '@videojs/element/context';
import type { MenuItemSettingType } from './menu-item-type';
export interface MenuContextValue {
menu: MenuApi;
state: MenuState;
@@ -20,10 +22,20 @@ export interface MenuGroupContextValue {
registerLabel: (id: string) => () => void;
}
export interface MenuItemSettingContextValue {
type: MenuItemSettingType;
label: string;
availability: 'available' | 'unavailable';
}
const MENU_CONTEXT_KEY = Symbol('@videojs/menu');
const MENU_RADIO_GROUP_CONTEXT_KEY = Symbol('@videojs/menu-radio-group');
const MENU_GROUP_CONTEXT_KEY = Symbol('@videojs/menu-group');
const MENU_ITEM_SETTING_CONTEXT_KEY = Symbol('@videojs/menu-item-setting');
export const menuContext = createContext<MenuContextValue>(MENU_CONTEXT_KEY);
export const menuRadioGroupContext = createContext<MenuRadioGroupContextValue>(MENU_RADIO_GROUP_CONTEXT_KEY);
export const menuGroupContext = createContext<MenuGroupContextValue>(MENU_GROUP_CONTEXT_KEY);
export const menuItemSettingContext = createContext<MenuItemSettingContextValue | undefined>(
MENU_ITEM_SETTING_CONTEXT_KEY
);
@@ -0,0 +1,42 @@
import type { MediaPlaybackRateState, MediaTextTrackState } from '@videojs/core';
import { CAPTIONS_OFF_VALUE, type CaptionsRadioGroupCore, type PlaybackRateRadioGroupCore } from '@videojs/core';
import type { MenuItemSettingType } from './menu-item-type';
export interface MenuItemSettingState {
label: string;
availability: 'available' | 'unavailable';
}
export function getMenuItemSettingState(
type: MenuItemSettingType,
cores: {
playbackRate: PlaybackRateRadioGroupCore;
captions: CaptionsRadioGroupCore;
},
media: MediaPlaybackRateState | MediaTextTrackState
): MenuItemSettingState {
if (type === 'playback-rate') {
cores.playbackRate.setMedia(media as MediaPlaybackRateState);
const state = cores.playbackRate.getState();
return {
label: cores.playbackRate.getRateLabel(state.rate),
availability: state.availability,
};
}
cores.captions.setMedia(media as MediaTextTrackState);
const state = cores.captions.getState();
if (state.value === CAPTIONS_OFF_VALUE) {
return { label: 'Off', availability: state.availability };
}
const track = state.tracks.find((candidate) => candidate.value === state.value);
return {
label: track?.label ?? 'Off',
availability: state.availability,
};
}
+71 -10
View File
@@ -1,9 +1,18 @@
import { applyElementProps, completeMenuItemSelection } from '@videojs/core/dom';
import { CaptionsRadioGroupCore, PlaybackRateRadioGroupCore } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { applyElementProps, completeMenuItemSelection, selectPlaybackRate, selectTextTrack } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { ContextConsumer, ContextProvider } from '@videojs/element/context';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
import { menuContext } from './context';
import { menuContext, menuItemSettingContext } from './context';
import { getMenuItemSettingState } from './get-menu-item-setting-state';
import type { MenuItemSettingType } from './menu-item-type';
type PlaybackRateState = ReturnType<typeof selectPlaybackRate>;
type TextTrackState = ReturnType<typeof selectTextTrack>;
export class MenuItemElement extends MediaElement {
static readonly tagName = 'media-menu-item';
@@ -11,16 +20,25 @@ export class MenuItemElement extends MediaElement {
static override properties = {
disabled: { type: Boolean },
commandfor: { type: String },
} satisfies PropertyDeclarationMap<'disabled' | 'commandfor'>;
type: { type: String },
} satisfies PropertyDeclarationMap<'disabled' | 'commandfor' | 'type'>;
disabled = false;
/** ID of a nested `<media-menu>` to open when this item is activated. */
commandfor: string | undefined = undefined;
/** Setting kind for submenu triggers (`playback-rate` or `captions`). */
type: MenuItemSettingType | null = null;
readonly #playbackRateCore = new PlaybackRateRadioGroupCore();
readonly #captionsCore = new CaptionsRadioGroupCore();
#playbackRateValue: PlayerController<AnyPlayerStore, PlaybackRateState> | null = null;
#captionsValue: PlayerController<AnyPlayerStore, TextTrackState> | null = null;
readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true });
readonly #settingProvider = new ContextProvider(this, { context: menuItemSettingContext });
#disconnect: AbortController | null = null;
#registered = false;
#settingUnavailable = false;
#cleanupRegistration: (() => void) | null = null;
override connectedCallback(): void {
@@ -41,6 +59,8 @@ export class MenuItemElement extends MediaElement {
protected override update(_changed: PropertyValues): void {
super.update(_changed);
this.#syncMenuItemSetting();
const ctx = this.#ctx.value;
if (!ctx || !this.#disconnect) return;
@@ -54,12 +74,10 @@ export class MenuItemElement extends MediaElement {
{
onClick: (event: MouseEvent) => {
const currentCtx = this.#ctx.value;
if (!currentCtx || this.disabled) return;
if (!currentCtx || this.#isDisabled()) return;
const target = this.commandfor;
if (target) {
// Push the linked submenu — use this element's id as triggerId
// (ensure the element has an id for focus restoration).
currentCtx.menu.push(target, this.id);
} else {
this.dispatchEvent(new CustomEvent('select', { bubbles: true }));
@@ -69,7 +87,7 @@ export class MenuItemElement extends MediaElement {
},
onKeyDown: (event: KeyboardEvent) => {
const currentCtx = this.#ctx.value;
if (!currentCtx || this.disabled || event.key !== 'ArrowRight') return;
if (!currentCtx || this.#isDisabled() || event.key !== 'ArrowRight') return;
const target = this.commandfor;
if (!target) return;
@@ -79,7 +97,7 @@ export class MenuItemElement extends MediaElement {
},
onPointerenter: () => {
const currentCtx = this.#ctx.value;
if (!this.disabled) currentCtx?.menu.highlight(this, { focus: false });
if (!this.#isDisabled()) currentCtx?.menu.highlight(this, { focus: false });
},
},
{ signal: this.#disconnect.signal }
@@ -93,7 +111,7 @@ export class MenuItemElement extends MediaElement {
applyElementProps(this, {
role: 'menuitem',
'aria-disabled': this.disabled ? 'true' : undefined,
'aria-disabled': this.#isDisabled() ? 'true' : undefined,
...(hasSubmenu && {
'aria-haspopup': 'menu',
'aria-expanded': isExpanded ? 'true' : 'false',
@@ -101,4 +119,47 @@ export class MenuItemElement extends MediaElement {
}),
});
}
#syncMenuItemSetting(): void {
if (!this.type || !this.commandfor) {
this.#setSettingUnavailable(false);
this.#settingProvider.setValue(undefined);
return;
}
const value = this.#getSettingValue(this.type);
if (!value) {
this.#setSettingUnavailable(false);
this.#settingProvider.setValue(undefined);
return;
}
const setting = getMenuItemSettingState(
this.type,
{ playbackRate: this.#playbackRateCore, captions: this.#captionsCore },
value
);
applyElementProps(this, { 'data-availability': setting.availability });
this.#setSettingUnavailable(setting.availability !== 'available');
this.#settingProvider.setValue({ type: this.type, ...setting });
}
#isDisabled(): boolean {
return this.disabled || this.#settingUnavailable;
}
#setSettingUnavailable(unavailable: boolean): void {
this.#settingUnavailable = unavailable;
}
#getSettingValue(type: MenuItemSettingType): PlaybackRateState | TextTrackState | undefined {
if (type === 'playback-rate') {
this.#playbackRateValue ??= new PlayerController(this, playerContext, selectPlaybackRate);
return this.#playbackRateValue.value;
}
this.#captionsValue ??= new PlayerController(this, playerContext, selectTextTrack);
return this.#captionsValue.value;
}
}
@@ -0,0 +1 @@
export type MenuItemSettingType = 'playback-rate' | 'captions';
@@ -0,0 +1,22 @@
import type { PropertyValues } from '@videojs/element';
import { ContextConsumer } from '@videojs/element/context';
import { MediaElement } from '../media-element';
import { menuItemSettingContext } from './context';
export class MenuItemValueElement extends MediaElement {
static readonly tagName = 'media-menu-item-value';
readonly #ctx = new ContextConsumer(this, { context: menuItemSettingContext, subscribe: true });
override connectedCallback(): void {
super.connectedCallback();
this.setAttribute('aria-live', 'off');
}
protected override update(_changed: PropertyValues): void {
super.update(_changed);
this.textContent = this.#ctx.value?.label ?? '';
}
}
@@ -0,0 +1,105 @@
import type { MediaTextTrackState } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MenuElement } from '../menu-element';
import { MenuItemElement } from '../menu-item-element';
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
let error: unknown;
for (let index = 0; index < 10; index++) {
try {
assertion();
return;
} catch (caught) {
error = caught;
await nextFrame();
}
}
throw error;
}
function createTextTrackStore({
textTrackList = [],
subtitlesShowing = false,
selectSubtitlesTrack = vi.fn(),
}: {
textTrackList?: MediaTextTrackState['textTrackList'] | undefined;
subtitlesShowing?: boolean | undefined;
selectSubtitlesTrack?: MediaTextTrackState['selectSubtitlesTrack'] | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaTextTrackState>({
name: 'textTrack',
state: () => ({
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
textTrackList,
subtitlesShowing,
toggleSubtitles: vi.fn(),
selectSubtitlesTrack,
}),
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store: AnyPlayerStore = createTextTrackStore();
readonly #provider = new ContextProvider(this, { context: playerContext });
override connectedCallback(): void {
this.#provider.setValue(this.store);
super.connectedCallback();
}
setStore(store: AnyPlayerStore): void {
this.store = store;
this.#provider.setValue(store);
}
}
defineElement(MenuItemElement.tagName, MenuItemElement);
defineElement(MenuElement.tagName, MenuElement);
defineElement('test-menu-item-player', TestPlayerProviderElement);
describe('MenuItemElement', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('marks submenu triggers unavailable when captions type has no tracks', async () => {
const provider = document.createElement('test-menu-item-player') as TestPlayerProviderElement;
const menu = document.createElement(MenuElement.tagName) as MenuElement;
const menuItem = document.createElement(MenuItemElement.tagName) as MenuItemElement;
provider.setStore(createTextTrackStore({ textTrackList: [] }));
menuItem.type = 'captions';
menuItem.commandfor = 'settings-captions-menu';
menu.append(menuItem);
provider.append(menu);
document.body.append(provider);
await menu.updateComplete;
await menuItem.updateComplete;
await waitForAssertion(() => {
expect(menuItem.getAttribute('data-availability')).toBe('unavailable');
expect(menuItem.getAttribute('aria-disabled')).toBe('true');
});
});
});
@@ -0,0 +1,180 @@
import type { MediaPlaybackRateState, MediaTextTrackState } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MenuItemElement } from '../menu-item-element';
import { MenuItemValueElement } from '../menu-item-value-element';
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
let error: unknown;
for (let index = 0; index < 10; index++) {
try {
assertion();
return;
} catch (caught) {
error = caught;
await nextFrame();
}
}
throw error;
}
function createPlaybackRateStore({
playbackRates = [0.5, 1, 1.5, 2],
playbackRate = 1.5,
}: {
playbackRates?: readonly number[] | undefined;
playbackRate?: number | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaPlaybackRateState>({
name: 'playbackRate',
state: () => ({
playbackRates,
playbackRate,
setPlaybackRate: vi.fn(),
}),
}) as unknown as AnyPlayerStore;
}
function createTextTrackStore({
textTrackList = [],
subtitlesShowing = false,
selectSubtitlesTrack = vi.fn(),
}: {
textTrackList?: MediaTextTrackState['textTrackList'] | undefined;
subtitlesShowing?: boolean | undefined;
selectSubtitlesTrack?: MediaTextTrackState['selectSubtitlesTrack'] | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaTextTrackState>({
name: 'textTrack',
state: () => ({
chaptersCues: [],
thumbnailCues: [],
thumbnailTrackSrc: null,
textTrackList,
subtitlesShowing,
toggleSubtitles: vi.fn(),
selectSubtitlesTrack,
}),
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store: AnyPlayerStore = createPlaybackRateStore();
readonly #provider = new ContextProvider(this, { context: playerContext });
override connectedCallback(): void {
this.#provider.setValue(this.store);
super.connectedCallback();
}
setStore(store: AnyPlayerStore): void {
this.store = store;
this.#provider.setValue(store);
}
}
defineElement(MenuItemElement.tagName, MenuItemElement);
defineElement(MenuItemValueElement.tagName, MenuItemValueElement);
defineElement('test-menu-item-value-player', TestPlayerProviderElement);
function setup(
store: AnyPlayerStore,
type: MenuItemElement['type']
): {
menuItem: MenuItemElement;
value: MenuItemValueElement;
} {
const provider = document.createElement('test-menu-item-value-player') as TestPlayerProviderElement;
const menuItem = document.createElement(MenuItemElement.tagName) as MenuItemElement;
const value = document.createElement(MenuItemValueElement.tagName) as MenuItemValueElement;
provider.setStore(store);
menuItem.type = type;
menuItem.commandfor = 'settings-submenu';
menuItem.append(value);
provider.append(menuItem);
document.body.append(provider);
return { menuItem, value };
}
describe('MenuItemValueElement', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('renders the current playback rate label from menu item context', async () => {
const { value } = setup(createPlaybackRateStore({ playbackRate: 1.5 }), 'playback-rate');
await value.updateComplete;
await waitForAssertion(() => {
expect(value.textContent).toBe('1.5×');
});
});
it('renders Off when captions are disabled', async () => {
const { value } = setup(
createTextTrackStore({
textTrackList: [{ kind: 'subtitles', label: 'English', language: 'en', mode: 'disabled' }],
}),
'captions'
);
await value.updateComplete;
await waitForAssertion(() => {
expect(value.textContent).toBe('Off');
});
});
it('renders the active caption track label', async () => {
const { value } = setup(
createTextTrackStore({
textTrackList: [
{ kind: 'subtitles', label: 'English', language: 'en', mode: 'showing' },
{ kind: 'subtitles', label: 'Spanish', language: 'es', mode: 'disabled' },
],
subtitlesShowing: true,
}),
'captions'
);
await value.updateComplete;
await waitForAssertion(() => {
expect(value.textContent).toBe('English');
});
});
it('clears the label when setting context is removed', async () => {
const { menuItem, value } = setup(createPlaybackRateStore({ playbackRate: 1.5 }), 'playback-rate');
await value.updateComplete;
await waitForAssertion(() => {
expect(value.textContent).toBe('1.5×');
});
menuItem.type = null;
await menuItem.updateComplete;
await waitForAssertion(() => {
expect(value.textContent).toBe('');
});
});
});
@@ -1,18 +1,68 @@
import { type MediaPlaybackRateState, PlaybackRateButtonCore, PlaybackRateButtonDataAttrs } from '@videojs/core';
import { selectPlaybackRate } from '@videojs/core/dom';
import { applyElementProps, selectPlaybackRate, type UIEvent } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { toggleCommandTarget } from '../command-for';
import { MediaButtonElement } from '../media-button-element';
export class PlaybackRateButtonElement extends MediaButtonElement<PlaybackRateButtonCore> {
static readonly tagName = 'media-playback-rate-button';
static override properties = {
label: { type: String },
disabled: { type: Boolean },
commandfor: { type: String },
} satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor'>;
commandfor: string | undefined = undefined;
protected readonly core = new PlaybackRateButtonCore();
protected readonly stateAttrMap = PlaybackRateButtonDataAttrs;
protected readonly mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
protected activate(state: MediaPlaybackRateState): void {
protected activate(state: MediaPlaybackRateState, event?: UIEvent): void {
if (this.commandfor) {
if (event instanceof KeyboardEvent) {
toggleCommandTarget(this, this.commandfor);
}
return;
}
this.core.cycle(state);
}
protected override getIsButtonDisabled(): boolean {
const media = this.mediaState.value;
if (super.getIsButtonDisabled()) return true;
if (this.commandfor && media && media.playbackRates.length === 0) return true;
return false;
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
if (changed.has('commandfor')) {
if (this.commandfor) {
this.setAttribute('commandfor', this.commandfor);
} else {
this.removeAttribute('commandfor');
}
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.mediaState.value;
if (!media || !this.commandfor) return;
applyElementProps(this, {
'aria-disabled': this.getIsButtonDisabled() ? 'true' : undefined,
});
}
}
export namespace PlaybackRateButtonElement {
export type State = PlaybackRateButtonCore.State;
}
@@ -1,62 +0,0 @@
import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core';
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MenuElement } from '../menu/menu-element';
export class PlaybackRateMenuElement extends MenuElement {
static override readonly tagName = 'media-playback-rate-menu';
static override properties = {
...MenuElement.properties,
label: { type: String },
disabled: { type: Boolean },
} satisfies PropertyDeclarationMap<
| 'open'
| 'defaultOpen'
| 'side'
| 'align'
| 'closeOnEscape'
| 'closeOnOutsideClick'
| 'boundary'
| 'label'
| 'disabled'
>;
label = '';
disabled = false;
override align: MenuElement['align'] = 'center';
formatRate = PlaybackRateMenuCore.defaultProps.formatRate;
readonly #core = new PlaybackRateMenuCore();
readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) {
logMissingFeature(this.localName, this.#mediaState.displayName);
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.#mediaState.value;
if (!media) return;
this.#core.setProps(this);
this.#core.setMedia(media);
const state = this.#core.getState();
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs);
}
}
export namespace PlaybackRateMenuElement {
export type State = PlaybackRateMenuCore.State;
}
@@ -1,100 +0,0 @@
import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core';
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class PlaybackRateMenuTriggerElement extends MediaElement {
static readonly tagName = 'media-playback-rate-menu-trigger';
static override properties = {
label: { type: String },
disabled: { type: Boolean },
commandfor: { type: String },
} satisfies PropertyDeclarationMap<'label' | 'disabled' | 'commandfor'>;
label = '';
disabled = false;
commandfor: string | undefined = undefined;
formatRate = PlaybackRateMenuCore.defaultProps.formatRate;
readonly #core = new PlaybackRateMenuCore();
readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
this.#disconnect = new AbortController();
applyElementProps(
this,
{
onClick: this.#handleClick,
onKeyDown: this.#handleKeyDown,
},
{ signal: this.#disconnect.signal }
);
if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) {
logMissingFeature(this.localName, this.#mediaState.displayName);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
/** Returns the trigger's current label derived from media state. */
getLabel(): string | undefined {
return this.#core.state.current.label || undefined;
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.#mediaState.value;
if (!media) return;
this.#core.setProps(this);
this.#core.setMedia(media);
const state = this.#core.getState();
applyElementProps(this, {
role: 'button',
tabIndex: 0,
...this.#core.getAttrs(state),
});
applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs);
}
#handleClick = (event: MouseEvent): void => {
if (this.#mediaState.value && !this.#core.state.current.disabled) return;
event.preventDefault();
event.stopImmediatePropagation();
};
#handleKeyDown = (event: KeyboardEvent): void => {
if (event.target !== event.currentTarget) return;
if (!this.#mediaState.value || this.#core.state.current.disabled) {
if (event.key !== 'Tab') event.preventDefault();
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.click();
}
};
}
export namespace PlaybackRateMenuTriggerElement {
export type State = PlaybackRateMenuCore.State;
}
@@ -1,4 +1,4 @@
import { PlaybackRateMenuCore, PlaybackRateMenuDataAttrs } from '@videojs/core';
import { PlaybackRateRadioGroupCore, PlaybackRateRadioGroupDataAttrs } from '@videojs/core';
import { applyStateDataAttrs, logMissingFeature, selectPlaybackRate } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
@@ -8,8 +8,8 @@ import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element';
import { MenuRadioGroupElement } from '../menu/menu-radio-group-element';
import { MenuRadioItemElement } from '../menu/menu-radio-item-element';
export class PlaybackRateOptionsElement extends MenuRadioGroupElement {
static override readonly tagName = 'media-playback-rate-options';
export class PlaybackRateRadioGroupElement extends MenuRadioGroupElement {
static override readonly tagName = 'media-playback-rate-radio-group';
static override properties = {
...MenuRadioGroupElement.properties,
@@ -17,9 +17,9 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement {
} satisfies PropertyDeclarationMap<'value' | 'disabled'>;
disabled = false;
formatRate = PlaybackRateMenuCore.defaultProps.formatRate;
formatRate = PlaybackRateRadioGroupCore.defaultProps.formatRate;
readonly #core = new PlaybackRateMenuCore();
readonly #core = new PlaybackRateRadioGroupCore();
readonly #mediaState = new PlayerController(this, playerContext, selectPlaybackRate);
#ratesKey = '';
@@ -45,7 +45,7 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement {
protected override update(changed: PropertyValues): void {
const media = this.#mediaState.value;
let state: PlaybackRateMenuCore.State | null = null;
let state: PlaybackRateRadioGroupCore.State | null = null;
if (media) {
this.#core.setProps({ formatRate: this.formatRate, disabled: this.disabled });
@@ -62,10 +62,10 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement {
super.update(changed);
if (state) applyStateDataAttrs(this, state, PlaybackRateMenuDataAttrs);
if (state) applyStateDataAttrs(this, state, PlaybackRateRadioGroupDataAttrs);
}
#syncContent(state: PlaybackRateMenuCore.State): void {
#syncContent(state: PlaybackRateRadioGroupCore.State): void {
const template = this.#getTemplate();
const templateKey = template?.innerHTML ?? '';
const ratesKey = `${state.rates.join('|')}::${templateKey}`;
@@ -145,6 +145,6 @@ export class PlaybackRateOptionsElement extends MenuRadioGroupElement {
};
}
export namespace PlaybackRateOptionsElement {
export type State = PlaybackRateMenuCore.State;
export namespace PlaybackRateRadioGroupElement {
export type State = PlaybackRateRadioGroupCore.State;
}
@@ -6,12 +6,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MenuElement } from '../../menu/menu-element';
import { MenuItemIndicatorElement } from '../../menu/menu-item-indicator-element';
import { MenuRadioGroupElement } from '../../menu/menu-radio-group-element';
import { MenuRadioItemElement } from '../../menu/menu-radio-item-element';
import { PlaybackRateMenuElement } from '../playback-rate-menu-element';
import { PlaybackRateMenuTriggerElement } from '../playback-rate-menu-trigger-element';
import { PlaybackRateOptionsElement } from '../playback-rate-options-element';
import { PlaybackRateButtonElement } from '../../playback-rate-button/playback-rate-button-element';
import { PlaybackRateRadioGroupElement } from '../playback-rate-radio-group-element';
let tagCounter = 0;
@@ -88,10 +88,12 @@ class TestPlayerProviderElement extends MediaElement {
}
}
defineElement(MenuElement.tagName, MenuElement);
defineElement(MenuRadioGroupElement.tagName, MenuRadioGroupElement);
defineElement(MenuRadioItemElement.tagName, MenuRadioItemElement);
defineElement(MenuItemIndicatorElement.tagName, MenuItemIndicatorElement);
defineElement(PlaybackRateOptionsElement.tagName, PlaybackRateOptionsElement);
defineElement(PlaybackRateRadioGroupElement.tagName, PlaybackRateRadioGroupElement);
defineElement(PlaybackRateButtonElement.tagName, PlaybackRateButtonElement);
defineElement('test-playback-rate-player', TestPlayerProviderElement);
function setup({
@@ -107,13 +109,13 @@ function setup({
} = {}) {
const store = createPlaybackRateStore({ playbackRates, playbackRate, setPlaybackRate });
const provider = document.createElement('test-playback-rate-player') as TestPlayerProviderElement;
const trigger = createElement(PlaybackRateMenuTriggerElement);
const menu = createElement(PlaybackRateMenuElement);
const options = createElement(PlaybackRateOptionsElement);
const trigger = createElement(PlaybackRateButtonElement);
const menu = createElement(MenuElement);
const options = createElement(PlaybackRateRadioGroupElement);
provider.setStore(store);
trigger.commandfor = 'playback-rate-menu';
menu.id = 'playback-rate-menu';
trigger.setAttribute('commandfor', 'playback-rate-menu');
if (template) {
const templateElement = document.createElement('template');
@@ -129,15 +131,15 @@ function setup({
}
async function waitForMenu(
menu: PlaybackRateMenuElement,
trigger?: PlaybackRateMenuTriggerElement,
options?: PlaybackRateOptionsElement
menu: MenuElement,
trigger?: PlaybackRateButtonElement,
options?: PlaybackRateRadioGroupElement
): Promise<void> {
await trigger?.updateComplete;
await menu.updateComplete;
await options?.updateComplete;
const group = menu.querySelector<PlaybackRateOptionsElement>(PlaybackRateOptionsElement.tagName);
const group = menu.querySelector<PlaybackRateRadioGroupElement>(PlaybackRateRadioGroupElement.tagName);
await group?.updateComplete;
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
@@ -151,7 +153,7 @@ afterEach(() => {
document.body.innerHTML = '';
});
describe('PlaybackRateMenuElement', () => {
describe('PlaybackRateRadioGroupElement', () => {
it('renders radio items from the available playback rates', async () => {
const { menu, trigger } = setup({ playbackRates: [1, 1.25, 1.5], playbackRate: 1.25 });
@@ -163,8 +165,6 @@ describe('PlaybackRateMenuElement', () => {
await waitForAssertion(() => {
expect(items.map((item) => item.getAttribute('aria-checked'))).toEqual(['false', 'true', 'false']);
});
expect(menu.getAttribute('aria-label')).toBe('Playback rate 1.25');
expect(menu.getAttribute('data-rate')).toBe('1.25');
});
it('renders radio items from a template', async () => {
@@ -183,15 +183,6 @@ describe('PlaybackRateMenuElement', () => {
expect(indicators.map((indicator) => indicator.checked)).toEqual([false, false, true, false]);
});
it('center aligns the root popup by default', async () => {
const { menu, trigger } = setup();
await waitForMenu(menu, trigger);
expect(menu.align).toBe('center');
expect(menu.getAttribute('data-align')).toBe('center');
});
it('sets the selected playback rate', async () => {
const setPlaybackRate = vi.fn();
const { menu, trigger } = setup({ setPlaybackRate });
@@ -208,8 +199,8 @@ describe('PlaybackRateMenuElement', () => {
});
});
describe('PlaybackRateMenuTriggerElement', () => {
it('renders a dynamic trigger from the current playback rate', async () => {
describe('PlaybackRateButtonElement', () => {
it('renders the current playback rate on the trigger button', async () => {
const { trigger } = setup({ playbackRate: 2 });
await trigger.updateComplete;
@@ -219,17 +210,58 @@ describe('PlaybackRateMenuTriggerElement', () => {
expect(trigger.getAttribute('data-rate')).toBe('2');
});
it('prevents activation when there are no playback rates', async () => {
it('does not cycle when commandfor is set', async () => {
const setPlaybackRate = vi.fn();
const { trigger, store } = setup({ playbackRate: 1, setPlaybackRate });
await trigger.updateComplete;
trigger.click();
expect(setPlaybackRate).not.toHaveBeenCalled();
expect((store.state as MediaPlaybackRateState).playbackRate).toBe(1);
});
it('opens the linked menu on Enter when commandfor is set', async () => {
const { menu, trigger } = setup();
await waitForMenu(menu, trigger);
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
await waitForAssertion(() => {
expect(menu.open).toBe(true);
});
});
it('opens the linked menu on Space when commandfor is set', async () => {
const { menu, trigger } = setup();
await waitForMenu(menu, trigger);
trigger.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
trigger.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true, cancelable: true }));
await waitForAssertion(() => {
expect(menu.open).toBe(true);
});
});
it('disables the trigger when there are no playback rates', async () => {
const { trigger } = setup({ playbackRates: [] });
await trigger.updateComplete;
const onClick = vi.fn();
trigger.addEventListener('click', onClick);
expect(trigger.getAttribute('aria-disabled')).toBe('true');
});
it('does not open the linked menu when disabled and clicked', async () => {
const { menu, trigger } = setup({ playbackRates: [] });
await waitForMenu(menu, trigger);
trigger.click();
expect(trigger.getAttribute('aria-disabled')).toBe('true');
expect(trigger.hasAttribute('data-disabled')).toBe(true);
expect(onClick).not.toHaveBeenCalled();
expect(menu.open).toBe(false);
});
});