mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add play button component (#383)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
export interface ElementProps {
|
||||
'aria-label'?: string | undefined;
|
||||
'aria-disabled'?: string | undefined;
|
||||
'data-paused'?: boolean | undefined;
|
||||
'data-ended'?: boolean | undefined;
|
||||
'data-waiting'?: boolean | undefined;
|
||||
'data-unsupported'?: boolean | undefined;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './element';
|
||||
export * from './media/state';
|
||||
export * from './ui/play-button/play-button-core';
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface PlaybackState {
|
||||
/**
|
||||
* Whether playback is paused.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/paused
|
||||
*/
|
||||
paused: boolean;
|
||||
/**
|
||||
* Whether playback has reached the end.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended
|
||||
*/
|
||||
ended: boolean;
|
||||
/**
|
||||
* Whether playback has started (played or seeked).
|
||||
*/
|
||||
started: boolean;
|
||||
/**
|
||||
* Whether playback is stalled waiting for data.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/waiting_event
|
||||
*/
|
||||
waiting: boolean;
|
||||
/**
|
||||
* Start playback.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
|
||||
*/
|
||||
play(): Promise<void>;
|
||||
/**
|
||||
* Pause playback.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/pause
|
||||
*/
|
||||
pause(): void;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PlaybackState } from '../../media/state';
|
||||
import { PlayButtonCore } from './play-button-core';
|
||||
|
||||
function createMockPlayback(overrides: Partial<PlaybackState> = {}): PlaybackState {
|
||||
return {
|
||||
paused: true,
|
||||
ended: false,
|
||||
started: false,
|
||||
waiting: false,
|
||||
play: vi.fn().mockResolvedValue(undefined),
|
||||
pause: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayButtonCore', () => {
|
||||
describe('getLabel', () => {
|
||||
it('returns custom label when provided', () => {
|
||||
const core = new PlayButtonCore({ label: 'Custom Label' });
|
||||
const playback = createMockPlayback();
|
||||
|
||||
expect(core.getLabel(playback)).toBe('Custom Label');
|
||||
});
|
||||
|
||||
it('returns "Replay" when ended', () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ ended: true });
|
||||
|
||||
expect(core.getLabel(playback)).toBe('Replay');
|
||||
});
|
||||
|
||||
it('returns "Play" when paused', () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: true });
|
||||
|
||||
expect(core.getLabel(playback)).toBe('Play');
|
||||
});
|
||||
|
||||
it('returns "Pause" when playing', () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: false });
|
||||
|
||||
expect(core.getLabel(playback)).toBe('Pause');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttrs', () => {
|
||||
it('returns aria-label based on playback state', () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: true });
|
||||
|
||||
const attrs = core.getAttrs(playback);
|
||||
|
||||
expect(attrs['aria-label']).toBe('Play');
|
||||
});
|
||||
|
||||
it('returns aria-disabled when disabled', () => {
|
||||
const core = new PlayButtonCore({ disabled: true });
|
||||
const playback = createMockPlayback();
|
||||
|
||||
const attrs = core.getAttrs(playback);
|
||||
|
||||
expect(attrs['aria-disabled']).toBe('true');
|
||||
});
|
||||
|
||||
it('returns undefined aria-disabled when not disabled', () => {
|
||||
const core = new PlayButtonCore({ disabled: false });
|
||||
const playback = createMockPlayback();
|
||||
|
||||
const attrs = core.getAttrs(playback);
|
||||
|
||||
expect(attrs['aria-disabled']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT return data-* attributes', () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: true, ended: false, waiting: true });
|
||||
|
||||
const attrs = core.getAttrs(playback);
|
||||
|
||||
// Verify no data-* keys exist
|
||||
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 PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: true, ended: false, started: true });
|
||||
|
||||
const state = core.getState(playback);
|
||||
|
||||
expect(state).toEqual({
|
||||
paused: true,
|
||||
ended: false,
|
||||
started: true,
|
||||
});
|
||||
|
||||
// Verify no functions in state
|
||||
const functionKeys = Object.entries(state).filter(([, value]) => typeof value === 'function');
|
||||
expect(functionKeys).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('includes paused from playback', () => {
|
||||
const core = new PlayButtonCore();
|
||||
|
||||
expect(core.getState(createMockPlayback({ paused: true })).paused).toBe(true);
|
||||
expect(core.getState(createMockPlayback({ paused: false })).paused).toBe(false);
|
||||
});
|
||||
|
||||
it('includes ended from playback', () => {
|
||||
const core = new PlayButtonCore();
|
||||
|
||||
expect(core.getState(createMockPlayback({ ended: true })).ended).toBe(true);
|
||||
expect(core.getState(createMockPlayback({ ended: false })).ended).toBe(false);
|
||||
});
|
||||
|
||||
it('includes started from playback', () => {
|
||||
const core = new PlayButtonCore();
|
||||
|
||||
expect(core.getState(createMockPlayback({ started: true })).started).toBe(true);
|
||||
expect(core.getState(createMockPlayback({ started: false })).started).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle', () => {
|
||||
it('calls play() when paused', async () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: true });
|
||||
|
||||
await core.toggle(playback);
|
||||
|
||||
expect(playback.play).toHaveBeenCalledTimes(1);
|
||||
expect(playback.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls play() when ended', async () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: false, ended: true });
|
||||
|
||||
await core.toggle(playback);
|
||||
|
||||
expect(playback.play).toHaveBeenCalledTimes(1);
|
||||
expect(playback.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls pause() when playing', async () => {
|
||||
const core = new PlayButtonCore();
|
||||
const playback = createMockPlayback({ paused: false, ended: false });
|
||||
|
||||
await core.toggle(playback);
|
||||
|
||||
expect(playback.pause).toHaveBeenCalledTimes(1);
|
||||
expect(playback.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when disabled', async () => {
|
||||
const core = new PlayButtonCore({ disabled: true });
|
||||
const playback = createMockPlayback({ paused: true });
|
||||
|
||||
await core.toggle(playback);
|
||||
|
||||
expect(playback.play).not.toHaveBeenCalled();
|
||||
expect(playback.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { ElementProps } from '../../element';
|
||||
import type { PlaybackState } from '../../media/state';
|
||||
|
||||
export interface PlayButtonProps {
|
||||
/** Custom label for the button. */
|
||||
label?: string | undefined;
|
||||
/** Whether the button is disabled. */
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PlayButtonState extends Pick<PlaybackState, 'paused' | 'ended' | 'started'> {}
|
||||
|
||||
export class PlayButtonCore {
|
||||
static readonly defaultProps: NonNullableObject<PlayButtonProps> = {
|
||||
label: '',
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
#props = { ...PlayButtonCore.defaultProps };
|
||||
|
||||
constructor(props?: PlayButtonProps) {
|
||||
if (props) this.setProps(props);
|
||||
}
|
||||
|
||||
setProps(props: PlayButtonProps): void {
|
||||
this.#props = defaults(props, PlayButtonCore.defaultProps);
|
||||
}
|
||||
|
||||
getLabel(playback: PlaybackState): string {
|
||||
if (this.#props.label) return this.#props.label;
|
||||
if (playback.ended) return 'Replay';
|
||||
return playback.paused ? 'Play' : 'Pause';
|
||||
}
|
||||
|
||||
getAttrs(playback: PlaybackState): ElementProps {
|
||||
return {
|
||||
'aria-label': this.getLabel(playback),
|
||||
'aria-disabled': this.#props.disabled ? 'true' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getState(playback: PlaybackState): PlayButtonState {
|
||||
return {
|
||||
paused: playback.paused,
|
||||
ended: playback.ended,
|
||||
started: playback.started,
|
||||
};
|
||||
}
|
||||
|
||||
async toggle(playback: PlaybackState): Promise<void> {
|
||||
if (this.#props.disabled) return;
|
||||
|
||||
if (playback.paused || playback.ended) {
|
||||
return playback.play();
|
||||
}
|
||||
|
||||
playback.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export namespace PlayButtonCore {
|
||||
export type Props = PlayButtonProps;
|
||||
export type State = PlayButtonState;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum PlayButtonDataAttrs {
|
||||
/** Present when the media is paused. */
|
||||
paused = 'data-paused',
|
||||
/** Present when the media has ended. */
|
||||
ended = 'data-ended',
|
||||
/** Present when playback has started. */
|
||||
started = 'data-started',
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineSlice } from '@videojs/store';
|
||||
|
||||
import type { PlayerTarget } from './types';
|
||||
import type { PlayerTarget } from './media/types';
|
||||
|
||||
export const definePlayerFeature = defineSlice<PlayerTarget>();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './feature';
|
||||
export * from './media/types';
|
||||
export * from './store/features';
|
||||
export * from './store/selectors';
|
||||
export * from './types';
|
||||
export * from './ui/button';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import type { InferSliceState } from '@videojs/store';
|
||||
import { listen } from '@videojs/utils/dom';
|
||||
|
||||
import type { PlaybackState } from '../../../core/media/state';
|
||||
import { definePlayerFeature } from '../../feature';
|
||||
|
||||
export const playbackFeature = definePlayerFeature({
|
||||
state: ({ target }) => ({
|
||||
/** Whether playback is paused. */
|
||||
state: ({ target }): PlaybackState => ({
|
||||
paused: true,
|
||||
/** Whether playback has reached the end. */
|
||||
ended: false,
|
||||
/** Whether playback has started (played or seeked). */
|
||||
started: false,
|
||||
/** Whether playback is stalled waiting for data. */
|
||||
waiting: false,
|
||||
/** Start playback. */
|
||||
play() {
|
||||
return target().media.play();
|
||||
},
|
||||
/** Pause playback immediately. */
|
||||
pause() {
|
||||
target().media.pause();
|
||||
},
|
||||
@@ -43,5 +37,3 @@ export const playbackFeature = definePlayerFeature({
|
||||
listen(media, 'waiting', sync, { signal });
|
||||
},
|
||||
});
|
||||
|
||||
export type PlaybackState = InferSliceState<typeof playbackFeature>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore } from '@videojs/store';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PlayerTarget } from '../../../types';
|
||||
import type { PlayerTarget } from '../../../media/types';
|
||||
import { bufferFeature } from '../buffer';
|
||||
|
||||
describe('bufferFeature', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore } from '@videojs/store';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PlayerTarget } from '../../../types';
|
||||
import type { PlayerTarget } from '../../../media/types';
|
||||
import { playbackFeature } from '../playback';
|
||||
|
||||
describe('playbackFeature', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { combine, createStore } from '@videojs/store';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { PlayerTarget } from '../../../types';
|
||||
import type { PlayerTarget } from '../../../media/types';
|
||||
import { sourceFeature } from '../source';
|
||||
import { timeFeature } from '../time';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore } from '@videojs/store';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PlayerTarget } from '../../../types';
|
||||
import type { PlayerTarget } from '../../../media/types';
|
||||
import { timeFeature } from '../time';
|
||||
|
||||
describe('timeFeature', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore } from '@videojs/store';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PlayerTarget } from '../../../types';
|
||||
import type { PlayerTarget } from '../../../media/types';
|
||||
import { volumeFeature } from '../volume';
|
||||
|
||||
describe('volumeFeature', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { InferSliceState } from '@videojs/store';
|
||||
import { listen } from '@videojs/utils/dom';
|
||||
|
||||
import { definePlayerFeature } from '../../feature';
|
||||
import type { FeatureAvailability } from '../../types';
|
||||
import type { FeatureAvailability } from '../../media/types';
|
||||
|
||||
export const volumeFeature = definePlayerFeature({
|
||||
state: ({ target }) => ({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { UIKeyboardEvent } from './event';
|
||||
|
||||
export interface ButtonOptions {
|
||||
onActivate: () => void;
|
||||
isDisabled: () => boolean;
|
||||
}
|
||||
|
||||
export interface ButtonProps {
|
||||
role: 'button';
|
||||
tabIndex: 0;
|
||||
onClick: (event: UIEvent) => void;
|
||||
onPointerDown: (event: UIEvent) => void;
|
||||
onMouseDown: (event: UIEvent) => void;
|
||||
onKeyDown: (event: UIKeyboardEvent) => void;
|
||||
onKeyUp: (event: UIKeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export function createButton(options: ButtonOptions): ButtonProps {
|
||||
const { onActivate, isDisabled } = options;
|
||||
|
||||
return {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
|
||||
onClick(event) {
|
||||
if (isDisabled()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
onActivate();
|
||||
},
|
||||
|
||||
onPointerDown(event) {
|
||||
if (isDisabled()) event.preventDefault();
|
||||
},
|
||||
|
||||
onMouseDown(event) {
|
||||
if (isDisabled()) event.preventDefault();
|
||||
},
|
||||
|
||||
onKeyDown(event) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
|
||||
if (isDisabled()) {
|
||||
if (event.key !== 'Tab') event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onActivate();
|
||||
} else if (event.key === ' ') {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
onKeyUp(event) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (isDisabled()) return;
|
||||
|
||||
if (event.key === ' ') {
|
||||
onActivate();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface UIEvent {
|
||||
preventDefault(): void;
|
||||
}
|
||||
|
||||
export interface UIKeyboardEvent extends UIEvent {
|
||||
key: string;
|
||||
target: Node;
|
||||
currentTarget: Node;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { listen } from '@videojs/utils/dom';
|
||||
import { isFunction, isUndefined } from '@videojs/utils/predicate';
|
||||
|
||||
import type { ElementProps } from '../../core/element';
|
||||
|
||||
/**
|
||||
* Apply props to a DOM element.
|
||||
*
|
||||
* Handles both attributes and event listeners:
|
||||
* - Event props (onClick, onKeyDown, etc.) are attached as listeners
|
||||
* - Boolean props: `true` sets empty attribute, `false` removes
|
||||
* - `undefined` removes the attribute
|
||||
* - Other props are set as string attributes
|
||||
*/
|
||||
export function applyElementProps(element: HTMLElement, props: ElementProps | object, signal?: AbortSignal): void {
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (isFunction(value) && key.startsWith('on')) {
|
||||
const event = key.slice(2).toLowerCase();
|
||||
listen(element, event, value as EventListener, signal ? { signal } : undefined);
|
||||
} else if (isUndefined(value) || value === false) {
|
||||
element.removeAttribute(key);
|
||||
} else if (value === true) {
|
||||
element.setAttribute(key, '');
|
||||
} else {
|
||||
element.setAttribute(key, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { applyElementProps } from './element-props';
|
||||
export { logMissingFeature } from './log';
|
||||
export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs';
|
||||
@@ -0,0 +1,9 @@
|
||||
const warned = new Set<string>();
|
||||
|
||||
export function logMissingFeature(displayName: string, featureName: string): void {
|
||||
const key = `${displayName}:${featureName}`;
|
||||
if (warned.has(key)) return;
|
||||
|
||||
warned.add(key);
|
||||
console.warn(`${displayName} requires ${featureName} feature`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Convert state object to data attributes.
|
||||
*
|
||||
* - `true` → `data-keyname=""`
|
||||
* - truthy string/number → `data-keyname="value"`
|
||||
* - falsy → no attribute
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const state = { paused: true, ended: false, volume: 0.5 };
|
||||
* getStateDataAttrs(state);
|
||||
* // { 'data-paused': '', 'data-volume': '0.5' }
|
||||
* ```
|
||||
*/
|
||||
export function getStateDataAttrs<State extends object>(state: State): Record<string, string> {
|
||||
const attrs: Record<string, string> = {};
|
||||
|
||||
for (const key in state) {
|
||||
const value = state[key];
|
||||
|
||||
if (value === true) {
|
||||
attrs[`data-${key.toLowerCase()}`] = '';
|
||||
} else if (value) {
|
||||
attrs[`data-${key.toLowerCase()}`] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply state as data attributes to an element.
|
||||
*
|
||||
* - `true` → sets `data-keyname=""`
|
||||
* - truthy string/number → sets `data-keyname="value"`
|
||||
* - falsy → removes the attribute
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const state = { paused: true, ended: false };
|
||||
* applyStateDataAttrs(element, state);
|
||||
* // element has data-paused="", data-ended is removed
|
||||
* ```
|
||||
*/
|
||||
export function applyStateDataAttrs<State extends object>(element: HTMLElement, state: State): void {
|
||||
for (const key in state) {
|
||||
const value = state[key];
|
||||
const attrName = `data-${key.toLowerCase()}`;
|
||||
|
||||
if (value === true) {
|
||||
element.setAttribute(attrName, '');
|
||||
} else if (value) {
|
||||
element.setAttribute(attrName, String(value));
|
||||
} else {
|
||||
element.removeAttribute(attrName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { applyStateDataAttrs, getStateDataAttrs } from '../state-data-attrs';
|
||||
|
||||
describe('getStateDataAttrs', () => {
|
||||
it('converts true to empty string attribute', () => {
|
||||
const state = { paused: true };
|
||||
expect(getStateDataAttrs(state)).toEqual({ 'data-paused': '' });
|
||||
});
|
||||
|
||||
it('ignores false values', () => {
|
||||
const state = { paused: false };
|
||||
expect(getStateDataAttrs(state)).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores undefined values', () => {
|
||||
const state = { paused: undefined };
|
||||
expect(getStateDataAttrs(state)).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores null values', () => {
|
||||
const state = { paused: null };
|
||||
expect(getStateDataAttrs(state)).toEqual({});
|
||||
});
|
||||
|
||||
it('converts string values to attribute', () => {
|
||||
const state = { status: 'loading' };
|
||||
expect(getStateDataAttrs(state)).toEqual({ 'data-status': 'loading' });
|
||||
});
|
||||
|
||||
it('converts number values to string attribute', () => {
|
||||
const state = { volume: 0.5 };
|
||||
expect(getStateDataAttrs(state)).toEqual({ 'data-volume': '0.5' });
|
||||
});
|
||||
|
||||
it('ignores zero values', () => {
|
||||
const state = { volume: 0 };
|
||||
expect(getStateDataAttrs(state)).toEqual({});
|
||||
});
|
||||
|
||||
it('converts keys to lowercase', () => {
|
||||
const state = { isPaused: true, currentTime: 10 };
|
||||
expect(getStateDataAttrs(state)).toEqual({
|
||||
'data-ispaused': '',
|
||||
'data-currenttime': '10',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles multiple state properties', () => {
|
||||
const state = { paused: true, ended: false, waiting: true, volume: 0.8 };
|
||||
expect(getStateDataAttrs(state)).toEqual({
|
||||
'data-paused': '',
|
||||
'data-waiting': '',
|
||||
'data-volume': '0.8',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object for empty state', () => {
|
||||
const state = {};
|
||||
expect(getStateDataAttrs(state)).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores empty string values', () => {
|
||||
const state = { label: '' };
|
||||
expect(getStateDataAttrs(state)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyStateDataAttrs', () => {
|
||||
it('applies data attributes to element', () => {
|
||||
const element = document.createElement('div');
|
||||
const state = { paused: true, volume: 0.5 };
|
||||
|
||||
applyStateDataAttrs(element, state);
|
||||
|
||||
expect(element.getAttribute('data-paused')).toBe('');
|
||||
expect(element.getAttribute('data-volume')).toBe('0.5');
|
||||
});
|
||||
|
||||
it('does not apply falsy values', () => {
|
||||
const element = document.createElement('div');
|
||||
const state = { paused: false, ended: null, waiting: undefined };
|
||||
|
||||
applyStateDataAttrs(element, state);
|
||||
|
||||
expect(element.hasAttribute('data-paused')).toBe(false);
|
||||
expect(element.hasAttribute('data-ended')).toBe(false);
|
||||
expect(element.hasAttribute('data-waiting')).toBe(false);
|
||||
});
|
||||
|
||||
it('applies multiple attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
const state = { paused: true, ended: true, started: true };
|
||||
|
||||
applyStateDataAttrs(element, state);
|
||||
|
||||
expect(element.getAttribute('data-paused')).toBe('');
|
||||
expect(element.getAttribute('data-ended')).toBe('');
|
||||
expect(element.getAttribute('data-started')).toBe('');
|
||||
});
|
||||
|
||||
it('removes attributes when state becomes false', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
applyStateDataAttrs(element, { paused: true });
|
||||
expect(element.hasAttribute('data-paused')).toBe(true);
|
||||
|
||||
applyStateDataAttrs(element, { paused: false });
|
||||
expect(element.hasAttribute('data-paused')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes attributes when state becomes null or undefined', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
applyStateDataAttrs(element, { status: 'loading' });
|
||||
expect(element.getAttribute('data-status')).toBe('loading');
|
||||
|
||||
applyStateDataAttrs(element, { status: null });
|
||||
expect(element.hasAttribute('data-status')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles state transitions correctly', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
// Initial state
|
||||
applyStateDataAttrs(element, { paused: true, ended: false, volume: 0.5 });
|
||||
expect(element.hasAttribute('data-paused')).toBe(true);
|
||||
expect(element.hasAttribute('data-ended')).toBe(false);
|
||||
expect(element.getAttribute('data-volume')).toBe('0.5');
|
||||
|
||||
// Transition: paused false, ended true, volume 0
|
||||
applyStateDataAttrs(element, { paused: false, ended: true, volume: 0 });
|
||||
expect(element.hasAttribute('data-paused')).toBe(false);
|
||||
expect(element.hasAttribute('data-ended')).toBe(true);
|
||||
expect(element.hasAttribute('data-volume')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user