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
+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);
});
});