mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(core): add time display component (#460)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user