feat(core): add time display component (#460)

This commit is contained in:
rahim
2026-02-06 14:31:33 +11:00
committed by GitHub
parent d5e5cec6ab
commit 7b8bc11f9f
20 changed files with 811 additions and 0 deletions
+2
View File
@@ -3,3 +3,5 @@ export * from './media/state';
export * from './ui/mute-button/mute-button-core';
export * from './ui/mute-button/mute-button-data-attrs';
export * from './ui/play-button/play-button-core';
export * from './ui/time/time-core';
export * from './ui/time/time-data-attrs';
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import type { TimeState } from '../../../media/state';
import { TimeCore } from '../time-core';
function createTimeState(overrides: Partial<TimeState> = {}): TimeState {
return {
currentTime: 90,
duration: 300,
seeking: false,
seek: async () => 0,
...overrides,
};
}
describe('TimeCore', () => {
describe('setProps', () => {
it('uses default props', () => {
const core = new TimeCore();
const state = core.getState(createTimeState());
expect(state.type).toBe('current');
});
it('accepts custom props', () => {
const core = new TimeCore({ type: 'duration' });
const state = core.getState(createTimeState());
expect(state.type).toBe('duration');
});
});
describe('getState', () => {
it('returns current time state', () => {
const core = new TimeCore({ type: 'current' });
const state = core.getState(createTimeState({ currentTime: 90 }));
expect(state.type).toBe('current');
expect(state.seconds).toBe(90);
expect(state.text).toBe('1:30');
expect(state.phrase).toBe('1 minute, 30 seconds');
expect(state.datetime).toBe('PT1M30S');
});
it('returns duration state', () => {
const core = new TimeCore({ type: 'duration' });
const state = core.getState(createTimeState({ duration: 300 }));
expect(state.type).toBe('duration');
expect(state.seconds).toBe(300);
expect(state.text).toBe('5:00');
expect(state.phrase).toBe('5 minutes');
expect(state.datetime).toBe('PT5M');
});
it('returns remaining time state', () => {
const core = new TimeCore({ type: 'remaining' });
const state = core.getState(createTimeState({ currentTime: 90, duration: 300 }));
expect(state.type).toBe('remaining');
expect(state.seconds).toBe(-210); // 90 - 300
expect(state.text).toBe('-3:30');
expect(state.phrase).toBe('3 minutes, 30 seconds remaining');
expect(state.datetime).toBe('PT3M30S');
});
it('uses custom negative sign', () => {
const core = new TimeCore({ type: 'remaining', negativeSign: '' });
const state = core.getState(createTimeState({ currentTime: 90, duration: 300 }));
expect(state.text).toBe('3:30');
});
it('shows hours when duration has hours', () => {
const core = new TimeCore({ type: 'current' });
const state = core.getState(createTimeState({ currentTime: 90, duration: 3700 }));
expect(state.text).toBe('0:01:30');
});
});
describe('getLabel', () => {
it('returns default label for current', () => {
const core = new TimeCore({ type: 'current' });
expect(core.getLabel(createTimeState())).toBe('Current time');
});
it('returns default label for duration', () => {
const core = new TimeCore({ type: 'duration' });
expect(core.getLabel(createTimeState())).toBe('Duration');
});
it('returns default label for remaining', () => {
const core = new TimeCore({ type: 'remaining' });
expect(core.getLabel(createTimeState())).toBe('Remaining');
});
it('returns custom string label', () => {
const core = new TimeCore({ type: 'current', label: 'Position' });
expect(core.getLabel(createTimeState())).toBe('Position');
});
it('returns custom function label', () => {
const core = new TimeCore({
type: 'current',
label: (state) => `Time: ${state.text}`,
});
expect(core.getLabel(createTimeState({ currentTime: 90 }))).toBe('Time: 1:30');
});
});
describe('getAttrs', () => {
it('returns aria attributes', () => {
const core = new TimeCore({ type: 'current' });
const attrs = core.getAttrs(createTimeState({ currentTime: 90 }));
expect(attrs['aria-label']).toBe('Current time');
expect(attrs['aria-valuetext']).toBe('1 minute, 30 seconds');
});
it('includes remaining suffix in valuetext', () => {
const core = new TimeCore({ type: 'remaining' });
const attrs = core.getAttrs(createTimeState({ currentTime: 90, duration: 300 }));
expect(attrs['aria-label']).toBe('Remaining');
expect(attrs['aria-valuetext']).toBe('3 minutes, 30 seconds remaining');
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '@videojs/utils/time';
import type { NonNullableObject } from '@videojs/utils/types';
import type { TimeState } from '../../media/state';
/** Time display type. */
export type TimeType = 'current' | 'duration' | 'remaining';
export interface TimeCoreProps {
/** Which time value to display. */
type?: TimeType | undefined;
/** Symbol prepended to remaining time. */
negativeSign?: string | undefined;
/** Custom label for accessibility. */
label?: string | ((state: TimeValueState) => string) | undefined;
}
export interface TimeValueState {
/** Time display type. */
type: TimeType;
/** Raw value in seconds. */
seconds: number;
/** Formatted display text (e.g., "1:30"). */
text: string;
/** Human-readable phrase (e.g., "1 minute, 30 seconds"). */
phrase: string;
/** ISO 8601 duration (e.g., "PT1M30S"). */
datetime: string;
}
const DEFAULT_LABELS: Record<TimeType, string> = {
current: 'Current time',
duration: 'Duration',
remaining: 'Remaining',
};
export class TimeCore {
static readonly defaultProps: NonNullableObject<TimeCoreProps> = {
type: 'current',
negativeSign: '-',
label: '',
};
#props = { ...TimeCore.defaultProps };
constructor(props?: TimeCoreProps) {
if (props) this.setProps(props);
}
setProps(props: TimeCoreProps): void {
this.#props = defaults(props, TimeCore.defaultProps);
}
#getSeconds(time: TimeState): number {
const { type } = this.#props;
switch (type) {
case 'current':
return time.currentTime;
case 'duration':
return time.duration;
case 'remaining':
return time.currentTime - time.duration;
default:
return 0;
}
}
#getText(time: TimeState): string {
const { type, negativeSign } = this.#props;
const seconds = this.#getSeconds(time);
if (type === 'remaining') {
const formatted = formatTime(Math.abs(seconds), time.duration);
return seconds < 0 ? `${negativeSign}${formatted}` : formatted;
}
return formatTime(seconds, time.duration);
}
#getPhrase(time: TimeState): string {
const { type } = this.#props;
const seconds = this.#getSeconds(time);
if (type === 'remaining') {
// Use negative to trigger "remaining" suffix
return formatTimeAsPhrase(seconds < 0 ? seconds : -Math.abs(seconds));
}
return formatTimeAsPhrase(seconds);
}
#getDatetime(time: TimeState): string {
const seconds = this.#getSeconds(time);
return secondsToIsoDuration(Math.abs(seconds));
}
getLabel(time: TimeState): string {
const state = this.getState(time);
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return DEFAULT_LABELS[this.#props.type];
}
getAttrs(time: TimeState): Record<string, string | undefined> {
return {
'aria-label': this.getLabel(time),
'aria-valuetext': this.#getPhrase(time),
};
}
getState(time: TimeState): TimeValueState {
const seconds = this.#getSeconds(time);
return {
type: this.#props.type,
seconds,
text: this.#getText(time),
phrase: this.#getPhrase(time),
datetime: this.#getDatetime(time),
};
}
}
export namespace TimeCore {
export type Props = TimeCoreProps;
export type State = TimeValueState;
}
@@ -0,0 +1,4 @@
export const TimeDataAttrs = {
/** The type of time being displayed. */
type: 'data-type',
} as const;