mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add mute button component (#455)
This commit is contained in:
@@ -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,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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user