feat(core): add mute button component (#455)

This commit is contained in:
rahim
2026-02-04 23:10:10 +11:00
committed by GitHub
parent d8f15195e3
commit aa189eec84
18 changed files with 541 additions and 24 deletions
+2
View File
@@ -1,3 +1,5 @@
export * from './element';
export * from './media/state';
export * from './ui/mute-button/mute-button-core';
export * from './ui/mute-button/mute-button-data-attrs';
export * from './ui/play-button/play-button-core';
@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from 'vitest';
import type { VolumeState } from '../../media/state';
import { MuteButtonCore } from './mute-button-core';
function createMockVolume(overrides: Partial<VolumeState> = {}): VolumeState {
return {
volume: 1,
muted: false,
volumeAvailability: 'available',
changeVolume: vi.fn(),
toggleMute: vi.fn(),
...overrides,
};
}
describe('MuteButtonCore', () => {
describe('getLabel', () => {
it('returns custom label string when provided', () => {
const core = new MuteButtonCore({ label: 'Custom Label' });
const volume = createMockVolume();
expect(core.getLabel(volume)).toBe('Custom Label');
});
it('returns custom label from function when provided', () => {
const core = new MuteButtonCore({
label: (state) => (state.muted ? 'Sound On' : 'Sound Off'),
});
const volume = createMockVolume({ muted: true });
expect(core.getLabel(volume)).toBe('Sound On');
});
it('falls back to default label when function returns empty string', () => {
const core = new MuteButtonCore({ label: () => '' });
const volume = createMockVolume({ muted: false });
expect(core.getLabel(volume)).toBe('Mute');
});
it('returns "Unmute" when muted', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true });
expect(core.getLabel(volume)).toBe('Unmute');
});
it('returns "Mute" when unmuted', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false });
expect(core.getLabel(volume)).toBe('Mute');
});
});
describe('getAttrs', () => {
it('returns aria-label based on mute state', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true });
const attrs = core.getAttrs(volume);
expect(attrs['aria-label']).toBe('Unmute');
});
it('returns aria-disabled when disabled', () => {
const core = new MuteButtonCore({ disabled: true });
const volume = createMockVolume();
const attrs = core.getAttrs(volume);
expect(attrs['aria-disabled']).toBe('true');
});
it('returns undefined aria-disabled when not disabled', () => {
const core = new MuteButtonCore({ disabled: false });
const volume = createMockVolume();
const attrs = core.getAttrs(volume);
expect(attrs['aria-disabled']).toBeUndefined();
});
it('does NOT return data-* attributes', () => {
const core = new MuteButtonCore();
const volume = createMockVolume();
const attrs = core.getAttrs(volume);
const dataKeys = Object.keys(attrs).filter((key) => key.startsWith('data-'));
expect(dataKeys).toHaveLength(0);
});
});
describe('getState', () => {
it('returns primitive values only (no methods)', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true, volume: 0.2 });
const state = core.getState(volume);
expect(state).toEqual({ muted: true, volumeLevel: 'off' });
const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function');
expect(functionKeys).toHaveLength(0);
});
it('returns off when muted', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: true, volume: 1 });
expect(core.getState(volume).volumeLevel).toBe('off');
});
it('returns off when volume is zero', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0 });
expect(core.getState(volume).volumeLevel).toBe('off');
});
it('returns low when volume is below 0.5', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0.4 });
expect(core.getState(volume).volumeLevel).toBe('low');
});
it('returns medium when volume is below 0.75', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0.6 });
expect(core.getState(volume).volumeLevel).toBe('medium');
});
it('returns high when volume is 0.75 or above', () => {
const core = new MuteButtonCore();
const volume = createMockVolume({ muted: false, volume: 0.9 });
expect(core.getState(volume).volumeLevel).toBe('high');
});
});
describe('toggle', () => {
it('calls toggleMute when enabled', () => {
const core = new MuteButtonCore();
const volume = createMockVolume();
core.toggle(volume);
expect(volume.toggleMute).toHaveBeenCalledTimes(1);
});
it('does nothing when disabled', () => {
const core = new MuteButtonCore({ disabled: true });
const volume = createMockVolume();
core.toggle(volume);
expect(volume.toggleMute).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,90 @@
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { ElementProps } from '../../element';
import type { VolumeState } from '../../media/state';
export type VolumeLevel = 'off' | 'low' | 'medium' | 'high';
export interface MuteButtonProps {
/** Custom label for the button. */
label?: string | ((state: MuteButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
export interface MuteButtonState {
/** Whether audio is muted. */
muted: boolean;
/**
* Derived volume level:
* - `off`: muted or volume is 0
* - `low`: volume < 0.5
* - `medium`: volume < 0.75
* - `high`: volume >= 0.75
*/
volumeLevel: VolumeLevel;
}
export class MuteButtonCore {
static readonly defaultProps: NonNullableObject<MuteButtonProps> = {
label: '',
disabled: false,
};
#props = { ...MuteButtonCore.defaultProps };
constructor(props?: MuteButtonProps) {
if (props) this.setProps(props);
}
setProps(props: MuteButtonProps): void {
this.#props = defaults(props, MuteButtonCore.defaultProps);
}
getLabel(volume: VolumeState): string {
const state = this.getState(volume);
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return state.muted ? 'Unmute' : 'Mute';
}
getAttrs(volume: VolumeState): ElementProps {
return {
'aria-label': this.getLabel(volume),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
getState(volume: VolumeState): MuteButtonState {
return {
muted: volume.muted,
volumeLevel: getVolumeLevel(volume),
};
}
toggle(volume: VolumeState): void {
if (this.#props.disabled) return;
volume.toggleMute();
}
}
export namespace MuteButtonCore {
export type Props = MuteButtonProps;
export type State = MuteButtonState;
}
function getVolumeLevel(volume: VolumeState): VolumeLevel {
if (volume.muted || volume.volume === 0) return 'off';
if (volume.volume < 0.5) return 'low';
if (volume.volume < 0.75) return 'medium';
return 'high';
}
@@ -0,0 +1,6 @@
export const MuteButtonDataAttributes = {
/** Present when the media is muted. */
muted: 'data-muted',
/** Indicates the volume level. */
volumeLevel: 'data-volume-level',
} as const;
@@ -17,13 +17,29 @@ function createMockPlayback(overrides: Partial<PlaybackState> = {}): PlaybackSta
describe('PlayButtonCore', () => {
describe('getLabel', () => {
it('returns custom label when provided', () => {
it('returns custom label string when provided', () => {
const core = new PlayButtonCore({ label: 'Custom Label' });
const playback = createMockPlayback();
expect(core.getLabel(playback)).toBe('Custom Label');
});
it('returns custom label from function when provided', () => {
const core = new PlayButtonCore({
label: (state) => (state.paused ? 'Start' : 'Stop'),
});
const playback = createMockPlayback({ paused: true });
expect(core.getLabel(playback)).toBe('Start');
});
it('falls back to default label when function returns empty string', () => {
const core = new PlayButtonCore({ label: () => '' });
const playback = createMockPlayback({ paused: true });
expect(core.getLabel(playback)).toBe('Play');
});
it('returns "Replay" when ended', () => {
const core = new PlayButtonCore();
const playback = createMockPlayback({ ended: true });
@@ -1,4 +1,5 @@
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { ElementProps } from '../../element';
@@ -6,7 +7,7 @@ import type { PlaybackState } from '../../media/state';
export interface PlayButtonProps {
/** Custom label for the button. */
label?: string | undefined;
label?: string | ((state: PlayButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
@@ -30,9 +31,18 @@ export class PlayButtonCore {
}
getLabel(playback: PlaybackState): string {
if (this.#props.label) return this.#props.label;
if (playback.ended) return 'Replay';
return playback.paused ? 'Play' : 'Pause';
const state = this.getState(playback);
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
if (state.ended) return 'Replay';
return state.paused ? 'Play' : 'Pause';
}
getAttrs(playback: PlaybackState): ElementProps {
@@ -1,8 +1,8 @@
export enum PlayButtonDataAttrs {
export const PlayButtonDataAttrs = {
/** Present when the media is paused. */
paused = 'data-paused',
paused: 'data-paused',
/** Present when the media has ended. */
ended = 'data-ended',
ended: 'data-ended',
/** Present when playback has started. */
started = 'data-started',
}
started: 'data-started',
} as const;
+1
View File
@@ -1,3 +1,4 @@
export { applyElementProps } from './element-props';
export { logMissingFeature } from './log';
export type { StateAttrMap } from './state-data-attrs';
export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs';
+28 -10
View File
@@ -1,3 +1,7 @@
export type StateAttrMap<State> = {
[Key in keyof State]?: string;
};
/**
* Convert state object to data attributes.
*
@@ -11,17 +15,23 @@
* getStateDataAttrs(state);
* // { 'data-paused': '', 'data-volume': '0.5' }
* ```
*
* When a mapping is provided, only mapped keys are converted.
*/
export function getStateDataAttrs<State extends object>(state: State): Record<string, string> {
export function getStateDataAttrs<State extends object>(
state: State,
map?: StateAttrMap<State>
): Record<string, string> {
const attrs: Record<string, string> = {};
for (const key in state) {
const value = state[key];
const name = map?.[key] ?? toDataAttrName(key),
value = state[key];
if (value === true) {
attrs[`data-${key.toLowerCase()}`] = '';
attrs[name] = '';
} else if (value) {
attrs[`data-${key.toLowerCase()}`] = String(value);
attrs[name] = String(value);
}
}
@@ -42,17 +52,25 @@ export function getStateDataAttrs<State extends object>(state: State): Record<st
* // element has data-paused="", data-ended is removed
* ```
*/
export function applyStateDataAttrs<State extends object>(element: HTMLElement, state: State): void {
export function applyStateDataAttrs<State extends object>(
element: HTMLElement,
state: State,
map?: StateAttrMap<State>
): void {
for (const key in state) {
const value = state[key];
const attrName = `data-${key.toLowerCase()}`;
const name = map?.[key] ?? toDataAttrName(key),
value = state[key];
if (value === true) {
element.setAttribute(attrName, '');
element.setAttribute(name, '');
} else if (value) {
element.setAttribute(attrName, String(value));
element.setAttribute(name, String(value));
} else {
element.removeAttribute(attrName);
element.removeAttribute(name);
}
}
}
function toDataAttrName(key: string): string {
return `data-${key.toLowerCase()}`;
}
@@ -64,6 +64,29 @@ describe('getStateDataAttrs', () => {
const state = { label: '' };
expect(getStateDataAttrs(state)).toEqual({});
});
it('supports explicit attribute mapping', () => {
const state = { muted: true, volumeLevel: 'low' };
const mapping = {
muted: 'data-muted',
volumeLevel: 'data-volume-level',
};
expect(getStateDataAttrs(state, mapping)).toEqual({
'data-muted': '',
'data-volume-level': 'low',
});
});
it('allows unmapped keys when mapping is provided', () => {
const state = { muted: true, volumeLevel: 'low' };
const mapping = { muted: 'data-muted' };
expect(getStateDataAttrs(state, mapping)).toEqual({
'data-muted': '',
'data-volumelevel': 'low',
});
});
});
describe('applyStateDataAttrs', () => {
@@ -134,4 +157,17 @@ describe('applyStateDataAttrs', () => {
expect(element.hasAttribute('data-ended')).toBe(true);
expect(element.hasAttribute('data-volume')).toBe(false);
});
it('removes mapped attributes when values become falsy', () => {
const element = document.createElement('div');
const mapping = { muted: 'data-muted', volumeLevel: 'data-volume-level' };
applyStateDataAttrs(element, { muted: true, volumeLevel: 'high' }, mapping);
expect(element.getAttribute('data-muted')).toBe('');
expect(element.getAttribute('data-volume-level')).toBe('high');
applyStateDataAttrs(element, { muted: false, volumeLevel: '' }, mapping);
expect(element.hasAttribute('data-muted')).toBe(false);
expect(element.hasAttribute('data-volume-level')).toBe(false);
});
});
@@ -0,0 +1,9 @@
import { MuteButtonElement } from '../../ui/mute-button/mute-button-element';
customElements.define(MuteButtonElement.tagName, MuteButtonElement);
declare global {
interface HTMLElementTagNameMap {
[MuteButtonElement.tagName]: MuteButtonElement;
}
}
+1
View File
@@ -18,4 +18,5 @@ export * from './store/types';
export * from './ui/media-element';
// UI Components
export { MuteButtonElement } from './ui/mute-button/mute-button-element';
export { PlayButtonElement } from './ui/play-button/play-button-element';
@@ -0,0 +1,71 @@
import type { PropertyValues } from '@lit/reactive-element';
import { MuteButtonCore, MuteButtonDataAttributes } from '@videojs/core';
import {
applyElementProps,
applyStateDataAttrs,
createButton,
logMissingFeature,
selectVolume,
} from '@videojs/core/dom';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MediaElement } from '../media-element';
export class MuteButtonElement extends MediaElement {
static readonly tagName = 'media-mute-button';
static override properties = {
label: { type: String },
disabled: { type: Boolean },
};
label = '';
disabled = false;
readonly #core = new MuteButtonCore();
readonly #state = new PlayerController(this, playerContext, selectVolume);
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();
const buttonProps = createButton({
onActivate: () => this.#core.toggle(this.#state.value!),
isDisabled: () => this.disabled || !this.#state.value,
});
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (!this.#state.value) {
logMissingFeature(MuteButtonElement.tagName, 'volume');
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
protected override willUpdate(changed: PropertyValues): void {
super.willUpdate(changed);
this.#core.setProps(this);
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const state = this.#state.value;
if (!state) {
return;
}
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, this.#core.getState(state), MuteButtonDataAttributes);
}
}
@@ -39,6 +39,10 @@ export class PlayButtonElement extends MediaElement {
});
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (!this.#state.value) {
logMissingFeature(PlayButtonElement.tagName, 'playback');
}
}
override disconnectedCallback(): void {
@@ -58,7 +62,6 @@ export class PlayButtonElement extends MediaElement {
const state = this.#state.value;
if (!state) {
logMissingFeature(PlayButtonElement.tagName, 'playback');
return;
}
+1
View File
@@ -33,6 +33,7 @@ export {
export { useButton } from './ui/hooks/use-button';
// UI Components
export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button';
// Utilities
@@ -0,0 +1,55 @@
'use client';
import { MuteButtonCore, MuteButtonDataAttributes } from '@videojs/core';
import { logMissingFeature, selectVolume } from '@videojs/core/dom';
import type { ForwardedRef } from 'react';
import { forwardRef, useState } from 'react';
import { usePlayer } from '../../player/context';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useButton } from '../hooks/use-button';
export interface MuteButtonProps extends UIComponentProps<'button', MuteButtonCore.State>, MuteButtonCore.Props {}
/**
* A button that toggles mute state.
*/
export const MuteButton = forwardRef(function MuteButton(
componentProps: MuteButtonProps,
forwardedRef: ForwardedRef<HTMLButtonElement>
) {
const { render, className, style, label, disabled = false, ...elementProps } = componentProps;
const volume = usePlayer(selectVolume);
const [core] = useState(() => new MuteButtonCore());
core.setProps({ label, disabled });
const { getButtonProps, buttonRef } = useButton({
displayName: 'MuteButton',
onActivate: () => core.toggle(volume!),
isDisabled: () => disabled || !volume,
});
if (!volume) {
logMissingFeature('MuteButton', 'volume');
return null;
}
return renderElement(
'button',
{ render, className, style },
{
state: core.getState(volume),
ref: [forwardedRef, buttonRef],
props: [core.getAttrs(volume), elementProps, getButtonProps()],
stateAttrMap: MuteButtonDataAttributes,
}
);
});
export namespace MuteButton {
export type Props = MuteButtonProps;
export type State = MuteButtonCore.State;
}
@@ -487,6 +487,39 @@ describe('renderElement', () => {
expect(element?.getAttribute('data-ispaused')).toBe('');
});
it('supports explicit state attribute mapping', () => {
interface VolumeState {
muted: boolean;
volumeLevel: 'low' | 'high';
}
const mapping = {
muted: 'data-muted',
volumeLevel: 'data-volume-level',
} as const;
const VolumeComponent = forwardRef(function VolumeComponent(
props: { muted?: boolean; volumeLevel?: 'low' | 'high' } & renderElement.ComponentProps<VolumeState>,
ref: ForwardedRef<HTMLDivElement>
) {
const { className, style, render: renderProp, muted = false, volumeLevel = 'high', ...elementProps } = props;
const state: VolumeState = { muted, volumeLevel };
return renderElement(
'div',
{ className, style, render: renderProp },
{ state, ref, props: [elementProps], stateAttrMap: mapping }
);
});
const { container } = render(<VolumeComponent muted volumeLevel="low" />);
const element = container.firstElementChild;
expect(element?.getAttribute('data-muted')).toBe('');
expect(element?.getAttribute('data-volume-level')).toBe('low');
expect(element?.hasAttribute('data-volumelevel')).toBe(false);
});
it('state data-* attributes can be overridden by explicit props', () => {
const ComponentWithExplicitDataAttr = forwardRef(function ComponentWithExplicitDataAttr(
props: TestComponentProps,
+4 -3
View File
@@ -1,6 +1,6 @@
'use client';
import { getStateDataAttrs } from '@videojs/core/dom';
import { getStateDataAttrs, type StateAttrMap } from '@videojs/core/dom';
import { isFunction } from '@videojs/utils/predicate';
import type { CSSProperties, ReactElement, Ref } from 'react';
import { cloneElement, createElement, isValidElement } from 'react';
@@ -20,6 +20,7 @@ export interface UseRenderParameters<State, RenderedElementType extends Element>
state: State;
ref?: Ref<RenderedElementType> | Ref<RenderedElementType>[] | undefined;
props?: object | object[] | undefined;
stateAttrMap?: StateAttrMap<State> | undefined;
}
function resolveClassName<State>(
@@ -72,14 +73,14 @@ export function renderElement<
params: UseRenderParameters<State, RenderedElementType>
): ReactElement {
const { className: classNameProp, style: styleProp, render } = componentProps;
const { state, ref, props } = params;
const { state, ref, props, stateAttrMap } = params;
// Resolve className and style if they're functions
const className = resolveClassName(classNameProp, state);
const style = resolveStyle(styleProp, state);
// Generate data attributes from state
const stateDataAttrs = getStateDataAttrs(state);
const stateDataAttrs = getStateDataAttrs(state, stateAttrMap);
// Merge: state data attrs first, then props (so props can override)
const propsArray = Array.isArray(props) ? props : props ? [props] : [];