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;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TimeElement } from '../../ui/time/time-element';
|
||||
import { TimeGroupElement } from '../../ui/time/time-group-element';
|
||||
import { TimeSeparatorElement } from '../../ui/time/time-separator-element';
|
||||
|
||||
customElements.define(TimeElement.tagName, TimeElement);
|
||||
customElements.define(TimeGroupElement.tagName, TimeGroupElement);
|
||||
customElements.define(TimeSeparatorElement.tagName, TimeSeparatorElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[TimeElement.tagName]: TimeElement;
|
||||
[TimeGroupElement.tagName]: TimeGroupElement;
|
||||
[TimeSeparatorElement.tagName]: TimeSeparatorElement;
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,6 @@ export * from './ui/media-element';
|
||||
// UI Components
|
||||
export { MuteButtonElement } from './ui/mute-button/mute-button-element';
|
||||
export { PlayButtonElement } from './ui/play-button/play-button-element';
|
||||
export { TimeElement } from './ui/time/time-element';
|
||||
export { TimeGroupElement } from './ui/time/time-group-element';
|
||||
export { TimeSeparatorElement } from './ui/time/time-separator-element';
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PropertyValues } from '@lit/reactive-element';
|
||||
import { TimeCore, type TimeType } from '@videojs/core';
|
||||
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectTime } from '@videojs/core/dom';
|
||||
|
||||
import { playerContext } from '../../player/context';
|
||||
import { PlayerController } from '../../player/player-controller';
|
||||
import { MediaElement } from '../media-element';
|
||||
|
||||
export class TimeElement extends MediaElement {
|
||||
static readonly tagName = 'media-time';
|
||||
|
||||
static override properties = {
|
||||
type: { type: String },
|
||||
negativeSign: { type: String, attribute: 'negative-sign' },
|
||||
label: { type: String },
|
||||
};
|
||||
|
||||
type: TimeType = 'current';
|
||||
negativeSign = '-';
|
||||
label = '';
|
||||
|
||||
readonly #core = new TimeCore();
|
||||
readonly #state = new PlayerController(this, playerContext, selectTime);
|
||||
|
||||
readonly #signSpan = document.createElement('span');
|
||||
readonly #textNode = document.createTextNode('');
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#signSpan.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.#state.value) {
|
||||
logMissingFeature(TimeElement.tagName, 'time');
|
||||
}
|
||||
}
|
||||
|
||||
protected override willUpdate(changed: PropertyValues): void {
|
||||
super.willUpdate(changed);
|
||||
this.#core.setProps({ type: this.type, negativeSign: this.negativeSign, label: this.label });
|
||||
}
|
||||
|
||||
protected override update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
const time = this.#state.value;
|
||||
|
||||
if (!time) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = this.#core.getState(time);
|
||||
const showSign = state.type === 'remaining' && state.seconds < 0;
|
||||
|
||||
if (showSign) {
|
||||
this.#signSpan.textContent = this.negativeSign;
|
||||
this.#textNode.textContent = state.text.replace(/^-/, '');
|
||||
|
||||
// Append elements if not already in DOM
|
||||
if (!this.#signSpan.parentNode) {
|
||||
this.textContent = '';
|
||||
this.appendChild(this.#signSpan);
|
||||
this.appendChild(this.#textNode);
|
||||
}
|
||||
} else {
|
||||
// Remove sign span if present, use direct text
|
||||
if (this.#signSpan.parentNode) {
|
||||
this.#signSpan.remove();
|
||||
this.#textNode.remove();
|
||||
}
|
||||
this.textContent = state.text;
|
||||
}
|
||||
|
||||
applyElementProps(this, this.#core.getAttrs(time));
|
||||
applyStateDataAttrs(this, state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { MediaElement } from '../media-element';
|
||||
|
||||
export class TimeGroupElement extends MediaElement {
|
||||
static readonly tagName = 'media-time-group';
|
||||
|
||||
// Future: Could provide context for hoursDisplay to children via Lit context
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MediaElement } from '../media-element';
|
||||
|
||||
export class TimeSeparatorElement extends MediaElement {
|
||||
static readonly tagName = 'media-time-separator';
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Set aria-hidden for accessibility
|
||||
this.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Set default content if empty
|
||||
if (!this.textContent?.trim()) {
|
||||
this.textContent = '/';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export { useButton } from './ui/hooks/use-button';
|
||||
// UI Components
|
||||
export { MuteButton, type MuteButtonProps } from './ui/mute-button/mute-button';
|
||||
export { PlayButton, type PlayButtonProps } from './ui/play-button/play-button';
|
||||
export { Time } from './ui/time';
|
||||
|
||||
// Utilities
|
||||
export { mergeProps } from './utils/merge-props';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Group, type GroupProps } from './time-group';
|
||||
export { Separator, type SeparatorProps } from './time-separator';
|
||||
export { Value, type ValueProps } from './time-value';
|
||||
@@ -0,0 +1 @@
|
||||
export * as Time from './index.parts';
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import type { ForwardedRef, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
|
||||
// Empty state for Group (no dynamic state)
|
||||
type GroupState = Record<string, never>;
|
||||
|
||||
export interface GroupProps extends UIComponentProps<'span', GroupState> {
|
||||
/** Time value components to render inside the group. */
|
||||
children?: ReactNode | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for composed time displays. Renders a `<span>` element.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Time.Group>
|
||||
* <Time.Value type="current" />
|
||||
* <Time.Separator />
|
||||
* <Time.Value type="duration" />
|
||||
* </Time.Group>
|
||||
* ```
|
||||
*/
|
||||
export const Group = forwardRef(function Group(
|
||||
componentProps: GroupProps,
|
||||
forwardedRef: ForwardedRef<HTMLSpanElement>
|
||||
) {
|
||||
const { render, className, style, children, ...elementProps } = componentProps;
|
||||
|
||||
const state: GroupState = {};
|
||||
|
||||
return renderElement(
|
||||
'span',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
ref: [forwardedRef],
|
||||
props: [{ children }, elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace Group {
|
||||
export type Props = GroupProps;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import type { ForwardedRef, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
|
||||
// Empty state for Separator (no dynamic state)
|
||||
type SeparatorState = Record<string, never>;
|
||||
|
||||
export interface SeparatorProps extends UIComponentProps<'span', SeparatorState> {
|
||||
/** Separator content. Defaults to "/". */
|
||||
children?: ReactNode | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider between time values. Hidden from screen readers.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Time.Separator />
|
||||
* <Time.Separator> of </Time.Separator>
|
||||
* ```
|
||||
*/
|
||||
export const Separator = forwardRef(function Separator(
|
||||
componentProps: SeparatorProps,
|
||||
forwardedRef: ForwardedRef<HTMLSpanElement>
|
||||
) {
|
||||
const { render, className, style, children = '/', ...elementProps } = componentProps;
|
||||
|
||||
const state: SeparatorState = {};
|
||||
|
||||
return renderElement(
|
||||
'span',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
ref: [forwardedRef],
|
||||
props: [{ 'aria-hidden': 'true', children }, elementProps],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace Separator {
|
||||
export type Props = SeparatorProps;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { TimeCore } from '@videojs/core';
|
||||
import { logMissingFeature, selectTime } from '@videojs/core/dom';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useState } from 'react';
|
||||
|
||||
import { usePlayer } from '../../player/context';
|
||||
import type { UIComponentProps } from '../../utils/types';
|
||||
import { renderElement } from '../../utils/use-render';
|
||||
|
||||
export interface ValueProps extends Omit<UIComponentProps<'time', TimeCore.State>, 'children'>, TimeCore.Props {}
|
||||
|
||||
/**
|
||||
* Displays a formatted time value (current, duration, or remaining).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Time.Value />
|
||||
* <Time.Value type="duration" />
|
||||
* <Time.Value type="remaining" negativeSign="−" />
|
||||
* ```
|
||||
*/
|
||||
export const Value = forwardRef(function Value(
|
||||
componentProps: ValueProps,
|
||||
forwardedRef: ForwardedRef<HTMLTimeElement>
|
||||
) {
|
||||
const { render, className, style, type, negativeSign, label, ...elementProps } = componentProps;
|
||||
|
||||
const time = usePlayer(selectTime);
|
||||
|
||||
const [core] = useState(() => new TimeCore());
|
||||
core.setProps({ type, negativeSign, label });
|
||||
|
||||
if (!time) {
|
||||
logMissingFeature('Time.Value', 'time');
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = core.getState(time);
|
||||
|
||||
// Render negative sign as aria-hidden span for remaining time
|
||||
const content =
|
||||
state.type === 'remaining' && state.seconds < 0 ? (
|
||||
<>
|
||||
<span aria-hidden="true">{negativeSign ?? '-'}</span>
|
||||
{state.text.replace(/^-/, '')}
|
||||
</>
|
||||
) : (
|
||||
state.text
|
||||
);
|
||||
|
||||
return renderElement(
|
||||
'time',
|
||||
{ render, className, style },
|
||||
{
|
||||
state,
|
||||
ref: [forwardedRef],
|
||||
props: [
|
||||
{
|
||||
datetime: state.datetime,
|
||||
children: content,
|
||||
...core.getAttrs(time),
|
||||
},
|
||||
elementProps,
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export namespace Value {
|
||||
export type Props = ValueProps;
|
||||
export type State = TimeCore.State;
|
||||
}
|
||||
@@ -34,6 +34,10 @@
|
||||
"types": "./dist/predicate.d.ts",
|
||||
"default": "./dist/predicate.js"
|
||||
},
|
||||
"./time": {
|
||||
"types": "./dist/time.d.ts",
|
||||
"default": "./dist/time.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"default": "./dist/types.js"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { isNumber } from '../predicate/predicate';
|
||||
|
||||
const UNIT_LABELS = [
|
||||
{ singular: 'hour', plural: 'hours' },
|
||||
{ singular: 'minute', plural: 'minutes' },
|
||||
{ singular: 'second', plural: 'seconds' },
|
||||
] as const;
|
||||
|
||||
function isValidTime(value: number): boolean {
|
||||
return isNumber(value) && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function toTimeUnitPhrase(value: number, unitIndex: number): string {
|
||||
const label = value === 1 ? UNIT_LABELS[unitIndex]?.singular : UNIT_LABELS[unitIndex]?.plural;
|
||||
return `${value} ${label}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds to digital display string.
|
||||
*
|
||||
* @param seconds - Time in seconds (can be negative)
|
||||
* @param guide - Guide time (typically duration) to determine display format
|
||||
* @returns Formatted string like "1:30" or "1:05:30"
|
||||
*
|
||||
* @example
|
||||
* formatTime(90) // "1:30"
|
||||
* formatTime(3661) // "1:01:01"
|
||||
* formatTime(35, 3600) // "0:00:35" (guided by 1-hour duration)
|
||||
* formatTime(35, 600) // "00:35" (guided by 10-minute duration)
|
||||
*/
|
||||
export function formatTime(seconds: number, guide?: number): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
const negative = seconds < 0;
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
|
||||
const h = Math.floor(positiveSeconds / 3600);
|
||||
const m = Math.floor((positiveSeconds / 60) % 60);
|
||||
const s = Math.floor(positiveSeconds % 60);
|
||||
|
||||
const guideAbs = guide ? Math.abs(guide) : 0;
|
||||
const gh = Math.floor(guideAbs / 3600);
|
||||
const gm = Math.floor((guideAbs / 60) % 60);
|
||||
|
||||
const showHours = h > 0 || gh > 0;
|
||||
// Add leading zero to minutes if hours showing OR guide minutes >= 10
|
||||
const padMinutes = showHours || gm >= 10;
|
||||
|
||||
const hoursStr = showHours ? `${h}:` : '';
|
||||
const minutesStr = `${padMinutes && m < 10 ? '0' : ''}${m}:`;
|
||||
const secondsStr = s < 10 ? `0${s}` : `${s}`;
|
||||
|
||||
return `${negative ? '-' : ''}${hoursStr}${minutesStr}${secondsStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds to human-readable phrase for screen readers.
|
||||
*
|
||||
* @param seconds - Time in seconds (negative indicates remaining)
|
||||
* @returns Human-readable phrase like "1 minute, 30 seconds"
|
||||
*
|
||||
* @example
|
||||
* formatTimeAsPhrase(90) // "1 minute, 30 seconds"
|
||||
* formatTimeAsPhrase(3661) // "1 hour, 1 minute, 1 second"
|
||||
* formatTimeAsPhrase(-270) // "4 minutes, 30 seconds remaining"
|
||||
*/
|
||||
export function formatTimeAsPhrase(seconds: number): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const negative = seconds < 0;
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
|
||||
const h = Math.floor(positiveSeconds / 3600);
|
||||
const m = Math.floor((positiveSeconds / 60) % 60);
|
||||
const s = Math.floor(positiveSeconds % 60);
|
||||
|
||||
const parts = [h, m, s].map((value, index) => (value > 0 ? toTimeUnitPhrase(value, index) : null)).filter(Boolean);
|
||||
|
||||
const phrase = parts.join(', ');
|
||||
const suffix = negative ? ' remaining' : '';
|
||||
|
||||
return `${phrase}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert seconds to ISO 8601 duration for datetime attribute.
|
||||
*
|
||||
* @param seconds - Time in seconds
|
||||
* @returns ISO 8601 duration string like "PT1M30S"
|
||||
*
|
||||
* @example
|
||||
* secondsToIsoDuration(90) // "PT1M30S"
|
||||
* secondsToIsoDuration(3661) // "PT1H1M1S"
|
||||
*/
|
||||
export function secondsToIsoDuration(seconds: number): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return 'PT0S';
|
||||
}
|
||||
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
|
||||
const h = Math.floor(positiveSeconds / 3600);
|
||||
const m = Math.floor((positiveSeconds / 60) % 60);
|
||||
const s = Math.floor(positiveSeconds % 60);
|
||||
|
||||
let duration = 'PT';
|
||||
if (h > 0) duration += `${h}H`;
|
||||
if (m > 0) duration += `${m}M`;
|
||||
if (s > 0 || duration === 'PT') duration += `${s}S`;
|
||||
|
||||
return duration;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './format';
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '../format';
|
||||
|
||||
describe('formatTime', () => {
|
||||
it('formats seconds only', () => {
|
||||
expect(formatTime(0)).toBe('0:00');
|
||||
expect(formatTime(5)).toBe('0:05');
|
||||
expect(formatTime(30)).toBe('0:30');
|
||||
expect(formatTime(59)).toBe('0:59');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatTime(60)).toBe('1:00');
|
||||
expect(formatTime(90)).toBe('1:30');
|
||||
expect(formatTime(125)).toBe('2:05');
|
||||
expect(formatTime(599)).toBe('9:59');
|
||||
expect(formatTime(600)).toBe('10:00');
|
||||
});
|
||||
|
||||
it('formats hours, minutes, and seconds', () => {
|
||||
expect(formatTime(3600)).toBe('1:00:00');
|
||||
expect(formatTime(3661)).toBe('1:01:01');
|
||||
expect(formatTime(7325)).toBe('2:02:05');
|
||||
expect(formatTime(36000)).toBe('10:00:00');
|
||||
});
|
||||
|
||||
it('pads minutes when hours are shown', () => {
|
||||
expect(formatTime(3605)).toBe('1:00:05');
|
||||
expect(formatTime(3660)).toBe('1:01:00');
|
||||
});
|
||||
|
||||
it('handles negative values', () => {
|
||||
expect(formatTime(-90)).toBe('-1:30');
|
||||
expect(formatTime(-3661)).toBe('-1:01:01');
|
||||
});
|
||||
|
||||
it('uses guide to determine hour display', () => {
|
||||
expect(formatTime(35, 3600)).toBe('0:00:35');
|
||||
expect(formatTime(90, 7200)).toBe('0:01:30');
|
||||
});
|
||||
|
||||
it('pads minutes when guide minutes >= 10', () => {
|
||||
// 10 minute guide (600s) should pad minutes for consistent width
|
||||
expect(formatTime(35, 600)).toBe('00:35');
|
||||
expect(formatTime(5, 600)).toBe('00:05');
|
||||
expect(formatTime(65, 600)).toBe('01:05');
|
||||
// 9 minute guide should not pad
|
||||
expect(formatTime(35, 540)).toBe('0:35');
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(formatTime(NaN)).toBe('0:00');
|
||||
expect(formatTime(Infinity)).toBe('0:00');
|
||||
expect(formatTime(-Infinity)).toBe('0:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTimeAsPhrase', () => {
|
||||
it('formats seconds only', () => {
|
||||
expect(formatTimeAsPhrase(1)).toBe('1 second');
|
||||
expect(formatTimeAsPhrase(30)).toBe('30 seconds');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(formatTimeAsPhrase(60)).toBe('1 minute');
|
||||
expect(formatTimeAsPhrase(90)).toBe('1 minute, 30 seconds');
|
||||
expect(formatTimeAsPhrase(125)).toBe('2 minutes, 5 seconds');
|
||||
});
|
||||
|
||||
it('formats hours, minutes, and seconds', () => {
|
||||
expect(formatTimeAsPhrase(3600)).toBe('1 hour');
|
||||
expect(formatTimeAsPhrase(3661)).toBe('1 hour, 1 minute, 1 second');
|
||||
expect(formatTimeAsPhrase(7325)).toBe('2 hours, 2 minutes, 5 seconds');
|
||||
});
|
||||
|
||||
it('handles singular vs plural', () => {
|
||||
expect(formatTimeAsPhrase(1)).toBe('1 second');
|
||||
expect(formatTimeAsPhrase(2)).toBe('2 seconds');
|
||||
expect(formatTimeAsPhrase(60)).toBe('1 minute');
|
||||
expect(formatTimeAsPhrase(120)).toBe('2 minutes');
|
||||
expect(formatTimeAsPhrase(3600)).toBe('1 hour');
|
||||
expect(formatTimeAsPhrase(7200)).toBe('2 hours');
|
||||
});
|
||||
|
||||
it('adds remaining suffix for negative values', () => {
|
||||
expect(formatTimeAsPhrase(-30)).toBe('30 seconds remaining');
|
||||
expect(formatTimeAsPhrase(-90)).toBe('1 minute, 30 seconds remaining');
|
||||
expect(formatTimeAsPhrase(-3661)).toBe('1 hour, 1 minute, 1 second remaining');
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(formatTimeAsPhrase(NaN)).toBe('');
|
||||
expect(formatTimeAsPhrase(Infinity)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondsToIsoDuration', () => {
|
||||
it('formats seconds only', () => {
|
||||
expect(secondsToIsoDuration(0)).toBe('PT0S');
|
||||
expect(secondsToIsoDuration(30)).toBe('PT30S');
|
||||
});
|
||||
|
||||
it('formats minutes and seconds', () => {
|
||||
expect(secondsToIsoDuration(60)).toBe('PT1M');
|
||||
expect(secondsToIsoDuration(90)).toBe('PT1M30S');
|
||||
});
|
||||
|
||||
it('formats hours, minutes, and seconds', () => {
|
||||
expect(secondsToIsoDuration(3600)).toBe('PT1H');
|
||||
expect(secondsToIsoDuration(3661)).toBe('PT1H1M1S');
|
||||
expect(secondsToIsoDuration(7325)).toBe('PT2H2M5S');
|
||||
});
|
||||
|
||||
it('handles negative values (uses absolute)', () => {
|
||||
expect(secondsToIsoDuration(-90)).toBe('PT1M30S');
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(secondsToIsoDuration(NaN)).toBe('PT0S');
|
||||
expect(secondsToIsoDuration(Infinity)).toBe('PT0S');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
function: './src/function/index.ts',
|
||||
object: './src/object/index.ts',
|
||||
predicate: './src/predicate/index.ts',
|
||||
time: './src/time/index.ts',
|
||||
types: './src/types/index.ts',
|
||||
},
|
||||
platform: 'neutral',
|
||||
|
||||
Reference in New Issue
Block a user