feat(vue): expand @robonen/vue composable collection

Composables, tests, category barrels, and README for @robonen/vue.
This commit is contained in:
2026-06-08 15:51:16 +07:00
parent 9a912f7a77
commit 59e995d0b5
369 changed files with 36554 additions and 188 deletions
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { effectScope, ref } from 'vue';
import { formatDate, normalizeDate, useDateFormat } from '.';
// A fixed local date: 2024-03-09 18:07:05.042 (a Saturday).
function fixture(): Date {
return new Date(2024, 2, 9, 18, 7, 5, 42);
}
describe(useDateFormat, () => {
it('defaults to HH:mm:ss', () => {
const formatted = useDateFormat(fixture());
expect(formatted.value).toBe('18:07:05');
});
it('formats year/month/day tokens', () => {
const date = fixture();
expect(useDateFormat(date, 'YYYY-MM-DD').value).toBe('2024-03-09');
expect(useDateFormat(date, 'YY/M/D').value).toBe('24/3/9');
});
it('formats time tokens including milliseconds and 12-hour', () => {
const date = fixture();
expect(useDateFormat(date, 'HH:mm:ss.SSS').value).toBe('18:07:05.042');
expect(useDateFormat(date, 'h:mm').value).toBe('6:07');
expect(useDateFormat(date, 'hh').value).toBe('06');
});
it('handles 12 -> 12 and 0 -> 12 for the h token', () => {
expect(useDateFormat(new Date(2024, 0, 1, 0, 0, 0), 'h A').value).toBe('12 AM');
expect(useDateFormat(new Date(2024, 0, 1, 12, 0, 0), 'h A').value).toBe('12 PM');
});
it('formats the meridiem variants', () => {
const pm = fixture();
expect(useDateFormat(pm, 'A').value).toBe('PM');
expect(useDateFormat(pm, 'AA').value).toBe('P.M.');
expect(useDateFormat(pm, 'a').value).toBe('pm');
expect(useDateFormat(pm, 'aa').value).toBe('p.m.');
const am = new Date(2024, 0, 1, 6, 0, 0);
expect(useDateFormat(am, 'A').value).toBe('AM');
});
it('formats ordinal tokens', () => {
const date = new Date(2024, 0, 1, 3, 0, 0); // Jan 1st, 3 o'clock
expect(useDateFormat(date, 'Do').value).toBe('1st');
expect(useDateFormat(date, 'Mo').value).toBe('1st');
expect(useDateFormat(new Date(2024, 1, 22), 'Do').value).toBe('22nd');
expect(useDateFormat(new Date(2024, 1, 23), 'Do').value).toBe('23rd');
expect(useDateFormat(new Date(2024, 1, 11), 'Do').value).toBe('11th');
});
it('formats localized weekday and month with the locales option', () => {
const date = fixture(); // a Saturday in March
expect(useDateFormat(date, 'dddd', { locales: 'en-US' }).value).toBe('Saturday');
expect(useDateFormat(date, 'ddd', { locales: 'en-US' }).value).toBe('Sat');
expect(useDateFormat(date, 'MMMM', { locales: 'en-US' }).value).toBe('March');
expect(useDateFormat(date, 'MMM', { locales: 'en-US' }).value).toBe('Mar');
expect(useDateFormat(date, 'd', { locales: 'en-US' }).value).toBe('6'); // Saturday
});
it('uses a custom meridiem function', () => {
const date = fixture();
const formatted = useDateFormat(date, 'h:mm a', {
customMeridiem: hours => (hours < 12 ? 'morning' : 'evening'),
});
expect(formatted.value).toBe('6:07 evening');
});
it('emits [literal] escapes verbatim', () => {
const date = fixture();
expect(useDateFormat(date, '[Year:] YYYY').value).toBe('Year: 2024');
expect(useDateFormat(date, '[YYYY] YYYY').value).toBe('YYYY 2024');
});
it('is reactive to the date, format, and locale', () => {
const date = ref<Date>(fixture());
const format = ref('YYYY');
const locale = ref('en-US');
const formatted = useDateFormat(date, format, { locales: locale });
expect(formatted.value).toBe('2024');
date.value = new Date(2025, 0, 1);
expect(formatted.value).toBe('2025');
format.value = 'MMMM';
expect(formatted.value).toBe('January');
locale.value = 'fr-FR';
expect(formatted.value).toBe('janvier');
});
it('accepts a numeric timestamp and a getter', () => {
const date = fixture();
expect(useDateFormat(date.getTime(), 'YYYY-MM-DD').value).toBe('2024-03-09');
expect(useDateFormat(() => date, 'YYYY').value).toBe('2024');
});
it('parses loose date strings without a trailing Z', () => {
expect(useDateFormat('2024-03-09', 'YYYY-MM-DD').value).toBe('2024-03-09');
expect(useDateFormat('2024-3', 'YYYY-MM-DD').value).toBe('2024-03-01');
expect(useDateFormat('2024-03-09 18:07:05', 'HH:mm:ss').value).toBe('18:07:05');
});
it('handles null/undefined by resolving to now without throwing', () => {
expect(useDateFormat(undefined, 'YYYY').value).toMatch(/^\d{4}$/);
expect(useDateFormat(null, 'YYYY').value).toMatch(/^\d{4}$/);
});
it('returns "Invalid Date" for unparseable input instead of NaN tokens', () => {
expect(useDateFormat('not a date', 'YYYY-MM-DD').value).toBe('Invalid Date');
expect(useDateFormat(Number.NaN, 'HH:mm:ss').value).toBe('Invalid Date');
});
it('constructs inside an effect scope without throwing (SSR-safe, no global access)', () => {
const scope = effectScope();
let formatted: ReturnType<typeof useDateFormat> | undefined;
scope.run(() => {
formatted = useDateFormat(fixture(), 'YYYY-MM-DD');
});
expect(formatted?.value).toBe('2024-03-09');
scope.stop();
});
});
describe(formatDate, () => {
it('formats a date one-shot', () => {
expect(formatDate(fixture(), 'YYYY/MM/DD')).toBe('2024/03/09');
});
it('returns "Invalid Date" for an invalid date', () => {
expect(formatDate(new Date(Number.NaN), 'YYYY')).toBe('Invalid Date');
});
});
describe(normalizeDate, () => {
it('returns a fresh Date for a Date input', () => {
const date = fixture();
const normalized = normalizeDate(date);
expect(normalized).not.toBe(date);
expect(normalized.getTime()).toBe(date.getTime());
});
it('resolves null/undefined to a valid current Date', () => {
expect(Number.isNaN(normalizeDate(undefined).getTime())).toBeFalsy();
expect(Number.isNaN(normalizeDate(null).getTime())).toBeFalsy();
});
it('parses a numeric timestamp', () => {
const date = fixture();
expect(normalizeDate(date.getTime()).getTime()).toBe(date.getTime());
});
});
@@ -0,0 +1,217 @@
import { computed, toValue } from 'vue';
import type { ComputedRef, MaybeRefOrGetter } from 'vue';
import { isDate, isString } from '@robonen/stdlib';
/**
* Accepted input for {@link useDateFormat}: a `Date`, a millisecond timestamp,
* a parseable date string, or `null`/`undefined` (resolves to "now").
*/
export type DateLike = Date | number | string | null | undefined;
/**
* Signature for a custom meridiem (AM/PM) formatter.
*
* @param hours The hour of the day, 0-23
* @param minutes The minute of the hour, 0-59
* @param isLowercase Whether the token requested a lowercase form (`a`/`aa`)
* @param hasPeriod Whether the token requested period separators (`AA`/`aa`)
*/
export type CustomMeridiem
= (hours: number, minutes: number, isLowercase?: boolean, hasPeriod?: boolean) => string;
export interface UseDateFormatOptions {
/**
* The locale(s) used for the `dd`/`ddd`/`dddd`/`MMM`/`MMMM`/`z` tokens.
*
* Accepts a reactive value (ref or getter); the output recomputes when it
* changes.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl#locales_argument
*/
locales?: MaybeRefOrGetter<Intl.LocalesArgument>;
/**
* A custom function controlling how the meridiem (`A`/`AA`/`a`/`aa`) is
* rendered.
*/
customMeridiem?: CustomMeridiem;
}
/**
* Reactive formatted date string.
*/
export type UseDateFormatReturn = ComputedRef<string>;
// Matches a token, or a `[literal]` escape that is emitted verbatim.
const REGEX_FORMAT
= /* #__PURE__ */ /[YMDHhms]o|\[([^\]]+)\]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a{1,2}|A{1,2}|m{1,2}|s{1,2}|z{1,4}|SSS/g;
// Loose ISO-ish parser used for date strings without a trailing `Z`. The optional
// separators make adjacent digit groups technically "misleading" to the linter,
// but this is the deliberate lenient dayjs parser (accepts `2024-01-01` and
// `20240101`); JS lacks possessive quantifiers to disambiguate it.
// eslint-disable-next-line regexp/no-misleading-capturing-group
const REGEX_PARSE = /* #__PURE__ */ /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[T\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/i;
const ORDINAL_SUFFIXES = ['th', 'st', 'nd', 'rd'] as const;
function defaultMeridiem(
hours: number,
_minutes: number,
isLowercase?: boolean,
hasPeriod?: boolean,
): string {
let m = hours < 12 ? 'AM' : 'PM';
if (hasPeriod) m = `${m[0]}.${m[1]}.`;
return isLowercase ? m.toLowerCase() : m;
}
function formatOrdinal(num: number): string {
const v = num % 100;
return num + (ORDINAL_SUFFIXES[(v - 20) % 10] || ORDINAL_SUFFIXES[v] || ORDINAL_SUFFIXES[0]);
}
/**
* Coerce a {@link DateLike} into a `Date`. `null`/`undefined` become the
* current time; a non-UTC string is parsed leniently so partial dates such as
* `'2024-3'` are accepted.
*
* @param date The value to coerce
* @returns A `Date` instance (possibly `Invalid Date`)
*/
export function normalizeDate(date: DateLike): Date {
if (date === null || date === undefined) return new Date();
if (isDate(date)) return new Date(date.getTime());
if (isString(date) && !/z$/i.test(date)) {
const d = REGEX_PARSE.exec(date);
if (d) {
const month = d[2] ? Number(d[2]) - 1 : 0;
const ms = (d[7] || '0').slice(0, 3);
return new Date(
Number(d[1]),
month,
Number(d[3]) || 1,
Number(d[4]) || 0,
Number(d[5]) || 0,
Number(d[6]) || 0,
Number(ms),
);
}
}
return new Date(date);
}
/**
* Format a `Date` against a token string. Exposed for one-shot, non-reactive
* formatting; {@link useDateFormat} wraps this in a `computed`.
*
* @param date The date to format
* @param formatStr The combination of tokens (e.g. `'YYYY-MM-DD HH:mm:ss'`)
* @param options Locale and meridiem options
* @returns The formatted string
*/
export function formatDate(
date: Date,
formatStr: string,
options: UseDateFormatOptions = {},
): string {
// Invalid dates round-trip to the literal "Invalid Date" rather than
// emitting `NaN` for every numeric token.
if (Number.isNaN(date.getTime())) return 'Invalid Date';
const years = date.getFullYear();
const month = date.getMonth();
const days = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const milliseconds = date.getMilliseconds();
const day = date.getDay();
const hour12 = hours % 12 || 12;
const locales = toValue(options.locales);
const meridiem = options.customMeridiem ?? defaultMeridiem;
// The timeZoneName lands after the date in the localized string; grab it.
const offsetName = (style: 'shortOffset' | 'longOffset'): string =>
date.toLocaleDateString(locales, { timeZoneName: style }).split(' ')[1] ?? '';
const matches: Record<string, () => string | number> = {
Yo: () => formatOrdinal(years),
YY: () => String(years).slice(-2),
YYYY: () => years,
M: () => month + 1,
Mo: () => formatOrdinal(month + 1),
MM: () => String(month + 1).padStart(2, '0'),
MMM: () => date.toLocaleDateString(locales, { month: 'short' }),
MMMM: () => date.toLocaleDateString(locales, { month: 'long' }),
D: () => String(days),
Do: () => formatOrdinal(days),
DD: () => String(days).padStart(2, '0'),
H: () => String(hours),
Ho: () => formatOrdinal(hours),
HH: () => String(hours).padStart(2, '0'),
h: () => String(hour12),
ho: () => formatOrdinal(hour12),
hh: () => String(hour12).padStart(2, '0'),
m: () => String(minutes),
mo: () => formatOrdinal(minutes),
mm: () => String(minutes).padStart(2, '0'),
s: () => String(seconds),
so: () => formatOrdinal(seconds),
ss: () => String(seconds).padStart(2, '0'),
SSS: () => String(milliseconds).padStart(3, '0'),
d: () => day,
dd: () => date.toLocaleDateString(locales, { weekday: 'narrow' }),
ddd: () => date.toLocaleDateString(locales, { weekday: 'short' }),
dddd: () => date.toLocaleDateString(locales, { weekday: 'long' }),
A: () => meridiem(hours, minutes),
AA: () => meridiem(hours, minutes, false, true),
a: () => meridiem(hours, minutes, true),
aa: () => meridiem(hours, minutes, true, true),
z: () => offsetName('shortOffset'),
zz: () => offsetName('shortOffset'),
zzz: () => offsetName('shortOffset'),
zzzz: () => offsetName('longOffset'),
};
return formatStr.replaceAll(REGEX_FORMAT, (match, literal) =>
literal ?? String(matches[match]?.() ?? match),
);
}
/**
* @name useDateFormat
* @category Animation
* @description Reactively format a `Date`, timestamp, or date string against a
* token string (`YYYY MM DD HH mm ss SSS dddd A` etc.). Recomputes when the
* date, format, or locale changes.
*
* @param {MaybeRefOrGetter<DateLike>} date The date to format
* @param {MaybeRefOrGetter<string>} [formatStr='HH:mm:ss'] The token string
* @param {UseDateFormatOptions} [options={}] Locale and meridiem options
* @returns {ComputedRef<string>} The reactive formatted string
*
* @example
* const formatted = useDateFormat(useNow(), 'YYYY-MM-DD HH:mm:ss');
*
* @example
* // Localized weekday + month, reactive locale
* const locale = ref('fr-FR');
* const label = useDateFormat(date, 'dddd, MMMM D', { locales: locale });
*
* @example
* // Custom meridiem
* const t = useDateFormat(date, 'hh:mm a', {
* customMeridiem: (h) => (h < 12 ? 'morning' : 'evening'),
* });
*
* @since 0.0.15
*/
export function useDateFormat(
date: MaybeRefOrGetter<DateLike>,
formatStr: MaybeRefOrGetter<string> = 'HH:mm:ss',
options: UseDateFormatOptions = {},
): UseDateFormatReturn {
return computed(() => formatDate(normalizeDate(toValue(date)), toValue(formatStr), options));
}