feat(core): add seek button component (#526)

This commit is contained in:
rahim
2026-02-13 23:30:25 +11:00
committed by GitHub
parent 2c8b77af45
commit c733077d32
10 changed files with 399 additions and 2 deletions
+2
View File
@@ -11,6 +11,8 @@ export * from './ui/play-button/play-button-core';
export * from './ui/play-button/play-button-data-attrs';
export * from './ui/poster/poster-core';
export * from './ui/poster/poster-data-attrs';
export * from './ui/seek-button/seek-button-core';
export * from './ui/seek-button/seek-button-data-attrs';
export * from './ui/time/time-core';
export * from './ui/time/time-data-attrs';
export * from './ui/types';
@@ -0,0 +1,79 @@
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaTimeState } from '../../media/state';
export interface SeekButtonProps {
/** Seconds to seek. Positive = forward, negative = backward. Default `30`. */
seconds?: number | undefined;
/** Custom label for the button. */
label?: string | ((state: SeekButtonState) => string) | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
export type SeekButtonDirection = 'forward' | 'backward';
export interface SeekButtonState {
/** Whether a seek is in progress. */
seeking: boolean;
/** Whether the button seeks forward or backward. */
direction: SeekButtonDirection;
}
export class SeekButtonCore {
static readonly defaultProps: NonNullableObject<SeekButtonProps> = {
seconds: 30,
label: '',
disabled: false,
};
#props = { ...SeekButtonCore.defaultProps };
constructor(props?: SeekButtonProps) {
if (props) this.setProps(props);
}
setProps(props: SeekButtonProps): void {
this.#props = defaults(props, SeekButtonCore.defaultProps);
}
getLabel(state: SeekButtonState): string {
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
const abs = Math.abs(this.#props.seconds);
return state.direction === 'backward' ? `Seek backward ${abs} seconds` : `Seek forward ${abs} seconds`;
}
getAttrs(state: SeekButtonState) {
return {
'aria-label': this.getLabel(state),
'aria-disabled': this.#props.disabled ? 'true' : undefined,
};
}
getState(media: MediaTimeState): SeekButtonState {
return {
seeking: media.seeking,
direction: this.#props.seconds < 0 ? 'backward' : 'forward',
};
}
async seek(media: MediaTimeState): Promise<void> {
if (this.#props.disabled) return;
await media.seek(media.currentTime + this.#props.seconds);
}
}
export namespace SeekButtonCore {
export type Props = SeekButtonProps;
export type State = SeekButtonState;
}
@@ -0,0 +1,9 @@
import type { StateAttrMap } from '../types';
import type { SeekButtonState } from './seek-button-core';
export const SeekButtonDataAttrs = {
/** Present when a seek is in progress. */
seeking: 'data-seeking',
/** Indicates the seek direction: `"forward"` or `"backward"`. */
direction: 'data-direction',
} as const satisfies StateAttrMap<SeekButtonState>;
@@ -0,0 +1,159 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaTimeState } from '../../../media/state';
import type { SeekButtonState } from '../seek-button-core';
import { SeekButtonCore } from '../seek-button-core';
function createMediaState(overrides: Partial<MediaTimeState> = {}): MediaTimeState {
return {
currentTime: 0,
duration: 300,
seeking: false,
seek: vi.fn(async (time: number) => time),
...overrides,
};
}
function createState(overrides: Partial<SeekButtonState> = {}): SeekButtonState {
return {
seeking: false,
direction: 'forward',
...overrides,
};
}
describe('SeekButtonCore', () => {
describe('setProps', () => {
it('uses default props', () => {
const core = new SeekButtonCore();
const state = core.getState(createMediaState());
expect(state.direction).toBe('forward');
});
it('accepts constructor props', () => {
const core = new SeekButtonCore({ seconds: -10 });
const state = core.getState(createMediaState());
expect(state.direction).toBe('backward');
});
it('accepts disabled via constructor', () => {
const core = new SeekButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
});
describe('getState', () => {
it('projects seeking from media state', () => {
const core = new SeekButtonCore();
const state = core.getState(createMediaState({ seeking: true }));
expect(state.seeking).toBe(true);
});
it('derives forward direction from positive seconds', () => {
const core = new SeekButtonCore({ seconds: 15 });
const state = core.getState(createMediaState());
expect(state.direction).toBe('forward');
});
it('derives backward direction from negative seconds', () => {
const core = new SeekButtonCore({ seconds: -15 });
const state = core.getState(createMediaState());
expect(state.direction).toBe('backward');
});
it('defaults to forward direction', () => {
const core = new SeekButtonCore();
const state = core.getState(createMediaState());
expect(state.direction).toBe('forward');
});
});
describe('getLabel', () => {
it('returns forward label for forward direction', () => {
const core = new SeekButtonCore({ seconds: 30 });
expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 30 seconds');
});
it('returns backward label for backward direction', () => {
const core = new SeekButtonCore({ seconds: -10 });
expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Seek backward 10 seconds');
});
it('uses absolute value in backward label', () => {
const core = new SeekButtonCore({ seconds: -30 });
const label = core.getLabel(createState({ direction: 'backward' }));
expect(label).toBe('Seek backward 30 seconds');
expect(label).not.toContain('-');
});
it('returns custom string label', () => {
const core = new SeekButtonCore({ label: 'Skip' });
expect(core.getLabel(createState())).toBe('Skip');
});
it('returns custom function label', () => {
const core = new SeekButtonCore({
label: (state) => (state.direction === 'backward' ? 'Rewind' : 'Skip ahead'),
});
expect(core.getLabel(createState({ direction: 'backward' }))).toBe('Rewind');
expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Skip ahead');
});
it('falls back to default when function returns empty', () => {
const core = new SeekButtonCore({ seconds: 10, label: () => '' });
expect(core.getLabel(createState({ direction: 'forward' }))).toBe('Seek forward 10 seconds');
});
});
describe('getAttrs', () => {
it('returns aria-label', () => {
const core = new SeekButtonCore({ seconds: 30 });
const attrs = core.getAttrs(createState({ direction: 'forward' }));
expect(attrs['aria-label']).toBe('Seek forward 30 seconds');
});
it('sets aria-disabled when disabled', () => {
const core = new SeekButtonCore({ disabled: true });
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBe('true');
});
it('omits aria-disabled when not disabled', () => {
const core = new SeekButtonCore();
const attrs = core.getAttrs(createState());
expect(attrs['aria-disabled']).toBeUndefined();
});
});
describe('seek', () => {
it('seeks forward by seconds offset', async () => {
const core = new SeekButtonCore({ seconds: 30 });
const media = createMediaState({ currentTime: 60 });
await core.seek(media);
expect(media.seek).toHaveBeenCalledWith(90);
});
it('seeks backward by negative seconds offset', async () => {
const core = new SeekButtonCore({ seconds: -10 });
const media = createMediaState({ currentTime: 60 });
await core.seek(media);
expect(media.seek).toHaveBeenCalledWith(50);
});
it('does not clamp the target time', async () => {
const core = new SeekButtonCore({ seconds: -30 });
const media = createMediaState({ currentTime: 10 });
await core.seek(media);
// Clamping is the store's responsibility, not the button's.
expect(media.seek).toHaveBeenCalledWith(-20);
});
it('does nothing when disabled', async () => {
const core = new SeekButtonCore({ disabled: true, seconds: 30 });
const media = createMediaState({ currentTime: 60 });
await core.seek(media);
expect(media.seek).not.toHaveBeenCalled();
});
});
});