mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add settings menu (#1615)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+10
-10
@@ -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;
|
||||
}
|
||||
+64
-32
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user