Impove Intl formatting (#5372)
* Improve Intl formatting * Additional fixes * Fixed formatters were not updated on locale change * Fixed formatNumber was not updated on locale change * Additional formatting and fixes after merge * Run prepr:frontend * Remove `'` in icon map * Run `pnpm install` * fix: lint + import * Additional fixes --------- Co-authored-by: Calum H. <calum@modrinth.com> Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
38
packages/ui/src/composables/format-date-time.ts
Normal file
38
packages/ui/src/composables/format-date-time.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
|
||||
const formatterCache = new LRUCache<string, Intl.DateTimeFormat>({ max: 40 })
|
||||
|
||||
export function useFormatDateTime(options?: Intl.DateTimeFormatOptions) {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(date?: Date | number | string): string {
|
||||
if (typeof date === 'number' || typeof date === 'string') {
|
||||
date = new Date(date)
|
||||
}
|
||||
|
||||
const formatter = getFormatter(locale.value, options)
|
||||
return formatter!.format(date)
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
function getFormatter(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
||||
let cacheKey = locale
|
||||
if (options) {
|
||||
const entries = Object.entries(options)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.sort()
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
cacheKey = [locale, ...entries].join(':')
|
||||
}
|
||||
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat(locale, options)
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
78
packages/ui/src/composables/format-money.ts
Normal file
78
packages/ui/src/composables/format-money.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
|
||||
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 10 })
|
||||
const maxDigitsCache = new LRUCache<string, number>({ max: 10 })
|
||||
|
||||
// `formatMoney(1234.56, 'USD')` → `$1,234.56`
|
||||
export function useFormatMoney() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(number: number, currency = 'USD'): string {
|
||||
try {
|
||||
const formatter = getFormatter(locale.value, currency)
|
||||
return formatter!.format(number)
|
||||
} catch {
|
||||
return `${currency} ${number.toFixed(2)}`
|
||||
}
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
// `formatPrice(123456, 'USD')` → `$1,234.56`
|
||||
export function useFormatPrice() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(price: number, currency: string, trimZeros = false): string {
|
||||
const maxDigits = getMaxDigits(currency)
|
||||
const convertedPrice = price / Math.pow(10, maxDigits)
|
||||
|
||||
const minimumFractionDigits = trimZeros && Number.isInteger(convertedPrice) ? 0 : undefined
|
||||
|
||||
try {
|
||||
const formatter = getFormatter(locale.value, currency, minimumFractionDigits)
|
||||
return formatter.format(convertedPrice)
|
||||
} catch {
|
||||
return `${currency} ${convertedPrice}`
|
||||
}
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
function getFormatter(
|
||||
locale: string,
|
||||
currency: string,
|
||||
minimumFractionDigits?: number,
|
||||
): Intl.NumberFormat {
|
||||
const cacheKey = `${locale}:${currency}:${minimumFractionDigits}`
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits,
|
||||
})
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
|
||||
function getMaxDigits(currency: string): number {
|
||||
let maxDigits = maxDigitsCache.get(currency)
|
||||
if (!maxDigits) {
|
||||
try {
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
})
|
||||
maxDigits = formatter.resolvedOptions().maximumFractionDigits || 2
|
||||
} catch {
|
||||
maxDigits = 2
|
||||
}
|
||||
maxDigitsCache.set(currency, maxDigits)
|
||||
}
|
||||
return maxDigits
|
||||
}
|
||||
74
packages/ui/src/composables/format-number.ts
Normal file
74
packages/ui/src/composables/format-number.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
|
||||
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 15 })
|
||||
|
||||
// `formatNumber(1234567)` → `1,234,567`
|
||||
export function useFormatNumber() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function format(value: number | bigint): string {
|
||||
const formatter = getStandardFormatter(locale.value)
|
||||
return formatter!.format(value)
|
||||
}
|
||||
|
||||
return format
|
||||
}
|
||||
|
||||
// `formatCompactNumber(1234567)` → `1.23M`
|
||||
//
|
||||
// Use `formatCompactNumberPlural` over `{(here!), plural, one {...} other {...}}`
|
||||
export function useCompactNumber() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
function formatCompactNumber(value: number | bigint): string {
|
||||
if (value < 10_000) {
|
||||
const standardFormatter = getStandardFormatter(locale.value)
|
||||
return standardFormatter.format(value)
|
||||
}
|
||||
if (value < 1_000_000) {
|
||||
const oneDigitCompactFormatter = getCompactFormatter(locale.value, 1)
|
||||
return oneDigitCompactFormatter.format(value)
|
||||
}
|
||||
const twoDigitsCompactFormatter = getCompactFormatter(locale.value, 2)
|
||||
return twoDigitsCompactFormatter.format(value)
|
||||
}
|
||||
|
||||
function formatCompactNumberPlural(value: number | bigint): string {
|
||||
if (value < 10_000) {
|
||||
return value.toString()
|
||||
}
|
||||
if (value < 1_000_000) {
|
||||
const oneDigitCompactFormatter = getCompactFormatter(locale.value, 1)
|
||||
return oneDigitCompactFormatter.format(value)
|
||||
}
|
||||
const twoDigitsCompactFormatter = getCompactFormatter(locale.value, 2)
|
||||
return twoDigitsCompactFormatter.format(value)
|
||||
}
|
||||
|
||||
return { formatCompactNumber, formatCompactNumberPlural }
|
||||
}
|
||||
|
||||
function getStandardFormatter(locale: string): Intl.NumberFormat {
|
||||
const cacheKey = `${locale}:standard`
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale)
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
|
||||
function getCompactFormatter(locale: string, maximumFractionDigits: number): Intl.NumberFormat {
|
||||
const cacheKey = `${locale}:compact:${maximumFractionDigits}`
|
||||
let formatter = formatterCache.get(cacheKey)
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(locale, {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits,
|
||||
})
|
||||
formatterCache.set(cacheKey, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
@@ -1,35 +1,14 @@
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
import { LRUCache } from 'lru-cache'
|
||||
|
||||
import { injectI18n } from '../providers/i18n'
|
||||
import { LOCALES } from './i18n.ts'
|
||||
|
||||
export type Formatter = (
|
||||
value: Date | number | null | string | undefined,
|
||||
options?: FormatOptions,
|
||||
) => string
|
||||
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 5 })
|
||||
|
||||
export interface FormatOptions {
|
||||
roundingMode?: 'halfExpand' | 'floor' | 'ceil'
|
||||
}
|
||||
|
||||
const formatters = new Map<string, ComputedRef<Intl.RelativeTimeFormat>>()
|
||||
|
||||
export function useRelativeTime(): Formatter {
|
||||
export function useRelativeTime() {
|
||||
const { locale } = injectI18n()
|
||||
|
||||
const formatterRef = computed(() => {
|
||||
const localeDefinition = LOCALES.find((loc) => loc.code === locale.value)
|
||||
return new Intl.RelativeTimeFormat(locale.value, {
|
||||
numeric: localeDefinition?.numeric || 'auto',
|
||||
style: 'long',
|
||||
})
|
||||
})
|
||||
|
||||
if (!formatters.has(locale.value)) {
|
||||
formatters.set(locale.value, formatterRef)
|
||||
}
|
||||
|
||||
return (value: Date | number | null | string | undefined) => {
|
||||
return (value: Date | number | string | null | undefined) => {
|
||||
if (value == null) {
|
||||
return ''
|
||||
}
|
||||
@@ -50,7 +29,7 @@ export function useRelativeTime(): Formatter {
|
||||
const months = Math.round(diff / 2629746000)
|
||||
const years = Math.round(diff / 31556952000)
|
||||
|
||||
const rtf = formatterRef.value
|
||||
const rtf = getFormatter(locale.value)
|
||||
|
||||
if (Math.abs(seconds) < 60) {
|
||||
return rtf.format(seconds, 'second')
|
||||
@@ -69,3 +48,16 @@ export function useRelativeTime(): Formatter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getFormatter(locale: string): Intl.RelativeTimeFormat {
|
||||
let formatter = formatterCache.get(locale)
|
||||
if (!formatter) {
|
||||
const localeDefinition = LOCALES.find((loc) => loc.code === locale)
|
||||
formatter = new Intl.RelativeTimeFormat(locale, {
|
||||
numeric: localeDefinition?.numeric || 'auto',
|
||||
style: 'long',
|
||||
})
|
||||
formatterCache.set(locale, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from './debug-logger'
|
||||
export * from './dynamic-font-size'
|
||||
export * from './format-date-time'
|
||||
export * from './format-money'
|
||||
export * from './format-number'
|
||||
export * from './how-ago'
|
||||
export * from './i18n'
|
||||
export * from './i18n-debug'
|
||||
|
||||
Reference in New Issue
Block a user