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:
@@ -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