diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 770c76dc..45e48bfb 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.test.ts b/packages/core/src/core/ui/mute-button/mute-button-core.test.ts new file mode 100644 index 00000000..c5399266 --- /dev/null +++ b/packages/core/src/core/ui/mute-button/mute-button-core.test.ts @@ -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 { + 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(); + }); + }); +}); diff --git a/packages/core/src/core/ui/mute-button/mute-button-core.ts b/packages/core/src/core/ui/mute-button/mute-button-core.ts new file mode 100644 index 00000000..9a3df31f --- /dev/null +++ b/packages/core/src/core/ui/mute-button/mute-button-core.ts @@ -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 = { + 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'; +} diff --git a/packages/core/src/core/ui/mute-button/mute-button-data-attrs.ts b/packages/core/src/core/ui/mute-button/mute-button-data-attrs.ts new file mode 100644 index 00000000..d036c117 --- /dev/null +++ b/packages/core/src/core/ui/mute-button/mute-button-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/play-button/play-button-core.test.ts b/packages/core/src/core/ui/play-button/play-button-core.test.ts index 1bb4885a..baec816b 100644 --- a/packages/core/src/core/ui/play-button/play-button-core.test.ts +++ b/packages/core/src/core/ui/play-button/play-button-core.test.ts @@ -17,13 +17,29 @@ function createMockPlayback(overrides: Partial = {}): 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 }); diff --git a/packages/core/src/core/ui/play-button/play-button-core.ts b/packages/core/src/core/ui/play-button/play-button-core.ts index 6bca9cb2..462d91d6 100644 --- a/packages/core/src/core/ui/play-button/play-button-core.ts +++ b/packages/core/src/core/ui/play-button/play-button-core.ts @@ -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 { diff --git a/packages/core/src/core/ui/play-button/play-button-data-attrs.ts b/packages/core/src/core/ui/play-button/play-button-data-attrs.ts index 136dd2d5..7eee41a8 100644 --- a/packages/core/src/core/ui/play-button/play-button-data-attrs.ts +++ b/packages/core/src/core/ui/play-button/play-button-data-attrs.ts @@ -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; diff --git a/packages/core/src/dom/utils/index.ts b/packages/core/src/dom/utils/index.ts index b019a85e..f6a987b2 100644 --- a/packages/core/src/dom/utils/index.ts +++ b/packages/core/src/dom/utils/index.ts @@ -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'; diff --git a/packages/core/src/dom/utils/state-data-attrs.ts b/packages/core/src/dom/utils/state-data-attrs.ts index ad7aa94b..01f87bd4 100644 --- a/packages/core/src/dom/utils/state-data-attrs.ts +++ b/packages/core/src/dom/utils/state-data-attrs.ts @@ -1,3 +1,7 @@ +export type StateAttrMap = { + [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: State): Record { +export function getStateDataAttrs( + state: State, + map?: StateAttrMap +): Record { const attrs: Record = {}; 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: State): Record(element: HTMLElement, state: State): void { +export function applyStateDataAttrs( + element: HTMLElement, + state: State, + map?: StateAttrMap +): 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()}`; +} diff --git a/packages/core/src/dom/utils/tests/state-data-attrs.test.ts b/packages/core/src/dom/utils/tests/state-data-attrs.test.ts index 191ee2e0..e5c509e3 100644 --- a/packages/core/src/dom/utils/tests/state-data-attrs.test.ts +++ b/packages/core/src/dom/utils/tests/state-data-attrs.test.ts @@ -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); + }); }); diff --git a/packages/html/src/define/ui/mute-button.ts b/packages/html/src/define/ui/mute-button.ts new file mode 100644 index 00000000..6089abbf --- /dev/null +++ b/packages/html/src/define/ui/mute-button.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 5d620549..8e5b87fe 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/mute-button/mute-button-element.ts b/packages/html/src/ui/mute-button/mute-button-element.ts new file mode 100644 index 00000000..aa3a6fa5 --- /dev/null +++ b/packages/html/src/ui/mute-button/mute-button-element.ts @@ -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); + } +} diff --git a/packages/html/src/ui/play-button/play-button-element.ts b/packages/html/src/ui/play-button/play-button-element.ts index ba48b8a2..841766a7 100644 --- a/packages/html/src/ui/play-button/play-button-element.ts +++ b/packages/html/src/ui/play-button/play-button-element.ts @@ -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; } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4f954b87..a50d2a46 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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 diff --git a/packages/react/src/ui/mute-button/mute-button.tsx b/packages/react/src/ui/mute-button/mute-button.tsx new file mode 100644 index 00000000..fb154132 --- /dev/null +++ b/packages/react/src/ui/mute-button/mute-button.tsx @@ -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 +) { + 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; +} diff --git a/packages/react/src/utils/tests/use-render.test.tsx b/packages/react/src/utils/tests/use-render.test.tsx index 0013be84..78b4795e 100644 --- a/packages/react/src/utils/tests/use-render.test.tsx +++ b/packages/react/src/utils/tests/use-render.test.tsx @@ -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, + ref: ForwardedRef + ) { + 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(); + 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, diff --git a/packages/react/src/utils/use-render.tsx b/packages/react/src/utils/use-render.tsx index c5a014fb..08a7a157 100644 --- a/packages/react/src/utils/use-render.tsx +++ b/packages/react/src/utils/use-render.tsx @@ -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: State; ref?: Ref | Ref[] | undefined; props?: object | object[] | undefined; + stateAttrMap?: StateAttrMap | undefined; } function resolveClassName( @@ -72,14 +73,14 @@ export function renderElement< params: UseRenderParameters ): 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] : [];