diff --git a/src/arts/format/README.md b/src/arts/format/README.md index 46aed5b0e..ae77fcf54 100644 --- a/src/arts/format/README.md +++ b/src/arts/format/README.md @@ -158,6 +158,32 @@ formats.numbers.setDecimalSeparator(','); formats.numbers.clearDecimalSeparator(); ``` +### Override de locale por llamada (Handoff 2026-05-25) + +Igual que `dates`, los cinco metodos de `numbers` aceptan un argumento +opcional `locale` final que solo afecta a esa llamada. El engine sigue +siendo la fuente del cache, separadores, grouping y `defaultFormat` — el +override solo pivota el locale para esa entrada de +`getCachedNumberFormat`: + +```ts +const nums = createEngineNumbers({ locale: 'es-ES' }); + +nums.format(1234.5); // '1234,5' (es-ES) +nums.format(1234.5, undefined, 'en-US'); // '1,234.5' +nums.getLocale(); // 'es-ES' (no muta) + +nums.formatCurrency(12.5, 'USD', undefined, 'en-US'); // '$12.50' +nums.formatPercent(0.5, undefined, 'en-US'); // '50%' +nums.formatCompact(1_500_000, undefined, 'en-US'); // '1.5M' +nums.formatUnit(20, 'kilometer', undefined, 'en-US'); // '20 km' +``` + +Signature: `format*(value, options?, locale?)`. Si no se pasa `locale`, +el motor usa su locale activo. Las preferencias de separadores siguen +viviendo en el engine — un override de locale no salta esas +preferencias, solo cambia la entrada del cache de `Intl.NumberFormat`. + ## Currency `currency` resuelve moneda desde la region explicita del locale. No hace fallback diff --git a/src/arts/format/numbers/engine-numbers.ts b/src/arts/format/numbers/engine-numbers.ts index 98b03c5bb..34edbe96c 100644 --- a/src/arts/format/numbers/engine-numbers.ts +++ b/src/arts/format/numbers/engine-numbers.ts @@ -49,9 +49,20 @@ export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineN return normalizeNumberFormatOptions({ ...defaultFormat, ...options }); } - function format(value: number, options?: NumbersFormatOptions): string { + function format( + value: number, + options?: NumbersFormatOptions, + localeOverride?: string + ): string { if (!Number.isFinite(value)) return String(value); - return getCachedNumberFormat(getLocale(), mergedOptions(options)).format(value); + // `localeOverride` lets a caller pin a single call to a foreign + // locale (e.g. a price rendered in `en-US` while the rest of the + // app stays in `es-ES`) without mutating engine state. The cache + // key still includes the locale so each (locale × options) pair + // gets its own cached `Intl.NumberFormat` instance. + return getCachedNumberFormat(localeOverride ?? getLocale(), mergedOptions(options)).format( + value + ); } return { @@ -60,29 +71,39 @@ export function createEngineNumbers(options: EngineNumbersOptions = {}): EngineN format, - formatPercent(value, options) { - return format(value, { ...options, style: 'percent' }); + formatPercent(value, options, locale) { + return format(value, { ...options, style: 'percent' }, locale); }, - formatCompact(value, options) { - return format(value, { ...options, notation: 'compact' }); + formatCompact(value, options, locale) { + return format(value, { ...options, notation: 'compact' }, locale); }, formatCurrency( value: number, currency: string, - options?: NumbersCurrencyFormatOptions + options?: NumbersCurrencyFormatOptions, + locale?: string ): string { - return format(value, { ...options, style: 'currency', currency }); + return format(value, { ...options, style: 'currency', currency }, locale); }, - formatUnit(value: number, unit: string, options?: NumbersUnitFormatOptions): string { - return format(value, { - unitDisplay: 'short', - ...options, - style: 'unit', - unit - }); + formatUnit( + value: number, + unit: string, + options?: NumbersUnitFormatOptions, + locale?: string + ): string { + return format( + value, + { + unitDisplay: 'short', + ...options, + style: 'unit', + unit + }, + locale + ); }, parse(value: string): number | undefined { diff --git a/src/arts/format/numbers/test/engine-numbers.test.ts b/src/arts/format/numbers/test/engine-numbers.test.ts index 24b1ebbd7..1f23e904d 100644 --- a/src/arts/format/numbers/test/engine-numbers.test.ts +++ b/src/arts/format/numbers/test/engine-numbers.test.ts @@ -47,4 +47,35 @@ describe('createEngineNumbers()', () => { expect(nums.isGroupingAuto()).toBe(true); expect(nums.getGrouping()).toBe(true); }); + + describe('per-call locale override', () => { + // A foreign locale passed as the trailing arg of `format*` only + // affects that single call. Engine state stays untouched, and a + // follow-up call with no override renders in the engine's locale + // again. Mirrors the dates engine (`engine-dates.ts`). + + it('format() honours per-call locale without mutating engine state', () => { + const nums = createEngineNumbers({ locale: 'es-ES' }); + expect(nums.format(1234.5, undefined, 'en-US')).toBe('1,234.5'); + expect(nums.getLocale()).toBe('es-ES'); + expect(nums.format(1234.5)).toBe('1234,5'); + }); + + it('formatCurrency() flows the override through to Intl', () => { + const nums = createEngineNumbers({ locale: 'es-ES' }); + expect(nums.formatCurrency(12.5, 'USD', undefined, 'en-US')).toBe('$12.50'); + expect(nums.getLocale()).toBe('es-ES'); + }); + + it('formatPercent() and formatCompact() flow the override too', () => { + const nums = createEngineNumbers({ locale: 'es-ES' }); + expect(nums.formatPercent(0.5, undefined, 'en-US')).toBe('50%'); + expect(nums.formatCompact(1_500_000, undefined, 'en-US')).toBe('1.5M'); + }); + + it('formatUnit() flows the override too', () => { + const nums = createEngineNumbers({ locale: 'es-ES' }); + expect(nums.formatUnit(20, 'kilometer', undefined, 'en-US')).toContain('km'); + }); + }); }); diff --git a/src/arts/format/numbers/types.ts b/src/arts/format/numbers/types.ts index 0c053d838..3d4734fc0 100644 --- a/src/arts/format/numbers/types.ts +++ b/src/arts/format/numbers/types.ts @@ -33,15 +33,36 @@ export interface EngineNumbersOptions { export interface EngineNumbers { getLocale: () => string; setLocale: (locale: string) => void; - format: (value: number, options?: NumbersFormatOptions) => string; - formatPercent: (value: number, options?: NumbersFormatOptions) => string; - formatCompact: (value: number, options?: NumbersFormatOptions) => string; + /** + * Format methods accept an optional per-call `locale` override. When + * provided it replaces the active locale only for that call — the + * cache key still includes it so repeated calls stay hot, the + * default-format merge still applies, and the engine state stays + * untouched. Without `locale` the engine uses its active locale. + */ + format: (value: number, options?: NumbersFormatOptions, locale?: string) => string; + formatPercent: ( + value: number, + options?: NumbersFormatOptions, + locale?: string + ) => string; + formatCompact: ( + value: number, + options?: NumbersFormatOptions, + locale?: string + ) => string; formatCurrency: ( value: number, currency: string, - options?: NumbersCurrencyFormatOptions + options?: NumbersCurrencyFormatOptions, + locale?: string + ) => string; + formatUnit: ( + value: number, + unit: string, + options?: NumbersUnitFormatOptions, + locale?: string ) => string; - formatUnit: (value: number, unit: string, options?: NumbersUnitFormatOptions) => string; parse: (value: string) => number | undefined; getDecimalSeparator: () => string; getGroupSeparator: () => string; diff --git a/src/uix/eidos/components/format-number/README.md b/src/uix/eidos/components/format-number/README.md index 04a2e249f..9deef1086 100644 --- a/src/uix/eidos/components/format-number/README.md +++ b/src/uix/eidos/components/format-number/README.md @@ -44,13 +44,19 @@ Familia con ``, ``, ``. ## Decisiones -- **Rutea por `uix.format.numbers`** (con cache `getCachedNumberFormat`) - cuando no hay `locale` explicito. Cuando el caller pasa `locale`, llama - a `Intl.NumberFormat(locale, options)` directamente para no contaminar - el cache del runtime. +- **Siempre rutea por `uix.format.numbers`** (no llama a `Intl` + directamente excepto cuando no hay runtime). Esto preserva el cache + (`getCachedNumberFormat`), los separadores activos, el `defaultFormat` + y la reactividad del locale activo. +- **`locale` se pasa como tercer arg al runtime** + (`numbers.format(value, options, locale)`) — el engine acepta override + por llamada sin mutar state. El cache keyea por + `(locale × options)` asi cada override es su propio slot. - **`valueAsPercent`**: shortcut para callers que ya escalaron el valor a 0-100 (`42.5` debe renderizar `'42.5%'`). Internamente divide por 100 antes de pasar a Intl. +- **Currency fallback**: cuando `formatStyle='currency'` sin `currency` + prop, lee el currency activo de `uix.format.currency.getCurrency()`. - **No CSS recipe**: marker `[data-format-number]` solo para tooling. - **No emite eventos sema**. diff --git a/src/uix/eidos/components/format-number/format-number.svelte b/src/uix/eidos/components/format-number/format-number.svelte index 72262483e..90dc45908 100644 --- a/src/uix/eidos/components/format-number/format-number.svelte +++ b/src/uix/eidos/components/format-number/format-number.svelte @@ -49,11 +49,6 @@ const resolved = $derived.by(() => { if (value == null || !Number.isFinite(value)) return fallback; const numbers = uix.format?.numbers; - if (!numbers) { - // No format runtime configured — best-effort fallback using - // the browser's Intl directly. - return new Intl.NumberFormat(locale).format(value); - } // Scale percent values when the caller passes them pre-scaled. const inputValue = @@ -85,14 +80,18 @@ if (activeCurrency) options.currency = activeCurrency; } - // Locale override: ActiveNumbers accepts the locale via its own - // state machine, but for one-shot overrides we delegate to - // Intl directly to avoid mutating the runtime's locale. - if (locale) { - return new Intl.NumberFormat(locale, options).format(inputValue); + // Always route through `uix.format.numbers` when the runtime is + // available — it owns the cache (`getCachedNumberFormat`), the + // reactive locale, separator preferences, and the default-format + // merge. The third `locale` arg is a per-call override that + // keys the cache by `(locale × options)` without touching engine + // state. + if (numbers) { + return numbers.format(inputValue, options, locale); } - return numbers.format(inputValue, options); + // No runtime available (e.g. eidos used outside ``). + return new Intl.NumberFormat(locale ?? undefined, options).format(inputValue); });