-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for formatting relative times (#286)
- Loading branch information
1 parent
8c2eefd
commit 4cd3fbe
Showing
11 changed files
with
479 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
--- | ||
'@sumup-oss/intl': minor | ||
--- | ||
|
||
Added support for formatting relative times. | ||
|
||
```ts | ||
import { formatRelativeTime } from '@sumup-oss/intl'; | ||
|
||
formatRelativeTime(7, 'years', 'pt-BR'); // 'em 7 anos' | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
/** | ||
* Copyright 2024, SumUp Ltd. | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import type { Locale } from '../../types/index.js'; | ||
import { formatNumber, formatNumberToParts } from '../number-format/index.js'; | ||
|
||
import { | ||
getRelativeTimeFormat, | ||
isRelativeTimeFormatSupported, | ||
} from './intl.js'; | ||
|
||
export { isRelativeTimeFormatSupported }; | ||
|
||
/** | ||
* Formats a relative time with support for various [styles](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#style). | ||
* | ||
* @example | ||
* import { formatRelativeTime } from '@sumup-oss/intl'; | ||
* | ||
* formatRelativeTime(1, 'day', 'de-DE'); // 'in 1 Tag' | ||
* formatRelativeTime(7, 'years', ['pt-BR', 'pt']); // 'em 7 anos' | ||
* formatRelativeTime(-5, 'months', 'en-GB', { | ||
* style: 'narrow', | ||
* }); // '5 mo ago' | ||
* | ||
* @remarks | ||
* In runtimes that don't support the `Intl.RelativeTimeFormat` API, | ||
* the relative time is formatted using the `Intl.NumberFormat` API instead. | ||
* | ||
* @category Date & Time | ||
*/ | ||
export const formatRelativeTime = formatRelativeTimeFactory(); | ||
|
||
function formatRelativeTimeFactory(): ( | ||
value: number, | ||
unit: Intl.RelativeTimeFormatUnit, | ||
locales?: Locale | Locale[], | ||
options?: Intl.RelativeTimeFormatOptions, | ||
) => string { | ||
if (!isRelativeTimeFormatSupported) { | ||
return (value, unit, locales, options) => { | ||
const numberFormat = convertToNumberFormat(value, unit, options); | ||
return formatNumber(numberFormat.value, locales, numberFormat.options); | ||
}; | ||
} | ||
|
||
return (value, unit, locales, options) => { | ||
const relativeTimeFormat = getRelativeTimeFormat(locales, options); | ||
return relativeTimeFormat.format(value, unit); | ||
}; | ||
} | ||
|
||
/** | ||
* Formats a relative time to parts with support for various [styles](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#style). | ||
* | ||
* @example | ||
* import { formatRelativeTimeToParts } from '@sumup-oss/intl'; | ||
* | ||
* formatRelativeTimeToParts(1, 'day', 'de-DE'); | ||
* // [ | ||
* // { "type": "literal", "value": "in " }, | ||
* // { "type": "integer", "unit": "day", "value": "1" }, | ||
* // { "type": "literal", "value": " Tag" } | ||
* // ] | ||
* formatRelativeTimeToParts(7, 'years', ['pt-BR', 'pt']); | ||
* // [ | ||
* // { "type": "literal", "value": "em " }, | ||
* // { "type": "integer", "unit": "year", "value": "7" }, | ||
* // { "type": "literal", "value": " anos" } | ||
* // ] | ||
* formatRelativeTimeToParts(-5, 'months', 'en-GB', { | ||
* style: 'narrow', | ||
* }); | ||
* // [ | ||
* // { "type": "integer", "unit": "month", "value": "5" }, | ||
* // { "type": "literal", "value": " mo ago" } | ||
* // ] | ||
* | ||
* @remarks | ||
* In runtimes that don't support the `Intl.RelativeTimeFormat` API, | ||
* the relative time is formatted using the `Intl.NumberFormat` API instead. | ||
* | ||
* @category Date & Time | ||
*/ | ||
export const formatRelativeTimeToParts = formatRelativeTimeToPartsFactory(); | ||
|
||
function formatRelativeTimeToPartsFactory(): ( | ||
value: number, | ||
unit: Intl.RelativeTimeFormatUnit, | ||
locales?: Locale | Locale[], | ||
options?: Intl.RelativeTimeFormatOptions, | ||
) => Intl.RelativeTimeFormatPart[] { | ||
if (!isRelativeTimeFormatSupported) { | ||
return (value, unit, locales, options) => { | ||
// In runtimes that don't support formatting to parts, the relative time | ||
// is formatted using the `Intl.NumberFormat` API instead. | ||
const numberFormat = convertToNumberFormat(value, unit, options); | ||
const numberFormatParts = formatNumberToParts( | ||
numberFormat.value, | ||
locales, | ||
numberFormat.options, | ||
); | ||
|
||
// Smooth over the subtle differences between NumberFormatPart and RelativeTimeFormatPart | ||
return numberFormatParts.map((part) => { | ||
if (part.type === 'integer') { | ||
return { ...part, unit: numberFormat.options.unit }; | ||
} | ||
if (part.type === 'unit') { | ||
return { ...part, type: 'literal' }; | ||
} | ||
return part; | ||
}) as Intl.RelativeTimeFormatPart[]; | ||
}; | ||
} | ||
|
||
return (value, unit, locales, options) => { | ||
const relativeTimeFormat = getRelativeTimeFormat(locales, options); | ||
return relativeTimeFormat.formatToParts(value, unit); | ||
}; | ||
} | ||
|
||
/** | ||
* Resolves the locale and collation options that are used to format a relative time. | ||
* | ||
* @example | ||
* import { resolveRelativeTimeFormat } from '@sumup-oss/intl'; | ||
* | ||
* resolveRelativeTimeFormat('de-DE'); | ||
* // { | ||
* // "locale": "de-DE", | ||
* // "numberingSystem": "latn", | ||
* // "numeric": "always", | ||
* // "style": "long", | ||
* // } | ||
* resolveRelativeTimeFormat(['pt-BR', 'pt']); | ||
* // { | ||
* // "locale": "pt-BR", | ||
* // "numberingSystem": "latn", | ||
* // "numeric": "always", | ||
* // "style": "long", | ||
* // } | ||
* resolveRelativeTimeFormat('en-GB', { | ||
* style: 'narrow', | ||
* }); | ||
* // { | ||
* // "locale": "en-GB", | ||
* // "numberingSystem": "latn", | ||
* // "numeric": "always", | ||
* // "style": "narrow", | ||
* // } | ||
* | ||
* @remarks | ||
* In runtimes that don't support the `Intl.RelativeTimeFormat.resolvedOptions` API, | ||
* `null` is returned. | ||
* | ||
* @category Date & Time | ||
*/ | ||
export const resolveRelativeTimeFormat = resolveRelativeTimeFormatFactory(); | ||
|
||
function resolveRelativeTimeFormatFactory(): ( | ||
locales?: Locale | Locale[], | ||
options?: Intl.RelativeTimeFormatOptions, | ||
) => null | Intl.ResolvedRelativeTimeFormatOptions { | ||
if (!isRelativeTimeFormatSupported) { | ||
return () => null; | ||
} | ||
|
||
return (locales, options) => { | ||
const relativeTimeFormat = getRelativeTimeFormat(locales, options); | ||
return relativeTimeFormat.resolvedOptions(); | ||
}; | ||
} | ||
|
||
function convertToNumberFormat( | ||
value: number, | ||
unit: Intl.RelativeTimeFormatUnit, | ||
options: Intl.RelativeTimeFormatOptions | undefined, | ||
): { value: number; options: Intl.NumberFormatOptions } { | ||
const style = 'unit'; | ||
const unitDisplay = options?.style || 'long'; | ||
|
||
// Intl.NumberFormat doesn't support the 'quarter' unit | ||
if (unit.startsWith('quarter')) { | ||
return { value: value * 3, options: { unit: 'month', style, unitDisplay } }; | ||
} | ||
|
||
const numberFormatUnits = [ | ||
'year', | ||
'month', | ||
'week', | ||
'day', | ||
'hour', | ||
'minute', | ||
'second', | ||
]; | ||
// Intl.NumberFormat only supports singular unit names | ||
const singularUnit = numberFormatUnits.find((supportedUnit) => | ||
unit.startsWith(supportedUnit), | ||
); | ||
return { value, options: { unit: singularUnit, style, unitDisplay } }; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
/** | ||
* Copyright 2024, SumUp Ltd. | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import type { Locale } from '../../types/index.js'; | ||
import { memoize } from '../memoize.js'; | ||
|
||
/** | ||
* Whether the `Intl` and `Intl.RelativeTimeFormat` APIs | ||
* are supported by the runtime. | ||
*/ | ||
export const isRelativeTimeFormatSupported = (() => { | ||
try { | ||
return ( | ||
typeof Intl !== 'undefined' && | ||
typeof Intl.RelativeTimeFormat !== 'undefined' | ||
); | ||
} catch (error) { | ||
return false; | ||
} | ||
})(); | ||
|
||
export const getRelativeTimeFormat = memoize(Intl.RelativeTimeFormat) as ( | ||
locales?: Locale | Locale[], | ||
options?: Intl.RelativeTimeFormatOptions, | ||
) => Intl.RelativeTimeFormat; |
9 changes: 9 additions & 0 deletions
9
src/lib/relative-time-format/tests/__snapshots__/unsupported-intl-api.spec.ts.snap
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html | ||
|
||
exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format -5 months as a relative time 1`] = `"-5 months"`; | ||
|
||
exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format 1 day as a relative time 1`] = `"1 day"`; | ||
|
||
exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format 3 quarters as a relative time 1`] = `"9 months"`; | ||
|
||
exports[`Relative times > when Intl.RelativeTimeFormat is unsupported > should format 7 years as a relative time 1`] = `"7 years"`; |
43 changes: 43 additions & 0 deletions
43
src/lib/relative-time-format/tests/format-to-parts.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
/** | ||
* Copyright 2020, SumUp Ltd. | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import { describe, it, expect } from 'vitest'; | ||
import type { SpyImpl } from 'tinyspy'; | ||
|
||
import { formatRelativeTimeToParts } from '../index.js'; | ||
|
||
import { relativeTimes, locales } from './shared.js'; | ||
|
||
const spy = Intl.RelativeTimeFormat as unknown as SpyImpl; | ||
|
||
describe('Relative times', () => { | ||
describe('formatRelativeTimeToParts', () => { | ||
it.each(locales)('should format a date for %o', (locale) => { | ||
const index = locales.indexOf(locale); | ||
const [value, unit] = relativeTimes[0]; | ||
const actual = formatRelativeTimeToParts(value, unit, locale); | ||
expect(actual).toBeArray(); | ||
expect(spy.calls[index]).toEqual([locale, undefined]); | ||
}); | ||
|
||
it.each(relativeTimes)('should format %s %s', (value, unit) => { | ||
const locale = locales[0]; | ||
const actual = formatRelativeTimeToParts(value, unit, locale); | ||
expect(actual).toBeArray(); | ||
const valuePart = actual.find((part) => part.type === 'integer'); | ||
expect(valuePart?.value).toBe(Math.abs(value).toString()); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
/** | ||
* Copyright 2020, SumUp Ltd. | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
import { describe, it, expect } from 'vitest'; | ||
import type { SpyImpl } from 'tinyspy'; | ||
|
||
import { formatRelativeTime } from '../index.js'; | ||
|
||
import { locales, relativeTimes } from './shared.js'; | ||
|
||
const spy = Intl.RelativeTimeFormat as unknown as SpyImpl; | ||
|
||
describe('Relative times', () => { | ||
describe('formatRelativeTime', () => { | ||
it.each(locales)('should format a relative time for %o', (locale) => { | ||
const index = locales.indexOf(locale); | ||
const [value, unit] = relativeTimes[0]; | ||
const actual = formatRelativeTime(value, unit, locale); | ||
expect(actual).toBeString(); | ||
expect(spy.calls[index]).toEqual([locale, undefined]); | ||
}); | ||
|
||
it.each(relativeTimes)('should format %s %s', (value, unit, expected) => { | ||
const locale = locales[0]; | ||
const actual = formatRelativeTime(value, unit, locale); | ||
expect(actual).toBe(expected); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.