basic-number.ts
ts
import { asNumber, type Component, component, setText } from '../../..'
export type BasicNumberProps = {
value: number
}
type Logger = {
onWarn: (message: string) => void
onError: (message: string) => void
}
const FALLBACK_LOCALE = 'en'
function getNumberFormatter(
locale: string,
rawOptions: string | null,
logger: Logger = {
onWarn: console.warn,
onError: console.error,
},
) {
const useFallback = () => new Intl.NumberFormat(locale)
if (!rawOptions) return useFallback()
const { onWarn, onError } = logger
let o: Intl.NumberFormatOptions = {}
try {
o = JSON.parse(rawOptions)
} catch (error) {
onError?.(`Invalid JSON: ${error}`)
return useFallback()
}
const style = o.style ?? 'decimal'
const drops: string[] = []
if (style === 'currency') {
if (
!o.currency ||
typeof o.currency !== 'string' ||
o.currency.length !== 3
) {
onError?.(
`style="currency" requires a 3-letter ISO currency (e.g. "CHF").`,
)
return useFallback()
}
} else {
drops.push('currency', 'currencyDisplay', 'currencySign')
}
if (style === 'unit') {
if (!o.unit || typeof o.unit !== 'string') {
onError?.(
`style="unit" requires a "unit" (e.g. "liter", "kilometer-per-hour").`,
)
return useFallback()
}
} else {
drops.push('unit', 'unitDisplay')
}
if (o.notation && o.notation !== 'compact') drops.push('compactDisplay')
const sanitized: Intl.NumberFormatOptions = {}
for (const [k, v] of Object.entries(o)) {
if (!drops.includes(k)) sanitized[k] = v
else onWarn?.(`Option "${k}" is ignored for style="${style}".`)
}
const { minimumFractionDigits: minFD, maximumFractionDigits: maxFD } =
sanitized
if (minFD != null && maxFD != null && minFD > maxFD) {
onWarn?.(
`minimumFractionDigits (${minFD}) > maximumFractionDigits (${maxFD}); swapping.`,
)
sanitized.minimumFractionDigits = maxFD
sanitized.maximumFractionDigits = minFD
}
const { minimumSignificantDigits: minSD, maximumSignificantDigits: maxSD } =
sanitized
if (minSD != null && maxSD != null && minSD > maxSD) {
onWarn?.(
`minimumSignificantDigits (${minSD}) > maximumSignificantDigits (${maxSD}); swapping.`,
)
sanitized.minimumSignificantDigits = maxSD
sanitized.maximumSignificantDigits = minSD
}
try {
const formatter = new Intl.NumberFormat(locale, sanitized)
if (formatter.resolvedOptions().locale !== locale)
onWarn(
`Fall back to locale ${formatter.resolvedOptions().locale} instead of ${locale}`,
)
return formatter
} catch (e) {
onError?.(
`Options rejected by Intl.NumberFormat: ${e instanceof Error ? e.message : String(e)}`,
)
return useFallback()
}
}
export default component('basic-number', { value: asNumber() }, el => {
const formatter = getNumberFormatter(
el.closest('[lang]')?.getAttribute('lang') || FALLBACK_LOCALE,
el.getAttribute('options'),
)
return [setText(() => formatter.format(el.value))]
})
declare global {
interface HTMLElementTagNameMap {
'basic-number': Component<BasicNumberProps>
}
}