mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): i18n (#1708)
This commit is contained in:
@@ -12,6 +12,11 @@ export {
|
||||
isInteractiveTarget,
|
||||
} from './interactive';
|
||||
export { listen } from './listen';
|
||||
export { effectiveLocale } from './locale/effective-locale';
|
||||
export { findNearestLang, findNearestLang as nearestLang } from './locale/find-nearest-lang';
|
||||
export { mergeLocaleOverlays } from './locale/merge-locale-overlays';
|
||||
export { resolveLangAttr } from './locale/resolve-lang-attr';
|
||||
export { subscribeAmbientLang } from './locale/subscribe-ambient-lang';
|
||||
export { isMacOS } from './platform';
|
||||
export { tryHidePopover, tryShowPopover } from './popover';
|
||||
export { isHTMLAudioElement, isHTMLMediaElement, isHTMLVideoElement } from './predicates';
|
||||
@@ -41,6 +46,7 @@ export type {
|
||||
EventType,
|
||||
QueriedElement,
|
||||
} from './types';
|
||||
export { walkAncestors } from './walk-ancestors';
|
||||
export {
|
||||
isWebKitAirPlayCapable,
|
||||
supportsWebKitAirPlay,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isUndefined } from '../../predicate';
|
||||
|
||||
/** Resolves locale: explicit non-empty value → ambient `lang` → {@link fallback}. */
|
||||
export function effectiveLocale<Locale extends string = string>(
|
||||
explicitLocale: Locale | undefined,
|
||||
ambientLang: Locale | undefined,
|
||||
fallback = 'en' as Locale
|
||||
): Locale {
|
||||
if (!isUndefined(explicitLocale) && explicitLocale.trim() !== '') {
|
||||
return explicitLocale;
|
||||
}
|
||||
if (!isUndefined(ambientLang) && ambientLang.trim() !== '') {
|
||||
return ambientLang;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { walkAncestors } from '../walk-ancestors';
|
||||
|
||||
function getElementLang(node: Element): string | undefined {
|
||||
const fromAttribute = node.getAttribute('lang')?.trim();
|
||||
if (fromAttribute) {
|
||||
return fromAttribute;
|
||||
}
|
||||
if ('lang' in node && typeof node.lang === 'string') {
|
||||
const fromProperty = node.lang.trim();
|
||||
if (fromProperty) {
|
||||
return fromProperty;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** First non-empty `lang` on `start` or an ancestor (HTML language inheritance). */
|
||||
export function findNearestLang(start: Element | null): string | undefined {
|
||||
return walkAncestors(start, getElementLang);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Loads overlay layers for each tag in {@link localeLookupChain}, least-specific first, then merges
|
||||
* most-specific-last (same semantics as the core i18n registry).
|
||||
*/
|
||||
export async function mergeLocaleOverlays<Overlay extends object>(
|
||||
locale: string,
|
||||
load: (tag: string) => Promise<Partial<Overlay> | undefined>,
|
||||
localeLookupChain: (locale: string) => string[]
|
||||
): Promise<{ merged: Partial<Overlay>; loadedTags: string[] }> {
|
||||
const chain = localeLookupChain(locale);
|
||||
const layers = await Promise.all(chain.map((tag) => load(tag)));
|
||||
const loadedTags: string[] = [];
|
||||
const merged: Partial<Overlay> = {};
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const layer = layers[i];
|
||||
if (layer && Object.keys(layer).length > 0) {
|
||||
loadedTags.push(chain[i]!);
|
||||
}
|
||||
}
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
const layer = layers[i];
|
||||
if (layer) {
|
||||
Object.assign(merged, layer);
|
||||
}
|
||||
}
|
||||
return { merged, loadedTags };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { isUndefined } from '../../predicate';
|
||||
|
||||
/**
|
||||
* Normalizes a raw `lang` string (e.g. from {@link findNearestLang}): empty or whitespace-only →
|
||||
* `undefined`, otherwise the trimmed value.
|
||||
*/
|
||||
export function resolveLangAttr<Locale extends string = string>(raw: string | undefined): Locale | undefined {
|
||||
if (isUndefined(raw) || raw.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
return raw.trim() as Locale;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
const subscribers = new Set<() => void>();
|
||||
let observer: MutationObserver | undefined;
|
||||
let queued = false;
|
||||
|
||||
const flush = (): void => {
|
||||
queued = false;
|
||||
for (const cb of subscribers) {
|
||||
cb();
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = (): void => {
|
||||
if (!queued) {
|
||||
queued = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
|
||||
function start(): void {
|
||||
if (observer || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
observer = new MutationObserver(schedule);
|
||||
observer.observe(document.documentElement, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['lang'],
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (subscribers.size || !observer) {
|
||||
return;
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
observer = undefined;
|
||||
queued = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to DOM updates that can change inherited `lang`: any `lang` attribute edit,
|
||||
* or subtree structural changes under `<html>` (which can move nodes between labeled ancestors).
|
||||
*/
|
||||
export function subscribeAmbientLang(onStoreChange: () => void): () => void {
|
||||
if (typeof document === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
subscribers.add(onStoreChange);
|
||||
start();
|
||||
|
||||
return () => {
|
||||
subscribers.delete(onStoreChange);
|
||||
stop();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { effectiveLocale } from '../locale/effective-locale';
|
||||
|
||||
describe('effectiveLocale', () => {
|
||||
it('prefers explicit locale over ambient', () => {
|
||||
expect(effectiveLocale('fr', 'de')).toBe('fr');
|
||||
});
|
||||
|
||||
it('uses ambient when explicit is undefined', () => {
|
||||
expect(effectiveLocale(undefined, 'es-MX')).toBe('es-MX');
|
||||
});
|
||||
|
||||
it('falls back to en when both missing or blank', () => {
|
||||
expect(effectiveLocale(undefined, undefined)).toBe('en');
|
||||
expect(effectiveLocale(' ', undefined)).toBe('en');
|
||||
expect(effectiveLocale(undefined, ' ')).toBe('en');
|
||||
});
|
||||
|
||||
it('respects custom fallback', () => {
|
||||
expect(effectiveLocale(undefined, undefined, 'xx')).toBe('xx');
|
||||
});
|
||||
|
||||
it('can resolve a caller locale type', () => {
|
||||
type Locale = 'en' | 'fr';
|
||||
const locale: Locale = effectiveLocale<Locale>(undefined, undefined);
|
||||
|
||||
expect(locale).toBe('en');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { findNearestLang } from '../locale/find-nearest-lang';
|
||||
|
||||
describe('findNearestLang', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute('lang');
|
||||
});
|
||||
|
||||
it('returns undefined for null start', () => {
|
||||
expect(findNearestLang(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads lang on the start element', () => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('lang', 'fr');
|
||||
document.body.appendChild(el);
|
||||
expect(findNearestLang(el)).toBe('fr');
|
||||
});
|
||||
|
||||
it('walks ancestors and prefers closest lang', () => {
|
||||
const outer = document.createElement('section');
|
||||
outer.setAttribute('lang', 'de');
|
||||
const inner = document.createElement('div');
|
||||
inner.setAttribute('lang', 'fr');
|
||||
outer.appendChild(inner);
|
||||
document.body.appendChild(outer);
|
||||
expect(findNearestLang(inner)).toBe('fr');
|
||||
});
|
||||
|
||||
it('inherits from an ancestor when start has no lang', () => {
|
||||
const outer = document.createElement('section');
|
||||
outer.setAttribute('lang', 'de');
|
||||
const inner = document.createElement('div');
|
||||
outer.appendChild(inner);
|
||||
document.body.appendChild(outer);
|
||||
expect(findNearestLang(inner)).toBe('de');
|
||||
});
|
||||
|
||||
it('ignores empty lang and continues walking', () => {
|
||||
const outer = document.createElement('section');
|
||||
outer.setAttribute('lang', 'de');
|
||||
const inner = document.createElement('div');
|
||||
inner.setAttribute('lang', ' ');
|
||||
outer.appendChild(inner);
|
||||
document.body.appendChild(outer);
|
||||
expect(findNearestLang(inner)).toBe('de');
|
||||
});
|
||||
|
||||
it('reads lang IDL property on html when set via documentElement.lang', () => {
|
||||
document.documentElement.lang = 'fr';
|
||||
const inner = document.createElement('div');
|
||||
document.body.appendChild(inner);
|
||||
expect(findNearestLang(inner)).toBe('fr');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { mergeLocaleOverlays } from '../locale/merge-locale-overlays';
|
||||
|
||||
describe('mergeLocaleOverlays', () => {
|
||||
it('merges layers least-specific to most-specific', async () => {
|
||||
const chain = (_locale: string) => ['es', 'en'];
|
||||
const load = async (tag: string): Promise<Partial<Record<'a' | 'b' | 'c', string>> | undefined> =>
|
||||
tag === 'en' ? { a: 'en-a', b: 'en-b' } : tag === 'es' ? { b: 'es-b', c: 'es-c' } : undefined;
|
||||
|
||||
const { merged, loadedTags } = await mergeLocaleOverlays('es', load, chain);
|
||||
expect(merged).toEqual({ a: 'en-a', b: 'es-b', c: 'es-c' });
|
||||
expect(loadedTags).toEqual(['es', 'en']);
|
||||
});
|
||||
|
||||
it('skips undefined layers', async () => {
|
||||
const chain = () => ['xx', 'en'];
|
||||
const load = async (tag: string) => (tag === 'en' ? { k: 'v' } : undefined);
|
||||
const { merged, loadedTags } = await mergeLocaleOverlays('xx', load, chain);
|
||||
expect(merged).toEqual({ k: 'v' });
|
||||
expect(loadedTags).toEqual(['en']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveLangAttr } from '../locale/resolve-lang-attr';
|
||||
|
||||
describe('resolveLangAttr', () => {
|
||||
it('returns undefined for missing or blank', () => {
|
||||
expect(resolveLangAttr(undefined)).toBeUndefined();
|
||||
expect(resolveLangAttr('')).toBeUndefined();
|
||||
expect(resolveLangAttr(' ')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns trimmed language tag', () => {
|
||||
expect(resolveLangAttr(' fr ')).toBe('fr');
|
||||
expect(resolveLangAttr('de-DE')).toBe('de-DE');
|
||||
});
|
||||
|
||||
it('can return a caller locale type', () => {
|
||||
type Locale = 'en' | 'fr';
|
||||
const locale: Locale | undefined = resolveLangAttr<Locale>('fr');
|
||||
|
||||
expect(locale).toBe('fr');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { subscribeAmbientLang } from '../locale/subscribe-ambient-lang';
|
||||
|
||||
describe('subscribeAmbientLang', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute('lang');
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('invokes callback when html lang changes', async () => {
|
||||
const spy = vi.fn();
|
||||
const off = subscribeAmbientLang(spy);
|
||||
document.documentElement.setAttribute('lang', 'de');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
off();
|
||||
});
|
||||
|
||||
it('invokes callback when html lang property changes', async () => {
|
||||
const spy = vi.fn();
|
||||
const off = subscribeAmbientLang(spy);
|
||||
document.documentElement.lang = 'fr';
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
off();
|
||||
});
|
||||
|
||||
it('unsubscribe stops notifications', async () => {
|
||||
const spy = vi.fn();
|
||||
const off = subscribeAmbientLang(spy);
|
||||
off();
|
||||
spy.mockClear();
|
||||
document.documentElement.setAttribute('lang', 'fr');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shares one observer across subscribers', () => {
|
||||
const RealObserver = globalThis.MutationObserver;
|
||||
const disconnect = vi.fn();
|
||||
const Observer = vi.fn(function (this: MutationObserver, callback: MutationCallback) {
|
||||
const observer = new RealObserver(callback);
|
||||
vi.spyOn(observer, 'disconnect').mockImplementation(disconnect);
|
||||
return observer;
|
||||
});
|
||||
vi.stubGlobal('MutationObserver', Observer);
|
||||
|
||||
const offA = subscribeAmbientLang(vi.fn());
|
||||
const offB = subscribeAmbientLang(vi.fn());
|
||||
|
||||
expect(Observer).toHaveBeenCalledTimes(1);
|
||||
|
||||
offA();
|
||||
expect(disconnect).not.toHaveBeenCalled();
|
||||
|
||||
offB();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { walkAncestors } from '../walk-ancestors';
|
||||
|
||||
describe('walkAncestors', () => {
|
||||
it('returns undefined for null start', () => {
|
||||
expect(walkAncestors(null, () => 'value')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the first defined callback value', () => {
|
||||
const outer = document.createElement('section');
|
||||
const middle = document.createElement('div');
|
||||
const inner = document.createElement('span');
|
||||
outer.appendChild(middle);
|
||||
middle.appendChild(inner);
|
||||
document.body.appendChild(outer);
|
||||
|
||||
expect(
|
||||
walkAncestors(inner, (node) => {
|
||||
if (node === middle) return 'middle';
|
||||
if (node === outer) return 'outer';
|
||||
return undefined;
|
||||
})
|
||||
).toBe('middle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isUndefined } from '../predicate';
|
||||
|
||||
export function walkAncestors<Value>(
|
||||
start: Element | null,
|
||||
callback: (node: Element) => Value | undefined
|
||||
): Value | undefined {
|
||||
if (!start || typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let node: Element | null = start;
|
||||
while (node) {
|
||||
const value = callback(node);
|
||||
if (!isUndefined(value)) {
|
||||
return value;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { formatPercent } from './percent';
|
||||
@@ -0,0 +1,43 @@
|
||||
import { isNumber } from '../predicate/predicate';
|
||||
|
||||
const formatters = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
function localeCacheKey(locale?: string | string[]): string {
|
||||
if (locale === undefined) return '';
|
||||
return Array.isArray(locale) ? locale.join(':') : locale;
|
||||
}
|
||||
|
||||
function getFormatter(locale?: string | string[]): Intl.NumberFormat | undefined {
|
||||
const key = localeCacheKey(locale);
|
||||
let formatter = formatters.get(key);
|
||||
if (!formatter) {
|
||||
try {
|
||||
formatter = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 0 });
|
||||
formatters.set(key, formatter);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function formatFallback(fraction: number): string {
|
||||
const percent = Math.round(Math.min(1, Math.max(0, fraction)) * 100);
|
||||
return `${percent}%`;
|
||||
}
|
||||
|
||||
/** Format a fraction (0-1) with {@link Intl.NumberFormat} `style: "percent"`. */
|
||||
export function formatPercent(fraction: number, locale?: string | string[]): string {
|
||||
const value = !isNumber(fraction) || !Number.isFinite(fraction) ? 0 : Math.min(1, Math.max(0, fraction));
|
||||
|
||||
try {
|
||||
const formatter = getFormatter(locale) ?? getFormatter(undefined);
|
||||
if (formatter) {
|
||||
return formatter.format(value);
|
||||
}
|
||||
} catch {
|
||||
// fall through to simple percent string
|
||||
}
|
||||
|
||||
return formatFallback(value);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatPercent } from '../percent';
|
||||
|
||||
describe('formatPercent', () => {
|
||||
it('uses Intl percent style', () => {
|
||||
expect(formatPercent(0.75)).toMatch(/75/);
|
||||
expect(formatPercent(0.75)).toMatch(/%/);
|
||||
});
|
||||
|
||||
it('clamps to 0-100%', () => {
|
||||
expect(formatPercent(-1)).toBe(formatPercent(0));
|
||||
expect(formatPercent(2)).toBe(formatPercent(1));
|
||||
});
|
||||
|
||||
it('handles invalid fraction', () => {
|
||||
expect(formatPercent(Number.NaN)).toMatch(/0/);
|
||||
expect(formatPercent(Number.NaN)).toMatch(/%/);
|
||||
});
|
||||
|
||||
it('falls back when locale is invalid', () => {
|
||||
expect(formatPercent(0.75, 'not-a-invalid-bcp47-tag!!!')).toBe('75%');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,55 @@
|
||||
import { isNumber } from '../predicate/predicate';
|
||||
|
||||
const UNIT_LABELS = [
|
||||
{ singular: 'hour', plural: 'hours' },
|
||||
{ singular: 'minute', plural: 'minutes' },
|
||||
{ singular: 'second', plural: 'seconds' },
|
||||
] as const;
|
||||
export type TimeFormatOptions = {
|
||||
/** BCP 47 tag(s) for {@link Intl.DurationFormat}. */
|
||||
locale?: string | string[];
|
||||
/** Called only when `seconds` is negative; formats the localized remaining-time phrase for the duration body. */
|
||||
formatRemaining?: (duration: string) => string;
|
||||
/** Passed to `Intl.DurationFormat`; defaults to `"long"`. */
|
||||
style?: 'long' | 'short' | 'narrow' | 'digital';
|
||||
};
|
||||
|
||||
type DurationRecord = Partial<{ hours: number; minutes: number; seconds: number }>;
|
||||
|
||||
type DurationFormatConstructor = new (
|
||||
locales?: string | string[],
|
||||
options?: { style?: TimeFormatOptions['style']; hoursDisplay?: 'auto' | 'always' }
|
||||
) => { format: (duration: DurationRecord) => string };
|
||||
|
||||
const DurationFormat = (Intl as typeof Intl & { DurationFormat: DurationFormatConstructor }).DurationFormat;
|
||||
|
||||
const durationFormatters = new Map<string, InstanceType<typeof DurationFormat>>();
|
||||
|
||||
function localeCacheKey(locale?: string | string[]): string {
|
||||
if (locale === undefined) return '';
|
||||
return Array.isArray(locale) ? locale.join(':') : locale;
|
||||
}
|
||||
|
||||
function isEnglishLocale(locale?: string | string[]): boolean {
|
||||
const tag = Array.isArray(locale) ? locale[0] : locale;
|
||||
if (!tag) return true;
|
||||
return tag === 'en' || tag.startsWith('en-');
|
||||
}
|
||||
|
||||
function getDurationFormatter(
|
||||
locale?: string | string[],
|
||||
style: NonNullable<TimeFormatOptions['style']> = 'long',
|
||||
hoursDisplay?: 'auto' | 'always'
|
||||
): InstanceType<typeof DurationFormat> {
|
||||
const key = `${localeCacheKey(locale)}:${style}:${hoursDisplay ?? ''}`;
|
||||
let formatter = durationFormatters.get(key);
|
||||
if (!formatter) {
|
||||
const options = hoursDisplay === undefined ? { style } : { style, hoursDisplay };
|
||||
formatter = new DurationFormat(locale, options);
|
||||
durationFormatters.set(key, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
@@ -35,59 +70,26 @@ export function formatTime(seconds: number, guide?: number): string {
|
||||
|
||||
const negative = seconds < 0;
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
const totalSeconds = Math.floor(positiveSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secondsPart = totalSeconds % 60;
|
||||
|
||||
const h = Math.floor(positiveSeconds / 3600);
|
||||
const m = Math.floor((positiveSeconds / 60) % 60);
|
||||
const s = Math.floor(positiveSeconds % 60);
|
||||
const guideSeconds = isValidTime(guide ?? 0) ? Math.abs(guide ?? 0) : 0;
|
||||
const guideHours = Math.floor(guideSeconds / 3600);
|
||||
const guideMinutes = Math.floor((guideSeconds / 60) % 60);
|
||||
|
||||
const guideAbs = guide ? Math.abs(guide) : 0;
|
||||
const gh = Math.floor(guideAbs / 3600);
|
||||
const gm = Math.floor((guideAbs / 60) % 60);
|
||||
const showHours = hours > 0 || guideHours > 0;
|
||||
const padMinutes = showHours || guideMinutes >= 10;
|
||||
|
||||
const showHours = h > 0 || gh > 0;
|
||||
// Add leading zero to minutes if hours showing OR guide minutes >= 10
|
||||
const padMinutes = showHours || gm >= 10;
|
||||
const duration = showHours ? { hours, minutes, seconds: secondsPart } : { minutes, seconds: secondsPart };
|
||||
let body = getDurationFormatter('en', 'digital', showHours ? 'always' : 'auto').format(duration);
|
||||
|
||||
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 '';
|
||||
if (!padMinutes) {
|
||||
body = body.replace(/^0(?=\d:)/, '');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (positiveSeconds === 0) {
|
||||
return `${toTimeUnitPhrase(0, 2)}${negative ? ' remaining' : ''}`;
|
||||
}
|
||||
|
||||
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}`;
|
||||
return `${negative ? '-' : ''}${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,3 +120,38 @@ export function secondsToIsoDuration(seconds: number): string {
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable duration using {@link Intl.DurationFormat}.
|
||||
*
|
||||
* Negative `seconds` denote remaining time: the absolute value is formatted, then wrapped in a
|
||||
* localized phrase via {@link TimeFormatOptions.formatRemaining}; otherwise `{duration} remaining`.
|
||||
*/
|
||||
export function formatTimeAsPhrase(seconds: number, options?: TimeFormatOptions): string {
|
||||
if (!isValidTime(seconds)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const negative = seconds < 0;
|
||||
const positiveSeconds = Math.abs(seconds);
|
||||
const totalSeconds = Math.floor(positiveSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secondsPart = totalSeconds % 60;
|
||||
|
||||
const record: DurationRecord = {};
|
||||
if (hours > 0) record.hours = hours;
|
||||
if (minutes > 0) record.minutes = minutes;
|
||||
if (secondsPart > 0 || (hours === 0 && minutes === 0)) record.seconds = secondsPart;
|
||||
|
||||
const body = getDurationFormatter(options?.locale, options?.style ?? 'long').format(record);
|
||||
|
||||
if (negative) {
|
||||
const formatRemaining = options?.formatRemaining;
|
||||
if (formatRemaining) return formatRemaining(body);
|
||||
if (isEnglishLocale(options?.locale)) return `${body} remaining`;
|
||||
return body;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -57,46 +57,47 @@ describe('formatTime', () => {
|
||||
});
|
||||
|
||||
describe('formatTimeAsPhrase', () => {
|
||||
it('formats zero seconds', () => {
|
||||
expect(formatTimeAsPhrase(0)).toBe('0 seconds');
|
||||
it('formats positive duration', () => {
|
||||
expect(formatTimeAsPhrase(90)).toContain('1');
|
||||
expect(formatTimeAsPhrase(90)).toMatch(/minute/i);
|
||||
expect(formatTimeAsPhrase(90)).toMatch(/30/);
|
||||
expect(formatTimeAsPhrase(300)).toMatch(/5/);
|
||||
expect(formatTimeAsPhrase(300)).toMatch(/minute/i);
|
||||
});
|
||||
|
||||
it('formats seconds only', () => {
|
||||
expect(formatTimeAsPhrase(1)).toBe('1 second');
|
||||
expect(formatTimeAsPhrase(30)).toBe('30 seconds');
|
||||
it('adds remaining suffix for negative seconds', () => {
|
||||
expect(formatTimeAsPhrase(-30)).toMatch(/30/);
|
||||
expect(formatTimeAsPhrase(-30)).toMatch(/remaining$/i);
|
||||
});
|
||||
|
||||
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('uses formatRemaining only for negative durations', () => {
|
||||
expect(formatTimeAsPhrase(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/^quedan /);
|
||||
expect(formatTimeAsPhrase(-30, { formatRemaining: (duration) => `quedan ${duration}` })).toMatch(/30/);
|
||||
expect(formatTimeAsPhrase(90, { formatRemaining: () => 'should-not-appear' })).toBe(formatTimeAsPhrase(90));
|
||||
});
|
||||
|
||||
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('omits English remaining suffix for non-English locales without formatRemaining', () => {
|
||||
const formatted = formatTimeAsPhrase(-30, { locale: 'es' });
|
||||
expect(formatted).toMatch(/30/);
|
||||
expect(formatted).not.toMatch(/remaining$/i);
|
||||
});
|
||||
|
||||
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('uses Intl.DurationFormat', () => {
|
||||
const en = formatTimeAsPhrase(125, { locale: 'en' });
|
||||
const de = formatTimeAsPhrase(125, { locale: 'de' });
|
||||
expect(en.length).toBeGreaterThan(0);
|
||||
expect(de.length).toBeGreaterThan(0);
|
||||
expect(en).not.toBe(de);
|
||||
});
|
||||
|
||||
it('handles invalid values', () => {
|
||||
expect(formatTimeAsPhrase(NaN)).toBe('');
|
||||
expect(formatTimeAsPhrase(Infinity)).toBe('');
|
||||
});
|
||||
|
||||
it('throws when Intl.DurationFormat rejects the locale', () => {
|
||||
expect(() => formatTimeAsPhrase(90, { locale: 'not-a-valid-bcp47-tag!!!' })).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondsToIsoDuration', () => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (x: infer I) => void ? I : never;
|
||||
|
||||
/** Matches strings that include the literal substring `Needle` (for example a `{param}` token). */
|
||||
export type Contains<Needle extends string> = `${string}${Needle}${string}`;
|
||||
|
||||
export type EnsureRecord<Keys extends PropertyKey, Value, Target extends Record<Keys, Value>> = Target;
|
||||
|
||||
export type Constructor<T, Arguments extends unknown[] = any[]> = new (...args: Arguments) => T;
|
||||
|
||||
export type AbstractConstructor<T, Arguments extends unknown[] = any[]> = abstract new (...args: Arguments) => T;
|
||||
|
||||
Reference in New Issue
Block a user