import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; export interface IntlFormatterProps { type?: 'date' | 'number' | 'percent' | 'currency'; value?: number | string | Date; lang?: string; // Date-specific props date?: Date | string; weekday?: 'narrow' | 'short' | 'long'; era?: 'narrow' | 'short' | 'long'; year?: 'numeric' | '2-digit'; month?: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long'; day?: 'numeric' | '2-digit'; hour?: 'numeric' | '2-digit'; minute?: 'numeric' | '2-digit'; second?: 'numeric' | '2-digit'; timeZoneName?: 'short' | 'long'; timeZone?: string; hourFormat?: 'auto' | '12' | '24'; dateStyle?: 'full' | 'long' | 'medium' | 'short'; timeStyle?: 'full' | 'long' | 'medium' | 'short'; // Number-specific props noGrouping?: boolean; currency?: string; currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name'; minimumIntegerDigits?: number; minimumFractionDigits?: number; maximumFractionDigits?: number; minimumSignificantDigits?: number; maximumSignificantDigits?: number; } /** * @element ag-intl-formatter * @summary Formats dates, numbers, currency, and percentages using the browser's Intl API * * @prop {string} type - The type of formatting: 'date', 'number', 'percent', or 'currency' * @prop {number|string|Date} value - The value to format (for numbers/currency/percent) or date * @prop {string} lang - The locale to use for formatting (e.g., 'en-US', 'fr-FR'). Defaults to browser locale * * @fires format-error - Dispatched when formatting fails with details about the error * * @csspart date-time - The time element wrapper for date formatting * @csspart number - The span element for number formatting * @csspart percent - The span element for percent formatting * @csspart currency - The span element for currency formatting * @csspart error - The span element shown when validation fails * * @example * ```html * * * * * * * * * * * * * * * ``` */ export class IntlFormatter extends LitElement implements IntlFormatterProps { @property({ type: String, reflect: true }) declare type: 'date' | 'number' | 'percent' | 'currency'; @property({ type: String, reflect: true }) declare value: number | string | Date; @property({ type: String, reflect: true }) declare lang: string; // Date-specific properties @property({ type: String, reflect: true }) declare date: Date | string; @property({ type: String, reflect: true }) declare weekday: 'narrow' | 'short' | 'long'; @property({ type: String, reflect: true }) declare era: 'narrow' | 'short' | 'long'; @property({ type: String, reflect: true }) declare year: 'numeric' | '2-digit'; @property({ type: String, reflect: true }) declare month: 'numeric' | '2-digit' | 'narrow' | 'short' | 'long'; @property({ type: String, reflect: true }) declare day: 'numeric' | '2-digit'; @property({ type: String, reflect: true }) declare hour: 'numeric' | '2-digit'; @property({ type: String, reflect: true }) declare minute: 'numeric' | '2-digit'; @property({ type: String, reflect: true }) declare second: 'numeric' | '2-digit'; @property({ type: String, reflect: true, attribute: 'time-zone-name' }) declare timeZoneName: 'short' | 'long'; @property({ type: String, reflect: true, attribute: 'time-zone' }) declare timeZone: string; @property({ type: String, reflect: true, attribute: 'hour-format' }) declare hourFormat: 'auto' | '12' | '24'; @property({ type: String, reflect: true, attribute: 'date-style' }) declare dateStyle: 'full' | 'long' | 'medium' | 'short'; @property({ type: String, reflect: true, attribute: 'time-style' }) declare timeStyle: 'full' | 'long' | 'medium' | 'short'; // Number-specific properties @property({ type: Boolean, reflect: true, attribute: 'no-grouping' }) declare noGrouping: boolean; @property({ type: String, reflect: true }) declare currency: string; @property({ type: String, reflect: true, attribute: 'currency-display' }) declare currencyDisplay: 'symbol' | 'narrowSymbol' | 'code' | 'name'; @property({ type: Number, reflect: true, attribute: 'minimum-integer-digits' }) declare minimumIntegerDigits: number; @property({ type: Number, reflect: true, attribute: 'minimum-fraction-digits' }) declare minimumFractionDigits: number; @property({ type: Number, reflect: true, attribute: 'maximum-fraction-digits' }) declare maximumFractionDigits: number; @property({ type: Number, reflect: true, attribute: 'minimum-significant-digits' }) declare minimumSignificantDigits: number; @property({ type: Number, reflect: true, attribute: 'maximum-significant-digits' }) declare maximumSignificantDigits: number; // Formatter cache for performance private _formatterCache = new Map(); constructor() { super(); this.type = 'date'; this.hourFormat = 'auto'; this.noGrouping = false; this.currency = 'USD'; this.currencyDisplay = 'symbol'; } /** * Get the locale to use for formatting */ private _getLocale(): string | string[] { return this.lang || navigator.language; } /** * Generate a cache key based on formatter type and options */ private _getCacheKey(): string { const parts = [this.type, this._getLocale()]; if (this.type === 'date') { parts.push( this.dateStyle || '', this.timeStyle || '', this.weekday || '', this.era || '', this.year || '', this.month || '', this.day || '', this.hour || '', this.minute || '', this.second || '', this.timeZoneName || '', this.timeZone || '', this.hourFormat || '' ); } else { parts.push( String(this.noGrouping), this.currency || '', this.currencyDisplay || '', String(this.minimumIntegerDigits || ''), String(this.minimumFractionDigits || ''), String(this.maximumFractionDigits || ''), String(this.minimumSignificantDigits || ''), String(this.maximumSignificantDigits || '') ); } return parts.join('|'); } /** * Dispatch a format error event */ private _dispatchError(type: 'date' | 'number', error: Error): void { this.dispatchEvent(new CustomEvent('format-error', { detail: { type, error: error.message }, bubbles: true, composed: true })); } /** * Validate component properties based on type */ private _validate(): { valid: boolean; error?: string } { if (this.type === 'currency' && !this.currency) { return { valid: false, error: 'Currency type requires a currency code' }; } if (this.type === 'date') { const dateValue = this.date || this.value; if (!dateValue) { return { valid: false, error: 'Date type requires a date value' }; } } else { if (this.value === undefined || this.value === null) { return { valid: false, error: `${this.type} type requires a value` }; } } return { valid: true }; } /** * Format a date value using Intl.DateTimeFormat */ private _formatDate(): string { const validation = this._validate(); if (!validation.valid) { this._dispatchError('date', new Error(validation.error)); return ''; } const dateValue = this.date || this.value; const date = new Date(dateValue); // Check for invalid date if (isNaN(date.getTime())) { this._dispatchError('date', new Error('Invalid date')); return ''; } const hour12 = this.hourFormat === 'auto' ? undefined : this.hourFormat === '12'; const options: Intl.DateTimeFormatOptions = { dateStyle: this.dateStyle, timeStyle: this.timeStyle, weekday: this.weekday, era: this.era, year: this.year, month: this.month, day: this.day, hour: this.hour, minute: this.minute, second: this.second, timeZoneName: this.timeZoneName, timeZone: this.timeZone, hour12: hour12, }; // Remove undefined values Object.keys(options).forEach(key => { if (options[key as keyof Intl.DateTimeFormatOptions] === undefined) { delete options[key as keyof Intl.DateTimeFormatOptions]; } }); try { // Use cache for better performance const cacheKey = this._getCacheKey(); let formatter = this._formatterCache.get(cacheKey) as Intl.DateTimeFormat; if (!formatter) { formatter = new Intl.DateTimeFormat(this._getLocale(), options); this._formatterCache.set(cacheKey, formatter); } return formatter.format(date); } catch (error) { this._dispatchError('date', error as Error); return ''; } } /** * Format a number value using Intl.NumberFormat */ private _formatNumber(): string { const validation = this._validate(); if (!validation.valid) { this._dispatchError('number', new Error(validation.error)); return ''; } const numValue = typeof this.value === 'string' ? parseFloat(this.value) : Number(this.value); // Check for invalid number if (isNaN(numValue)) { this._dispatchError('number', new Error('Invalid number')); return ''; } const style = this.type === 'percent' ? 'percent' : this.type === 'currency' ? 'currency' : 'decimal'; const options: Intl.NumberFormatOptions = { style, currency: this.currency, currencyDisplay: this.currencyDisplay, useGrouping: !this.noGrouping, minimumIntegerDigits: this.minimumIntegerDigits, minimumFractionDigits: this.minimumFractionDigits, maximumFractionDigits: this.maximumFractionDigits, minimumSignificantDigits: this.minimumSignificantDigits, maximumSignificantDigits: this.maximumSignificantDigits, }; // Remove undefined values Object.keys(options).forEach(key => { if (options[key as keyof Intl.NumberFormatOptions] === undefined) { delete options[key as keyof Intl.NumberFormatOptions]; } }); try { // Use cache for better performance const cacheKey = this._getCacheKey(); let formatter = this._formatterCache.get(cacheKey) as Intl.NumberFormat; if (!formatter) { formatter = new Intl.NumberFormat(this._getLocale(), options); this._formatterCache.set(cacheKey, formatter); } return formatter.format(numValue); } catch (error) { this._dispatchError('number', error as Error); return ''; } } /** * Render the formatted value */ render() { const validation = this._validate(); if (!validation.valid) { return html``; } let formattedValue = ''; if (this.type === 'date') { formattedValue = this._formatDate(); const dateValue = this.date || this.value; const date = new Date(dateValue); if (!isNaN(date.getTime())) { return html``; } } else { formattedValue = this._formatNumber(); return html`${formattedValue}`; } return html`${formattedValue}`; } /** * Clean up formatter cache when component is removed */ disconnectedCallback() { super.disconnectedCallback(); this._formatterCache.clear(); } }