From 61aea4356a36dac113b284a82ccef4b75f5ed966 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 20:22:12 +0000 Subject: [PATCH] Use Intl.ListFormat for formatList and locale for duration labels Replace hand-rolled English list joining with Intl.ListFormat (default en-GB to preserve no-Oxford-comma output), keep custom limit/"N more" truncation, and thread a locale option through formatDurationLabels. Co-authored-by: Jeremy Butler --- docs/pages/formatters.md | 12 ++++++--- nuxt-web/pages/docs/formatters.vue | 4 +-- src/formatters.test.ts | 2 ++ src/formatters.ts | 40 ++++++++++++++++++++++-------- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/docs/pages/formatters.md b/docs/pages/formatters.md index 4eeef5a..f29befe 100644 --- a/docs/pages/formatters.md +++ b/docs/pages/formatters.md @@ -155,7 +155,7 @@ formatPercentage(0.1234, { decimals: 1 }) // '12.3%' --- -### `formatDurationLabels(seconds: number, options?: { display?: 'short' | 'long', round?: boolean, decimals?: number }): string` +### `formatDurationLabels(seconds: number, options?: { display?: 'short' | 'long', round?: boolean, decimals?: number, locale?: string }): string` Format time into a human-readable string. @@ -166,6 +166,7 @@ Format time into a human-readable string. - `unitDisplay` / `labels` ('short' | 'long', optional): Aliases for `display` - `round` (boolean, optional): Round to largest unit - `decimals` (number, optional): Decimal places for rounding + - `locale` (string, optional): BCP 47 locale for unit labels. Defaults to `'en-US'` **Returns:** Human-readable duration string @@ -176,6 +177,7 @@ import { formatDurationLabels } from 'usemods' formatDurationLabels(3661) // '1 hour 1 minute 1 second' formatDurationLabels(3661, { display: 'short' }) // '1 hr 1 min 1 sec' formatDurationLabels(3661, { round: true }) // '1 hour' +formatDurationLabels(3661, { locale: 'de-DE' }) // '1 Stunde 1 Minute 1 Sekunde' ``` --- @@ -362,15 +364,16 @@ formatUnixTime(1609459200000) // '2021-01-01 00:00:00' --- -### `formatList(items: string | object | string[], options?: { limit?: number, conjunction?: string }): string` +### `formatList(items: string | object | string[], options?: { limit?: number, conjunction?: string, locale?: string }): string` -Create a string of comma-separated values from an array, object, or string with an optional limit and conjunction. +Create a string of comma-separated values from an array, object, or string with an optional limit and conjunction. Full lists use `Intl.ListFormat`; truncation (`limit` / "N more") stays custom. **Parameters:** - `items` (string | object | string[]): Items to format - `options` (object, optional): - `limit` (number, optional): Maximum items to show - - `conjunction` (string, optional): Conjunction word. Defaults to `'and'` + - `conjunction` (string, optional): Conjunction word. Defaults to `'and'` (`'or'` uses ListFormat disjunction) + - `locale` (string, optional): BCP 47 locale for ListFormat. Defaults to `'en-GB'` (no Oxford comma) **Returns:** Formatted list string @@ -381,6 +384,7 @@ import { formatList } from 'usemods' formatList(['apple', 'banana', 'cherry']) // 'apple, banana and cherry' formatList(['apple', 'banana', 'cherry'], { limit: 2 }) // 'apple, banana and 1 more' formatList('apple, banana, cherry') // 'apple, banana and cherry' +formatList(['apple', 'banana', 'cherry'], { locale: 'en-US' }) // 'apple, banana, and cherry' ``` --- diff --git a/nuxt-web/pages/docs/formatters.vue b/nuxt-web/pages/docs/formatters.vue index bbbc9f6..2e13ccf 100644 --- a/nuxt-web/pages/docs/formatters.vue +++ b/nuxt-web/pages/docs/formatters.vue @@ -56,7 +56,7 @@ @@ -129,7 +129,7 @@ diff --git a/src/formatters.test.ts b/src/formatters.test.ts index a771e88..22f285a 100644 --- a/src/formatters.test.ts +++ b/src/formatters.test.ts @@ -67,6 +67,7 @@ test('formatDurationLabels', () => { expect(mod.formatDurationLabels(3600 * 400 + 60 + 1)).toBe('16 days 16 hours 1 minute 1 second') expect(mod.formatDurationLabels(3600 * 400 + 60 + 1, { round: true })).toBe('16.7 days') expect(mod.formatDurationLabels(9241233, { round: true })).toBe('107 days') + expect(mod.formatDurationLabels(3661, { locale: 'de-DE' })).toBe('1 Stunde 1 Minute 1 Sekunde') }) test('formatDurationNumbers', () => { @@ -111,6 +112,7 @@ test('formatList', () => { expect(mod.formatList(['Apple', 'Oranges', 'Bananas', 'Pears', 'Grapes'], { limit: 2 })).toBe('Apple, Oranges and 3 more') expect(mod.formatList(['Apple', 'Oranges', 'Bananas', 'Pears', 'Grapes'], { limit: 2, conjunction: 'or' })).toBe('Apple, Oranges or 3 more') expect(mod.formatList([], { limit: 2, conjunction: 'or' })).toBe('') + expect(mod.formatList(['Apple', 'Oranges', 'Bananas'], { locale: 'en-US' })).toBe('Apple, Oranges, and Bananas') }) test('formatTitle', () => { diff --git a/src/formatters.ts b/src/formatters.ts index a86163c..cfae644 100644 --- a/src/formatters.ts +++ b/src/formatters.ts @@ -190,11 +190,13 @@ export function formatDurationLabels( labels?: DisplayLength round?: boolean decimals?: number + locale?: string }, ): string { const display = resolveDisplay(options, 'long') + const locale = options?.locale ?? 'en-US' - if (seconds <= 0) return formatUnit(0, { unit: 'second', decimals: 0, display }) + if (seconds <= 0) return formatUnit(0, { unit: 'second', decimals: 0, display, locale }) const units = [ { unit: 'year', value: 31536000 }, @@ -211,7 +213,7 @@ export function formatDurationLabels( const unitValue = seconds / value const hasDecimal = unitValue % 1 !== 0 const decimals = hasDecimal && unitValue.toFixed(1).endsWith('.0') ? 0 : hasDecimal ? 1 : 0 - return formatUnit(unitValue, { unit, decimals, display }) + return formatUnit(unitValue, { unit, decimals, display, locale }) } } } @@ -221,13 +223,13 @@ export function formatDurationLabels( for (const { unit, value } of units) { const unitValue = Math.floor(seconds / value) if (unitValue > 0) { - results.push(formatUnit(unitValue, { unit, decimals: 0, display })) + results.push(formatUnit(unitValue, { unit, decimals: 0, display, locale })) seconds %= value } } const milliseconds = Math.floor((seconds % 1) * 1000) - if (milliseconds > 0) results.push(formatUnit(milliseconds, { unit: 'millisecond', decimals: 0, display })) + if (milliseconds > 0) results.push(formatUnit(milliseconds, { unit: 'millisecond', decimals: 0, display, locale })) return results.join(' ') } @@ -459,27 +461,43 @@ export function formatUnixTime(timestamp?: number): string { } /** - * Create a string of comma-separated values from an array, object, or string with an optional limit and conjunction + * Create a string of comma-separated values from an array, object, or string with an optional limit and conjunction. + * Uses Intl.ListFormat for locale-aware joining; truncation (`limit` / "N more") stays custom. + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat */ export function formatList( items: string | object | string[], options?: { limit?: number conjunction?: string + locale?: string }, ): string { if (typeof items === 'string') items = items.split(',').map(item => item.trim()) if (typeof items === 'object' && !Array.isArray(items)) items = Object.values(items) if (!Array.isArray(items) || items.length === 0) return '' + + // Default en-GB: no Oxford comma, matching historical English output. + const locale = options?.locale ?? 'en-GB' const conj = options?.conjunction ?? 'and' - if (items.length <= 2) return items.join(items.length === 2 ? ` ${conj} ` : '') + const list = items.map(String) + const effectiveLimit = options?.limit ?? list.length + + // ListFormat has no "N more" equivalent — keep truncation English-literal. + if (list.length > effectiveLimit) { + const listedItems = list.slice(0, effectiveLimit).join(', ') + const remaining = list.length - effectiveLimit + return `${listedItems} ${conj} ${remaining} more` + } - const effectiveLimit = options?.limit ?? items.length - if (items.length <= effectiveLimit) return `${items.slice(0, -1).join(', ')} ${conj} ${items.at(-1)}` + // Custom conjunctions beyond and/or cannot go through ListFormat type. + if (conj !== 'and' && conj !== 'or') { + if (list.length <= 2) return list.join(list.length === 2 ? ` ${conj} ` : '') + return `${list.slice(0, -1).join(', ')} ${conj} ${list.at(-1)}` + } - const listedItems = items.slice(0, effectiveLimit).join(', ') - const remaining = items.length - effectiveLimit - return `${listedItems} ${conj} ${remaining} more` + const type: Intl.ListFormatType = conj === 'or' ? 'disjunction' : 'conjunction' + return new Intl.ListFormat(locale, { type, style: 'long' }).format(list) } /**