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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
Reference in New Issue
Block a user