diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 45e48bfb..56e74cb6 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/ui/time/tests/time-core.test.ts b/packages/core/src/core/ui/time/tests/time-core.test.ts new file mode 100644 index 00000000..10f020b3 --- /dev/null +++ b/packages/core/src/core/ui/time/tests/time-core.test.ts @@ -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 { + 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'); + }); + }); +}); diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts new file mode 100644 index 00000000..99343eda --- /dev/null +++ b/packages/core/src/core/ui/time/time-core.ts @@ -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 = { + current: 'Current time', + duration: 'Duration', + remaining: 'Remaining', +}; + +export class TimeCore { + static readonly defaultProps: NonNullableObject = { + 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 { + 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; +} diff --git a/packages/core/src/core/ui/time/time-data-attrs.ts b/packages/core/src/core/ui/time/time-data-attrs.ts new file mode 100644 index 00000000..c5d5de3b --- /dev/null +++ b/packages/core/src/core/ui/time/time-data-attrs.ts @@ -0,0 +1,4 @@ +export const TimeDataAttrs = { + /** The type of time being displayed. */ + type: 'data-type', +} as const; diff --git a/packages/html/src/define/ui/time.ts b/packages/html/src/define/ui/time.ts new file mode 100644 index 00000000..0d1089a9 --- /dev/null +++ b/packages/html/src/define/ui/time.ts @@ -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; + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 8e5b87fe..0499a46c 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts new file mode 100644 index 00000000..851eca4c --- /dev/null +++ b/packages/html/src/ui/time/time-element.ts @@ -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); + } +} diff --git a/packages/html/src/ui/time/time-group-element.ts b/packages/html/src/ui/time/time-group-element.ts new file mode 100644 index 00000000..45e5a08e --- /dev/null +++ b/packages/html/src/ui/time/time-group-element.ts @@ -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 +} diff --git a/packages/html/src/ui/time/time-separator-element.ts b/packages/html/src/ui/time/time-separator-element.ts new file mode 100644 index 00000000..6aaf6ed6 --- /dev/null +++ b/packages/html/src/ui/time/time-separator-element.ts @@ -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 = '/'; + } + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a50d2a46..cf3b8b97 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -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'; diff --git a/packages/react/src/ui/time/index.parts.ts b/packages/react/src/ui/time/index.parts.ts new file mode 100644 index 00000000..106d3c32 --- /dev/null +++ b/packages/react/src/ui/time/index.parts.ts @@ -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'; diff --git a/packages/react/src/ui/time/index.ts b/packages/react/src/ui/time/index.ts new file mode 100644 index 00000000..c620d76e --- /dev/null +++ b/packages/react/src/ui/time/index.ts @@ -0,0 +1 @@ +export * as Time from './index.parts'; diff --git a/packages/react/src/ui/time/time-group.tsx b/packages/react/src/ui/time/time-group.tsx new file mode 100644 index 00000000..ec3935cc --- /dev/null +++ b/packages/react/src/ui/time/time-group.tsx @@ -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; + +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 `` element. + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ +export const Group = forwardRef(function Group( + componentProps: GroupProps, + forwardedRef: ForwardedRef +) { + 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; +} diff --git a/packages/react/src/ui/time/time-separator.tsx b/packages/react/src/ui/time/time-separator.tsx new file mode 100644 index 00000000..8fa399bd --- /dev/null +++ b/packages/react/src/ui/time/time-separator.tsx @@ -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; + +export interface SeparatorProps extends UIComponentProps<'span', SeparatorState> { + /** Separator content. Defaults to "/". */ + children?: ReactNode | undefined; +} + +/** + * Divider between time values. Hidden from screen readers. + * + * @example + * ```tsx + * + * of + * ``` + */ +export const Separator = forwardRef(function Separator( + componentProps: SeparatorProps, + forwardedRef: ForwardedRef +) { + 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; +} diff --git a/packages/react/src/ui/time/time-value.tsx b/packages/react/src/ui/time/time-value.tsx new file mode 100644 index 00000000..ba10bf15 --- /dev/null +++ b/packages/react/src/ui/time/time-value.tsx @@ -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, 'children'>, TimeCore.Props {} + +/** + * Displays a formatted time value (current, duration, or remaining). + * + * @example + * ```tsx + * + * + * + * ``` + */ +export const Value = forwardRef(function Value( + componentProps: ValueProps, + forwardedRef: ForwardedRef +) { + 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 ? ( + <> + + {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; +} diff --git a/packages/utils/package.json b/packages/utils/package.json index a8dd4216..2cbbf982 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -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" diff --git a/packages/utils/src/time/format.ts b/packages/utils/src/time/format.ts new file mode 100644 index 00000000..678be960 --- /dev/null +++ b/packages/utils/src/time/format.ts @@ -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; +} diff --git a/packages/utils/src/time/index.ts b/packages/utils/src/time/index.ts new file mode 100644 index 00000000..16c5b2b5 --- /dev/null +++ b/packages/utils/src/time/index.ts @@ -0,0 +1 @@ +export * from './format'; diff --git a/packages/utils/src/time/tests/format.test.ts b/packages/utils/src/time/tests/format.test.ts new file mode 100644 index 00000000..bcd82a22 --- /dev/null +++ b/packages/utils/src/time/tests/format.test.ts @@ -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'); + }); +}); diff --git a/packages/utils/tsdown.config.ts b/packages/utils/tsdown.config.ts index ca4b72a0..23cdc69d 100644 --- a/packages/utils/tsdown.config.ts +++ b/packages/utils/tsdown.config.ts @@ -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',