feat(format/numbers): per-call locale override (parity with dates engine)

Engine (`arts/format/numbers/engine-numbers.ts` + `types.ts`):
- format(value, options?, locale?) acepta tercer arg locale como en
  dates. Cache (getCachedNumberFormat) keyea por (locale x options).
- formatPercent / formatCompact / formatCurrency / formatUnit propagan
  el locale al format() interno.
- No muta engine state (getLocale() sigue devolviendo el activo).

Component (`format-number.svelte`):
- Drop del bypass `new Intl.NumberFormat(locale, options)` cuando habia
  locale prop. Ahora SIEMPRE rutea por numbers.format(inputValue,
  options, locale). Preserva cache + separadores activos + defaultFormat
  + reactividad del locale.
- Fallback a Intl directo solo cuando no hay runtime (eidos fuera de
  UixApp).

Tests: 9/9 en engine-numbers.test.ts (+4 cubriendo format/Percent/
Compact/Currency/Unit con locale override sin mutar state).

Docs: arts/format/README.md sub-seccion "Override de locale por
llamada" en Numbers (handoff 2026-05-25). format-number/README.md
decisiones actualizadas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent 89f52c5e88
commit 7b01397aab

@ -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

@ -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 {

@ -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');
});
});
});

@ -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;

@ -44,13 +44,19 @@ Familia con `<Trans>`, `<FormatDate>`, `<RelativeTime>`.
## 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**.

@ -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 `<UixApp>`).
return new Intl.NumberFormat(locale ?? undefined, options).format(inputValue);
});
</script>

Loading…
Cancel
Save

Powered by TurnKey Linux.