feat(core): add play button component (#383)

This commit is contained in:
rahim
2026-02-04 00:33:14 +11:00
committed by GitHub
parent 9fa172e8a4
commit 9cfab264d8
41 changed files with 2083 additions and 38 deletions
+8
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './element';
export * from './media/state';
export * from './ui/play-button/play-button-core';
+36
View File
@@ -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 -1
View File
@@ -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>();
+3 -1
View File
@@ -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 }) => ({
+66
View File
@@ -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();
}
},
};
}
+9
View File
@@ -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));
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export { applyElementProps } from './element-props';
export { logMissingFeature } from './log';
export { applyStateDataAttrs, getStateDataAttrs } from './state-data-attrs';
+9
View File
@@ -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);
});
});