Add NumberField terra primitive and expand glob numr format system

- glob/numr_types: replace NumrConfig with rich NumrFormat (style, currency,
  percent, unit, notation, compactDisplay, signDisplay, grouping, dir) using
  semantic type names decoupled from Intl.NumberFormatOptions
- glob/numr_engine: internal numrFormatToIntlOpts adapter keeps Intl hidden
  from consumers; mergeFormats/clampDecimals for safe instance+call merging
- glob/numr_engine.svelte: updated signature to accept NumrFormat
- terra/number-field: new headless primitive (Root, Label, Input, Increment,
  Decrement) with spinbutton ARIA, locale-aware format/parse via createNumr,
  direction via resolveDirectionProp, name prop for hidden form input
- terra/number-field types use NumrFormat from glob, no Intl dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
dev 6 months ago
parent bf9042ad92
commit ffdbb79234

@ -5,33 +5,33 @@
* la dependencia reactiva sin necesidad de listeners propios.
*/
import type { NumrConfig, NumrInstance } from './numr_types.ts';
import type { NumrFormat, NumrInstance } from './numr_types.ts';
import type { LocaleResolver } from './comn_types.ts';
import { DEFAULT_ISO_LOCALE } from '../../ling/lib/consts.ts';
import { createNumr } from './numr_engine.ts';
/**
* @param opts - Config de decimales.
* @param defaultLocale - Locale o resolver dinámico.
* @param format - Formato por defecto de la instancia.
* @param defaultLocale - Locale o resolver dinámico.
* @param getReactiveLocale - Getter que lee un $state de locale externo.
* Al leerlo aquí, Svelte establece la dependencia.
*/
export function createNumrState(
opts : NumrConfig = {},
defaultLocale : LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale : () => unknown = () => null,
format : NumrFormat = {},
defaultLocale : LocaleResolver = DEFAULT_ISO_LOCALE,
getReactiveLocale : () => unknown = () => null,
): NumrInstance {
const _numr = createNumr(opts, defaultLocale);
const _numr = createNumr(format, defaultLocale);
return {
f: (n, callOpts) => {
getReactiveLocale(); // lee $state externo → dependencia reactiva
return _numr.f(n, callOpts);
},
parse: (value, callOpts) => {
getReactiveLocale();
return _numr.parse(value, callOpts);
},
};
return {
f: (n, fmt) => {
getReactiveLocale(); // lee $state externo → dependencia reactiva
return _numr.f(n, fmt);
},
parse: (value, fmt) => {
getReactiveLocale();
return _numr.parse(value, fmt);
},
};
}

@ -1,68 +1,107 @@
import { DEFAULT_ISO_LOCALE } from '../../ling/lib';
import { resolveLocale, cachedNumberFormat, getNumberParser, isValidNumber } from './comn_engine.ts';
import type { LocaleResolver } from './comn_types.ts';
import type { NumrConfig, NumrInstance } from './numr_types.ts';
import type { NumrFormat, NumrGrouping, NumrInstance } from './numr_types.ts';
// ==============================
// ENGINE
// ==============================
// ─── Conversión interna ───────────────────────────────────────────────────────
//
// La traducción de NumrFormat → Intl.NumberFormatOptions queda confinada aquí.
// Los consumidores nunca ven tipos Intl.
function groupingToIntl(g: NumrGrouping): Intl.NumberFormatOptions['useGrouping'] {
if (g === true) return 'auto';
if (g === false) return false;
if (g === 'never') return false;
return g as 'min2' | 'always' | 'auto';
}
function numrFormatToIntlOpts(fmt: NumrFormat): Intl.NumberFormatOptions {
const opts: Intl.NumberFormatOptions = {};
if (fmt.style !== undefined) opts.style = fmt.style;
if (fmt.currency !== undefined) opts.currency = fmt.currency;
if (fmt.currencyDisplay !== undefined) opts.currencyDisplay = fmt.currencyDisplay;
if (fmt.currencySign !== undefined) opts.currencySign = fmt.currencySign;
if (fmt.unit !== undefined) opts.unit = fmt.unit;
if (fmt.unitDisplay !== undefined) opts.unitDisplay = fmt.unitDisplay;
if (fmt.notation !== undefined) opts.notation = fmt.notation;
if (fmt.compactDisplay !== undefined) opts.compactDisplay = fmt.compactDisplay;
if (fmt.signDisplay !== undefined) opts.signDisplay = fmt.signDisplay;
if (fmt.minIntegerDigits !== undefined) opts.minimumIntegerDigits = fmt.minIntegerDigits;
if (fmt.minDecimals !== undefined) opts.minimumFractionDigits = fmt.minDecimals;
if (fmt.maxDecimals !== undefined) opts.maximumFractionDigits = fmt.maxDecimals;
if (fmt.minSignificantDigits !== undefined) opts.minimumSignificantDigits = fmt.minSignificantDigits;
if (fmt.maxSignificantDigits !== undefined) opts.maximumSignificantDigits = fmt.maxSignificantDigits;
if (fmt.grouping !== undefined) opts.useGrouping = groupingToIntl(fmt.grouping);
// dir no se pasa a Intl — lo usa el consumidor para el atributo HTML `dir`.
return opts;
}
// ─── Merge de formatos ────────────────────────────────────────────────────────
/** Fusiona el formato de instancia con el override de llamada. Solo sobreescribe los campos definidos. */
function mergeFormats(base: NumrFormat, override: NumrFormat | undefined): NumrFormat {
if (!override) return base;
return { ...base, ...Object.fromEntries(
Object.entries(override).filter(([, v]) => v !== undefined)
)};
}
/** Garantiza que maxDecimals >= minDecimals para evitar RangeError en Intl. */
function clampDecimals(fmt: NumrFormat): NumrFormat {
const min = fmt.minDecimals;
const max = fmt.maxDecimals;
if (min !== undefined && max !== undefined && min > max) {
return { ...fmt, maxDecimals: min };
}
return fmt;
}
// ─── Engine ───────────────────────────────────────────────────────────────────
/**
* Crea un formateador de números basado en `Intl.NumberFormat`.
* Crea un formateador/parser de números basado en `Intl.NumberFormat`.
*
* @param opts - Decimales por defecto de la instancia.
* @param format - Formato por defecto de la instancia (style, currency, notación, etc.).
* @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito.
*/
export function createNumr(
opts : NumrConfig = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
format : NumrFormat = {},
defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE,
): NumrInstance {
const defaults: Required<NumrConfig> = {
minDecimals: opts.minDecimals ?? 0,
maxDecimals: opts.maxDecimals ?? 2,
};
/**
* Formatea `n` con el locale y los decimales indicados.
* Los `callOpts` tienen prioridad sobre los defaults de la instancia.
* Si `minDecimals > maxDecimals`, `maxDecimals` se eleva al valor de `minDecimals`
* para evitar un `RangeError` de `Intl.NumberFormat`.
*/
function f(
n : number,
callOpts?: NumrConfig,
locale : LocaleResolver = defaultLocale
): string {
if (!isValidNumber(n)) return String(n);
const min = callOpts?.minDecimals ?? defaults.minDecimals;
const max = Math.max(callOpts?.maxDecimals ?? defaults.maxDecimals, min);
return cachedNumberFormat(resolveLocale(locale), {
minimumFractionDigits: min,
maximumFractionDigits: max,
}).format(n);
}
/**
* Parsea un string numérico localizado y devuelve el número.
* Usa los mismos decimales de la instancia para construir el parser.
* Devuelve `NaN` si el string no es un número válido en ese locale.
*/
function parse(
value : string,
callOpts?: NumrConfig,
locale : LocaleResolver = defaultLocale
): number {
const min = callOpts?.minDecimals ?? defaults.minDecimals;
const max = Math.max(callOpts?.maxDecimals ?? defaults.maxDecimals, min);
return getNumberParser(resolveLocale(locale), {
minimumFractionDigits: min,
maximumFractionDigits: max,
}).parse(value);
}
return { f, parse };
function resolvedFormat(callFmt: NumrFormat | undefined): Intl.NumberFormatOptions {
return numrFormatToIntlOpts(clampDecimals(mergeFormats(format, callFmt)));
}
/**
* Formatea `n` con el locale y el formato indicados.
* El `fmt` de la llamada se fusiona sobre los defaults de la instancia.
*/
function f(
n : number,
fmt? : NumrFormat,
locale : LocaleResolver = defaultLocale,
): string {
if (!isValidNumber(n)) return String(n);
return cachedNumberFormat(resolveLocale(locale), resolvedFormat(fmt)).format(n);
}
/**
* Parsea un string numérico localizado y devuelve el número.
* Devuelve `NaN` si el string no es un número válido en ese locale.
*/
function parse(
value : string,
fmt? : NumrFormat,
locale : LocaleResolver = defaultLocale,
): number {
return getNumberParser(resolveLocale(locale), resolvedFormat(fmt)).parse(value);
}
return { f, parse };
}

@ -2,27 +2,193 @@
// NUMR TYPES
// ==============================
import type { LocaleResolver } from "./comn_types.ts";
/** Defaults de decimales para la instancia. Pueden sobreescribirse en cada llamada a `f()`. */
export interface NumrConfig {
/** Mínimo de decimales mostrados. Default: `0`. */
minDecimals?: number;
/** Máximo de decimales mostrados. Default: `2`. */
maxDecimals?: number;
import type { LocaleResolver } from './comn_types.ts';
// ─── Sub-tipos ────────────────────────────────────────────────────────────────
/** Modo de presentación del número. */
export type NumrStyle = 'decimal' | 'currency' | 'percent' | 'unit';
/** Cómo se muestra la moneda en el texto formateado. */
export type NumrCurrencyDisplay = 'symbol' | 'narrowSymbol' | 'code' | 'name';
/**
* Convención de signo para valores monetarios negativos.
* - `standard` → signo menos (−12 €)
* - `accounting` → paréntesis ((12 €))
*/
export type NumrCurrencySign = 'standard' | 'accounting';
/** Cómo se muestra la unidad de medida. */
export type NumrUnitDisplay = 'short' | 'narrow' | 'long';
/** Notación numérica empleada. */
export type NumrNotation = 'standard' | 'scientific' | 'engineering' | 'compact';
/** Variante compacta (miles, millones…). Solo activa cuando `notation === 'compact'`. */
export type NumrCompactDisplay = 'short' | 'long';
/**
* Cuándo mostrar el signo explícito del número.
* - `auto` → solo en negativos (comportamiento por defecto)
* - `always` → siempre (+1 234 / −1 234)
* - `exceptZero` → en todos excepto 0
* - `negative` → solo en negativos (sin signo en positivos ni en cero)
* - `never` → nunca (útil en contextos donde el signo va por otro canal)
*/
export type NumrSignDisplay = 'auto' | 'always' | 'exceptZero' | 'negative' | 'never';
/**
* Control de separadores de miles.
* - `true` / `'auto'` → según convención del locale
* - `false` / `'never'`→ sin separadores
* - `'min2'` → solo a partir de 5 dígitos (10000 → 10 000, 9999 → 9999)
* - `'always'` → siempre, incluso si hay menos de 4 dígitos
*/
export type NumrGrouping = boolean | 'min2' | 'always' | 'auto' | 'never';
/** Dirección del texto asociado al número. */
export type NumrDir = 'ltr' | 'rtl';
// ─── NumrFormat ───────────────────────────────────────────────────────────────
/**
* Formato de presentación de un número.
*
* Abstrae `Intl.NumberFormatOptions` con nombres semánticos propios.
* La conversión a las opciones internas de `Intl` es responsabilidad del engine,
* no de los consumidores.
*/
export interface NumrFormat {
// ── Estilo ──────────────────────────────────────────────────────────────
/**
* Modo de presentación.
* @defaultValue `'decimal'`
*/
style?: NumrStyle;
/**
* Código ISO 4217 de la moneda (ej. `'EUR'`, `'USD'`).
* Obligatorio cuando `style === 'currency'`.
*/
currency?: string;
/**
* Cómo mostrar la moneda.
* @defaultValue `'symbol'`
*/
currencyDisplay?: NumrCurrencyDisplay;
/**
* Convención de signo para cantidades monetarias negativas.
* @defaultValue `'standard'`
*/
currencySign?: NumrCurrencySign;
/**
* Identificador de unidad de medida (ej. `'kilometer'`, `'liter'`, `'celsius'`).
* Obligatorio cuando `style === 'unit'`.
*/
unit?: string;
/**
* Cómo mostrar la unidad.
* @defaultValue `'short'`
*/
unitDisplay?: NumrUnitDisplay;
// ── Notación ────────────────────────────────────────────────────────────
/**
* Notación del número.
* @defaultValue `'standard'`
*/
notation?: NumrNotation;
/**
* Variante compacta. Solo se aplica cuando `notation === 'compact'`.
* @defaultValue `'short'`
*/
compactDisplay?: NumrCompactDisplay;
// ── Signo ───────────────────────────────────────────────────────────────
/**
* Cuándo mostrar el signo explícito.
* @defaultValue `'auto'`
*/
signDisplay?: NumrSignDisplay;
// ── Precisión ───────────────────────────────────────────────────────────
/**
* Mínimo de dígitos fraccionarios.
* @defaultValue `0`
*/
minDecimals?: number;
/**
* Máximo de dígitos fraccionarios.
* Si se omite y no hay `minDecimals`, el engine usa `3` como tope razonable.
* Si `minDecimals > maxDecimals`, `maxDecimals` se eleva automáticamente.
*/
maxDecimals?: number;
/**
* Mínimo de dígitos significativos.
* Cuando se especifica, el motor entra en modo de «dígitos significativos»
* y `minDecimals`/`maxDecimals` son ignorados por `Intl`.
*/
minSignificantDigits?: number;
/** Máximo de dígitos significativos. */
maxSignificantDigits?: number;
/**
* Mínimo de dígitos enteros.
* @defaultValue `1`
*/
minIntegerDigits?: number;
// ── Agrupación ──────────────────────────────────────────────────────────
/**
* Separadores de grupos de miles.
* @defaultValue `'auto'` (según convención del locale)
*/
grouping?: NumrGrouping;
// ── Dirección ───────────────────────────────────────────────────────────
/**
* Dirección de escritura del número.
* Se integra con el sistema de dirección de terra (`resolveDirectionProp`).
* No afecta al formateador interno de `Intl` — se usa para propagar el
* atributo `dir` a los elementos de UI.
*/
dir?: NumrDir;
}
// ─── NumrConfig (compat) ──────────────────────────────────────────────────────
/**
* Subconjunto mínimo de `NumrFormat` para uso rápido cuando solo importa la precisión.
* @deprecated Prefer `NumrFormat` for full control.
*/
export type NumrConfig = Pick<NumrFormat, 'minDecimals' | 'maxDecimals'>;
// ─── NumrInstance ─────────────────────────────────────────────────────────────
export interface NumrInstance {
/**
* Formatea un número según el locale activo.
* Los `opts` de cada llamada sobreescriben los defaults de la instancia.
* Si `minDecimals > maxDecimals`, `maxDecimals` se eleva automáticamente.
*/
f: (n: number, opts?: NumrConfig, locale?: LocaleResolver) => string;
/**
* Parsea un string numérico localizado y devuelve el número.
* Detecta automáticamente el sistema numérico (latn, arab, hanidec, deva…).
* Devuelve `NaN` si el string no es un número válido.
*/
parse: (value: string, opts?: NumrConfig, locale?: LocaleResolver) => number;
/**
* Formatea `n` con el locale activo.
*
* El `fmt` de cada llamada se fusiona sobre los defaults de la instancia.
* Solo los campos definidos en `fmt` sobreescriben el default correspondiente.
* Si `minDecimals > maxDecimals` el motor eleva `maxDecimals` automáticamente.
*/
f: (n: number, fmt?: NumrFormat, locale?: LocaleResolver) => string;
/**
* Parsea un string numérico localizado y devuelve el número.
*
* Detecta automáticamente el sistema numérico (latn, arab, hanidec, deva…).
* Devuelve `NaN` si el string no representa un número válido en ese locale.
*/
parse: (value: string, fmt?: NumrFormat, locale?: LocaleResolver) => number;
}

@ -0,0 +1,366 @@
<script lang="ts">
import { NumberField } from '$uix/terra';
let basic = $state<number | undefined>(42);
let clamped = $state<number | undefined>(5);
let currency = $state<number | undefined>(1234.56);
let percent = $state<number | undefined>(0.75);
let formValue = $state<number | undefined>(undefined);
let formResult = $state<string>('');
function handleFormSubmit(e: SubmitEvent) {
e.preventDefault();
const data = new FormData(e.currentTarget as HTMLFormElement);
formResult = JSON.stringify(Object.fromEntries(data));
}
</script>
<svelte:head>
<title>NumberField</title>
</svelte:head>
<div class="page">
<header class="hero">
<div>
<p class="eyebrow">Terra</p>
<h1>NumberField</h1>
<p class="lead">
Primitivo headless para entrada de valores numéricos. Formato localizado, soporte de
teclado completo (flechas, PgUp/PgDn, Home/End) e integración con Form.
</p>
</div>
<div class="links">
<a href="/test">Indice general</a>
<a href="/test/terra">Hub terra</a>
</div>
</header>
<section class="panel">
<header class="panel-header">
<h2>Básico</h2>
</header>
<div class="examples">
<div class="card">
<h3>Sin límites</h3>
<p>Valor: <code>{basic ?? '—'}</code></p>
<NumberField.Root bind:value={basic}>
<div class="field-row">
<NumberField.Label class="label">Cantidad</NumberField.Label>
<div class="input-group">
<NumberField.Decrement class="stepper-btn">−</NumberField.Decrement>
<NumberField.Input class="input" />
<NumberField.Increment class="stepper-btn">+</NumberField.Increment>
</div>
</div>
</NumberField.Root>
</div>
<div class="card">
<h3>Con min / max / step</h3>
<p>Valor: <code>{clamped ?? '—'}</code></p>
<NumberField.Root bind:value={clamped} min={0} max={10} step={0.5}>
<div class="field-row">
<NumberField.Label class="label">Rating (0–10, paso 0.5)</NumberField.Label>
<div class="input-group">
<NumberField.Decrement class="stepper-btn">−</NumberField.Decrement>
<NumberField.Input class="input" />
<NumberField.Increment class="stepper-btn">+</NumberField.Increment>
</div>
</div>
</NumberField.Root>
</div>
</div>
</section>
<section class="panel">
<header class="panel-header">
<h2>Formato</h2>
</header>
<div class="examples">
<div class="card">
<h3>Moneda (EUR)</h3>
<p>Valor: <code>{currency ?? '—'}</code></p>
<NumberField.Root
bind:value={currency}
locale="es-ES"
formatOptions={{ style: 'currency', currency: 'EUR' }}
step={0.01}
>
<div class="field-row">
<NumberField.Label class="label">Importe</NumberField.Label>
<div class="input-group">
<NumberField.Decrement class="stepper-btn">−</NumberField.Decrement>
<NumberField.Input class="input" />
<NumberField.Increment class="stepper-btn">+</NumberField.Increment>
</div>
</div>
</NumberField.Root>
</div>
<div class="card">
<h3>Porcentaje</h3>
<p>Valor: <code>{percent ?? '—'}</code></p>
<NumberField.Root
bind:value={percent}
min={0}
max={1}
step={0.01}
formatOptions={{ style: 'percent' }}
>
<div class="field-row">
<NumberField.Label class="label">Descuento</NumberField.Label>
<div class="input-group">
<NumberField.Decrement class="stepper-btn">−</NumberField.Decrement>
<NumberField.Input class="input" />
<NumberField.Increment class="stepper-btn">+</NumberField.Increment>
</div>
</div>
</NumberField.Root>
</div>
</div>
</section>
<section class="panel">
<header class="panel-header">
<h2>Integración con Form</h2>
</header>
<div class="examples">
<div class="card">
<h3>Envío nativo</h3>
<p>
El campo publica el valor en <code>form.quantity</code> como input hidden. Al enviar el
formulario se captura el valor sin JS extra.
</p>
<form onsubmit={handleFormSubmit} class="demo-form">
<NumberField.Root
bind:value={formValue}
name="quantity"
min={1}
max={100}
step={1}
required={true}
>
<div class="field-row">
<NumberField.Label class="label">Cantidad</NumberField.Label>
<div class="input-group">
<NumberField.Decrement class="stepper-btn">−</NumberField.Decrement>
<NumberField.Input class="input" />
<NumberField.Increment class="stepper-btn">+</NumberField.Increment>
</div>
</div>
</NumberField.Root>
<button type="submit" class="submit-btn">Enviar</button>
</form>
{#if formResult}
<pre class="result">{formResult}</pre>
{/if}
</div>
<div class="card">
<h3>Estado deshabilitado</h3>
<NumberField.Root value={99} disabled={true}>
<div class="field-row">
<NumberField.Label class="label">Precio (bloqueado)</NumberField.Label>
<div class="input-group">
<NumberField.Decrement class="stepper-btn">−</NumberField.Decrement>
<NumberField.Input class="input" />
<NumberField.Increment class="stepper-btn">+</NumberField.Increment>
</div>
</div>
</NumberField.Root>
</div>
</div>
</section>
</div>
<style>
:global(*, *::before, *::after) {
box-sizing: border-box;
}
.page {
min-height: 100vh;
padding: 2rem;
background: #0b1020;
color: #e5e7eb;
font-family: Inter, system-ui, sans-serif;
}
.hero,
.panel {
max-width: 960px;
margin: 0 auto 1.5rem;
padding: 1.25rem;
border: 1px solid #24324a;
border-radius: 18px;
background: #111827;
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.eyebrow {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #67e8f9;
}
h1,
h2,
h3,
p {
margin: 0;
}
.lead {
max-width: 62ch;
margin-top: 0.5rem;
color: #94a3b8;
line-height: 1.5;
}
.links {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.links a {
color: #93c5fd;
text-decoration: none;
}
.panel-header {
margin-bottom: 1rem;
}
.examples {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem;
border: 1px solid #1f2937;
border-radius: 14px;
background: #0f172a;
}
.card p {
color: #94a3b8;
line-height: 1.5;
}
.field-row {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
:global(.label) {
font-size: 0.8rem;
font-weight: 600;
color: #94a3b8;
letter-spacing: 0.04em;
}
.input-group {
display: flex;
align-items: stretch;
border: 1px solid #334155;
border-radius: 8px;
overflow: hidden;
background: #0b1120;
}
:global(.input) {
flex: 1;
min-width: 0;
padding: 0.45rem 0.6rem;
background: transparent;
border: none;
color: #e5e7eb;
font: inherit;
font-size: 0.95rem;
text-align: center;
outline: none;
}
:global(.input:focus) {
background: rgba(103, 232, 249, 0.05);
}
:global(.stepper-btn) {
flex-shrink: 0;
width: 2.2rem;
border: none;
background: transparent;
color: #94a3b8;
font: inherit;
font-size: 1.1rem;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
:global(.stepper-btn:hover:not(:disabled)) {
color: #67e8f9;
background: rgba(103, 232, 249, 0.06);
}
:global(.stepper-btn:disabled) {
opacity: 0.3;
cursor: not-allowed;
}
code {
padding: 0.1rem 0.35rem;
border-radius: 6px;
background: #020617;
color: #93c5fd;
font-size: 0.85em;
}
.demo-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.submit-btn {
align-self: flex-start;
padding: 0.45rem 1rem;
border: 1px solid #334155;
border-radius: 8px;
background: #0f172a;
color: #e5e7eb;
font: inherit;
cursor: pointer;
}
.submit-btn:hover {
border-color: #67e8f9;
color: #67e8f9;
}
pre.result {
padding: 0.6rem 0.8rem;
border-radius: 8px;
background: #020617;
color: #86efac;
font-size: 0.8rem;
overflow-x: auto;
}
</style>

@ -48,6 +48,7 @@
{ name: 'Form', path: '/test/form', status: 'wip' },
{ name: 'Field', path: '/test/field', status: 'wip' },
{ name: 'NumberField', path: '/test/number-field', status: 'stable' },
{ name: 'PinInput', path: '/test/pin-input', status: 'stable' },
{ name: 'RadioGroup', path: '/test/radio-group', status: 'stable' },
{ name: 'Select', path: '/test/select', status: 'stable' },

@ -24,6 +24,7 @@ export * as Spinner from './spinner';
export * as Tabs from './tabs';
export * as Toggle from './toggle';
export * as ToggleGroup from './toggle-group';
export * as NumberField from './number-field';
export * as PinInput from './pin-input';
export * as ScrollArea from './scroll-area';
export * as Slider from './slider';
@ -45,14 +46,14 @@ export * as Drawer from './drawer';
export * as Field from './field';
export * as Form from './form';
export { TerraConfig } from './base/config/exports';
export { TerraConfig } from './config/exports';
export type {
TerraConfigProps,
TerraConfigPropsWithoutChildren,
TerraPresentationProps,
TerraDatePresentationProps,
TerraDateTimePresentationProps,
} from './base/config/exports';
} from './config/exports';
export * as Calendar from './calendar';
export * as RangeCalendar from './range-calendar';

@ -0,0 +1,31 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { NumberFieldDecrementProps } from '../types';
import { NumberFieldDecrementState } from '../number-field.svelte';
const uid = $props.id();
let {
ref = $bindable<HTMLButtonElement | null>(null),
id = createId(uid),
child,
children,
...restProps
}: NumberFieldDecrementProps = $props();
// svelte-ignore state_referenced_locally
const decrementState = NumberFieldDecrementState.create({
ref: writableActive(() => ref, (v) => (ref = v as any)),
id: readableActive(() => id),
});
const mergedProps = $derived(mergeProps(restProps, decrementState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,31 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { NumberFieldIncrementProps } from '../types';
import { NumberFieldIncrementState } from '../number-field.svelte';
const uid = $props.id();
let {
ref = $bindable<HTMLButtonElement | null>(null),
id = createId(uid),
child,
children,
...restProps
}: NumberFieldIncrementProps = $props();
// svelte-ignore state_referenced_locally
const incrementState = NumberFieldIncrementState.create({
ref: writableActive(() => ref, (v) => (ref = v as any)),
id: readableActive(() => id),
});
const mergedProps = $derived(mergeProps(restProps, incrementState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,39 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { NumberFieldInputProps } from '../types';
import { NumberFieldInputState, NumberFieldRootContext } from '../number-field.svelte';
const uid = $props.id();
let {
ref = $bindable<HTMLInputElement | null>(null),
id = createId(uid),
child,
children,
...restProps
}: NumberFieldInputProps = $props();
// svelte-ignore state_referenced_locally
const inputState = NumberFieldInputState.create({
ref: writableActive(() => ref, (v) => (ref = v as any)),
id: readableActive(() => id),
});
const root = NumberFieldRootContext.get();
const mergedProps = $derived(mergeProps(restProps, inputState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<input {...mergedProps} />
{/if}
{#if root.opts.name.current}
<input
type="hidden"
name={root.opts.name.current}
value={root.opts.value.current ?? ''}
disabled={root.isDisabled || undefined}
/>
{/if}

@ -0,0 +1,31 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps, createId } from '$terra/utils';
import type { NumberFieldLabelProps } from '../types';
import { NumberFieldLabelState } from '../number-field.svelte';
const uid = $props.id();
let {
ref = $bindable<HTMLLabelElement | null>(null),
id = createId(uid),
child,
children,
...restProps
}: NumberFieldLabelProps = $props();
// svelte-ignore state_referenced_locally
const labelState = NumberFieldLabelState.create({
ref: writableActive(() => ref, (v) => (ref = v as any)),
id: readableActive(() => id),
});
const mergedProps = $derived(mergeProps(restProps, labelState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<label {...mergedProps}>
{@render children?.()}
</label>
{/if}

@ -0,0 +1,44 @@
<script lang="ts">
import { noop } from '$lib/util/funcs';
import { readableActive, writableActive } from '$terra/utils';
import type { NumberFieldRootProps } from '../types';
import { NumberFieldRootState } from '../number-field.svelte';
let {
value = $bindable<number | undefined>(undefined),
onValueChange = noop,
min = undefined,
max = undefined,
step = 1,
largeStep = undefined,
locale = undefined,
formatOptions = undefined,
dir = undefined,
disabled = false,
readonly = false,
required = false,
name = undefined,
invalid = false,
children,
}: NumberFieldRootProps = $props();
// svelte-ignore state_referenced_locally
NumberFieldRootState.create({
value: writableActive(() => value, (v) => (value = v)),
onValueChange: readableActive(() => onValueChange),
min: readableActive(() => min),
max: readableActive(() => max),
step: readableActive(() => step),
largeStep: readableActive(() => largeStep),
locale: readableActive(() => locale),
formatOptions: readableActive(() => formatOptions),
dir: readableActive(() => dir),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
name: readableActive(() => name),
invalid: readableActive(() => invalid),
});
</script>
{@render children?.()}

@ -0,0 +1,13 @@
export { default as Root } from './components/number-field-root.svelte';
export { default as Label } from './components/number-field-label.svelte';
export { default as Input } from './components/number-field-input.svelte';
export { default as Increment } from './components/number-field-increment.svelte';
export { default as Decrement } from './components/number-field-decrement.svelte';
export type {
NumberFieldRootProps as RootProps,
NumberFieldLabelProps as LabelProps,
NumberFieldInputProps as InputProps,
NumberFieldIncrementProps as IncrementProps,
NumberFieldDecrementProps as DecrementProps,
NumberFieldFormatOptions,
} from './types';

@ -0,0 +1,2 @@
export * as NumberField from './exports';
export * from './exports';

@ -0,0 +1,429 @@
import { watch } from 'runed';
import { createTerraContext } from '$terra/utils/context';
import {
attachRef,
kbd,
readableActive,
} from '$terra/utils';
import type {
Active,
ActiveProps,
StateProps,
RefAttachment,
WithRefOpts,
TerraFocusEvent,
TerraKeyboardEvent,
TerraInputEvent,
OnChangeFn,
Direction,
} from '$terra/utils';
import { boolToEmptyStrOrUndef, boolToStr, createTerraAttrs } from '../utils/attrs';
import { resolveLocaleProp, resolveDirectionProp } from '../config/prop-resolvers';
import { createNumr } from '@/glob/lib';
import type { NumrFormat } from '@/glob/lib';
// ─── Attrs ────────────────────────────────────────────────────────────────────
export const numberFieldAttrs = createTerraAttrs({
component: 'number-field',
parts: ['root', 'label', 'input', 'increment', 'decrement'] as const,
});
// ─── Context ──────────────────────────────────────────────────────────────────
export const NumberFieldRootContext = createTerraContext<NumberFieldRootState>('NumberField.Root');
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getDecimalPrecision(n: number): number {
const str = String(n);
const dot = str.indexOf('.');
return dot >= 0 ? str.length - dot - 1 : 0;
}
// ─── Root ─────────────────────────────────────────────────────────────────────
interface NumberFieldRootStateOpts
extends ActiveProps<{
onValueChange: OnChangeFn<number | undefined> | undefined;
min: number | undefined;
max: number | undefined;
step: number;
largeStep: number | undefined;
locale: string | undefined;
formatOptions: NumrFormat | undefined;
dir: Direction | undefined;
disabled: boolean;
readonly: boolean;
required: boolean;
name: string | undefined;
invalid: boolean;
}>,
StateProps<{ value: number | undefined }> {}
export class NumberFieldRootState {
static create(opts: NumberFieldRootStateOpts) {
return NumberFieldRootContext.set(new NumberFieldRootState(opts));
}
readonly opts: NumberFieldRootStateOpts;
/** ID del <label> asociado, escrito por NumberFieldLabelState. */
labelId = $state<string | undefined>(undefined);
/** ID del <input>, escrito por NumberFieldInputState. */
inputId = $state<string | undefined>(undefined);
#focused = $state(false);
/** Valor en bruto que muestra el <input>. */
#inputValue = $state('');
/** Locale resuelto (prop → TerraConfig → 'en'). */
readonly #locale: Active<string>;
/** Dirección resuelta (prop → TerraConfig → 'ltr'). */
readonly #dir: Active<Direction>;
private constructor(opts: NumberFieldRootStateOpts) {
this.opts = opts;
this.#locale = resolveLocaleProp(() => opts.locale.current);
this.#dir = resolveDirectionProp(() => opts.dir.current);
// Sincroniza #inputValue con el valor externo cuando no está en foco.
watch.pre(
() => [opts.value.current, this.#locale.current, opts.formatOptions.current] as const,
([value]) => {
if (!this.#focused) {
this.#inputValue = this.#formatValue(value);
}
},
);
}
// ── Computed ─────────────────────────────────────────────────────────────
readonly isDisabled = $derived.by(() => this.opts.disabled.current);
readonly isReadonly = $derived.by(() => this.opts.readonly.current);
readonly isRequired = $derived.by(() => this.opts.required.current);
readonly isInvalid = $derived.by(() => this.opts.invalid.current);
readonly #step = $derived.by(() => this.opts.step.current);
readonly #largeStep = $derived.by(() => this.opts.largeStep.current ?? this.#step * 10);
readonly #min = $derived.by(() => this.opts.min.current);
readonly #max = $derived.by(() => this.opts.max.current);
/** Dirección activa del campo (ltr/rtl). */
readonly dir = $derived.by(() => this.#dir.current);
/**
* Instancia NumrInstance reactiva.
* Se recrea solo cuando cambian formatOptions o el locale.
* La dirección se gestiona por separado (atributo HTML, no formateo).
*/
readonly #numr = $derived.by(() => {
const fmt: NumrFormat = { ...(this.opts.formatOptions.current ?? {}) };
return createNumr(fmt, this.#locale.current);
});
readonly atMin = $derived.by(
() => this.#min !== undefined
&& this.opts.value.current !== undefined
&& this.opts.value.current <= this.#min,
);
readonly atMax = $derived.by(
() => this.#max !== undefined
&& this.opts.value.current !== undefined
&& this.opts.value.current >= this.#max,
);
get inputValue(): string {
return this.#inputValue;
}
// ── Format / Parse ───────────────────────────────────────────────────────
#formatValue(value: number | undefined): string {
if (value === undefined || Number.isNaN(value)) return '';
return this.#numr.f(value);
}
#parseValue(raw: string): number | undefined {
if (!raw.trim()) return undefined;
const n = this.#numr.parse(raw);
return Number.isNaN(n) ? undefined : n;
}
#clamp(value: number): number {
let v = value;
if (this.#min !== undefined) v = Math.max(this.#min, v);
if (this.#max !== undefined) v = Math.min(this.#max, v);
return v;
}
#round(value: number, step: number): number {
const precision = getDecimalPrecision(step);
const factor = Math.pow(10, precision);
return Math.round(value * factor) / factor;
}
#commit(value: number | undefined): void {
if (value === undefined) {
this.opts.value.current = undefined;
this.opts.onValueChange.current?.(undefined);
this.#inputValue = '';
return;
}
const clamped = this.#clamp(this.#round(value, this.#step));
this.opts.value.current = clamped;
this.opts.onValueChange.current?.(clamped);
this.#inputValue = this.#focused ? this.#inputValue : this.#formatValue(clamped);
}
// ── Public actions ───────────────────────────────────────────────────────
increment(by: number = this.#step): void {
if (this.isDisabled || this.isReadonly) return;
const current = this.opts.value.current ?? 0;
this.#commit(current + by);
}
decrement(by: number = this.#step): void {
if (this.isDisabled || this.isReadonly) return;
const current = this.opts.value.current ?? 0;
this.#commit(current - by);
}
// ── Event handlers (consumidos por InputState) ────────────────────────────
readonly handleFocus = (_e: TerraFocusEvent<HTMLInputElement>): void => {
this.#focused = true;
// Al enfocar, mostrar el número en bruto (sin formato).
const raw = this.opts.value.current;
this.#inputValue = raw !== undefined ? String(raw) : '';
};
readonly handleBlur = (_e: TerraFocusEvent<HTMLInputElement>): void => {
this.#focused = false;
const parsed = this.#parseValue(this.#inputValue);
this.#commit(parsed);
};
readonly handleInput = (e: TerraInputEvent<HTMLInputElement>): void => {
this.#inputValue = e.currentTarget.value;
};
readonly handleKeydown = (e: TerraKeyboardEvent<HTMLInputElement>): void => {
if (this.isDisabled || this.isReadonly) return;
switch (e.key) {
case kbd.ARROW_UP:
e.preventDefault();
this.increment(this.#step);
break;
case kbd.ARROW_DOWN:
e.preventDefault();
this.decrement(this.#step);
break;
case kbd.PAGE_UP:
e.preventDefault();
this.increment(this.#largeStep);
break;
case kbd.PAGE_DOWN:
e.preventDefault();
this.decrement(this.#largeStep);
break;
case kbd.HOME:
if (this.#min !== undefined) {
e.preventDefault();
this.#commit(this.#min);
}
break;
case kbd.END:
if (this.#max !== undefined) {
e.preventDefault();
this.#commit(this.#max);
}
break;
}
};
// ── Props (el Root no renderiza elemento DOM) ─────────────────────────────
readonly snippetProps = $derived.by(() => ({
value: this.opts.value.current,
isDisabled: this.isDisabled,
isReadonly: this.isReadonly,
isInvalid: this.isInvalid,
dir: this.dir,
}));
}
// ─── Label ────────────────────────────────────────────────────────────────────
interface NumberFieldLabelStateOpts extends WithRefOpts {}
export class NumberFieldLabelState {
static create(opts: NumberFieldLabelStateOpts) {
return new NumberFieldLabelState(opts, NumberFieldRootContext.get());
}
readonly opts: NumberFieldLabelStateOpts;
readonly root: NumberFieldRootState;
readonly attachment: RefAttachment;
private constructor(opts: NumberFieldLabelStateOpts, root: NumberFieldRootState) {
this.opts = opts;
this.root = root;
this.root.labelId = opts.id.current;
this.attachment = attachRef(opts.ref);
watch.pre(
() => opts.id.current,
(id) => { this.root.labelId = id; },
);
}
readonly props = $derived.by(() => ({
id: this.opts.id.current,
for: this.root.inputId,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
'data-invalid': boolToEmptyStrOrUndef(this.root.isInvalid),
[numberFieldAttrs.label]: '',
...this.attachment,
}) as const);
}
// ─── Input ────────────────────────────────────────────────────────────────────
interface NumberFieldInputStateOpts extends WithRefOpts {}
export class NumberFieldInputState {
static create(opts: NumberFieldInputStateOpts) {
return new NumberFieldInputState(opts, NumberFieldRootContext.get());
}
readonly opts: NumberFieldInputStateOpts;
readonly root: NumberFieldRootState;
readonly attachment: RefAttachment<HTMLInputElement>;
private constructor(opts: NumberFieldInputStateOpts, root: NumberFieldRootState) {
this.opts = opts;
this.root = root;
this.root.inputId = opts.id.current;
this.attachment = attachRef(opts.ref as any);
watch.pre(
() => opts.id.current,
(id) => { this.root.inputId = id; },
);
}
readonly #ariaValueText = $derived.by(() => {
const v = this.root.opts.value.current;
return v !== undefined ? String(v) : undefined;
});
readonly props = $derived.by(() => ({
id: this.opts.id.current,
role: 'spinbutton' as const,
inputmode: 'decimal' as const,
autocomplete: 'off' as const,
autocorrect: 'off' as const,
spellcheck: false,
dir: this.root.dir,
value: this.root.inputValue,
'aria-valuenow': this.root.opts.value.current,
'aria-valuemin': this.root.opts.min.current,
'aria-valuemax': this.root.opts.max.current,
'aria-valuetext': this.#ariaValueText,
'aria-labelledby': this.root.labelId,
'aria-required': boolToStr(this.root.isRequired),
'aria-disabled': boolToStr(this.root.isDisabled),
'aria-readonly': boolToStr(this.root.isReadonly),
'aria-invalid': this.root.isInvalid ? ('true' as const) : undefined,
disabled: this.root.isDisabled || undefined,
readonly: this.root.isReadonly || undefined,
required: this.root.isRequired || undefined,
onfocus: this.root.handleFocus,
onblur: this.root.handleBlur,
oninput: this.root.handleInput,
onkeydown: this.root.handleKeydown,
'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(this.root.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(this.root.isInvalid),
[numberFieldAttrs.input]: '',
...this.attachment,
}) as const);
}
// ─── Increment ────────────────────────────────────────────────────────────────
interface NumberFieldIncrementStateOpts extends WithRefOpts {}
export class NumberFieldIncrementState {
static create(opts: NumberFieldIncrementStateOpts) {
return new NumberFieldIncrementState(opts, NumberFieldRootContext.get());
}
readonly opts: NumberFieldIncrementStateOpts;
readonly root: NumberFieldRootState;
readonly attachment: RefAttachment;
private constructor(opts: NumberFieldIncrementStateOpts, root: NumberFieldRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(opts.ref);
}
readonly #isDisabled = $derived.by(() => this.root.isDisabled || this.root.atMax);
readonly #handleClick = () => { this.root.increment(); };
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'button' as const,
tabindex: -1,
disabled: this.#isDisabled || undefined,
'aria-label': 'Increment',
'aria-disabled': boolToStr(this.#isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
onclick: this.#handleClick,
[numberFieldAttrs.increment]: '',
...this.attachment,
}) as const);
}
// ─── Decrement ────────────────────────────────────────────────────────────────
interface NumberFieldDecrementStateOpts extends WithRefOpts {}
export class NumberFieldDecrementState {
static create(opts: NumberFieldDecrementStateOpts) {
return new NumberFieldDecrementState(opts, NumberFieldRootContext.get());
}
readonly opts: NumberFieldDecrementStateOpts;
readonly root: NumberFieldRootState;
readonly attachment: RefAttachment;
private constructor(opts: NumberFieldDecrementStateOpts, root: NumberFieldRootState) {
this.opts = opts;
this.root = root;
this.attachment = attachRef(opts.ref);
}
readonly #isDisabled = $derived.by(() => this.root.isDisabled || this.root.atMin);
readonly #handleClick = () => { this.root.decrement(); };
readonly props = $derived.by(() => ({
id: this.opts.id.current,
type: 'button' as const,
tabindex: -1,
disabled: this.#isDisabled || undefined,
'aria-label': 'Decrement',
'aria-disabled': boolToStr(this.#isDisabled),
'data-disabled': boolToEmptyStrOrUndef(this.#isDisabled),
onclick: this.#handleClick,
[numberFieldAttrs.decrement]: '',
...this.attachment,
}) as const);
}

@ -0,0 +1,109 @@
import type {
WithChild,
WithChildren,
Without,
OnChangeFn,
} from '$terra/utils';
import type {
TerraPrimitiveLabelAttributes,
TerraPrimitiveButtonAttributes,
TerraPrimitiveInputAttributes,
} from '$terra/utils';
import type { Direction } from '$terra/utils';
import type { NumrFormat } from '@/glob/lib';
/** Opciones de formato del número. Provienen de la librería glob, no de Intl. */
export type { NumrFormat as NumberFieldFormatOptions };
// ─── Root ─────────────────────────────────────────────────────────────────────
type BaseNumberFieldRootProps = {
/**
* El valor numérico del campo.
* `undefined` representa el estado vacío.
* @bindable
*/
value?: number;
/** Callback invocado cuando el valor cambia. */
onValueChange?: OnChangeFn<number | undefined>;
/** Valor mínimo aceptado. Sin límite por defecto. */
min?: number;
/** Valor máximo aceptado. Sin límite por defecto. */
max?: number;
/**
* Incremento/decremento por paso normal (↑ ↓).
* @defaultValue 1
*/
step?: number;
/**
* Incremento/decremento por paso grande (PgUp / PgDn).
* @defaultValue 10 × step
*/
largeStep?: number;
/**
* Locale BCP 47 usado para formatear y parsear el número.
* Si no se especifica, usa el locale de TerraConfig o "en" como fallback.
*/
locale?: string;
/**
* Opciones de formato. Provienen de `NumrFormat` (librería glob),
* sin dependencia directa de `Intl.NumberFormatOptions`.
*/
formatOptions?: NumrFormat;
/**
* Dirección de escritura del campo.
* Si no se especifica, hereda de TerraConfig o el locale activo.
*/
dir?: Direction;
/** Deshabilita el campo completo. */
disabled?: boolean;
/** Pone el campo en modo solo lectura. */
readonly?: boolean;
/** Marca el campo como requerido en formularios. */
required?: boolean;
/**
* Nombre del campo para envío de formulario.
* Genera un `<input type="hidden">` con este nombre y el valor numérico.
*/
name?: string;
/** Fuerza el estado inválido visualmente. */
invalid?: boolean;
};
export type NumberFieldRootPropsWithoutHTML = WithChildren<BaseNumberFieldRootProps>;
export type NumberFieldRootProps = NumberFieldRootPropsWithoutHTML;
// ─── Label ────────────────────────────────────────────────────────────────────
export type NumberFieldLabelPropsWithoutHTML = WithChild;
export type NumberFieldLabelProps = NumberFieldLabelPropsWithoutHTML &
Without<TerraPrimitiveLabelAttributes, NumberFieldLabelPropsWithoutHTML>;
// ─── Input ────────────────────────────────────────────────────────────────────
export type NumberFieldInputPropsWithoutHTML = WithChild;
export type NumberFieldInputProps = NumberFieldInputPropsWithoutHTML &
Without<TerraPrimitiveInputAttributes, NumberFieldInputPropsWithoutHTML>;
// ─── Increment / Decrement ───────────────────────────────────────────────────
export type NumberFieldIncrementPropsWithoutHTML = WithChild;
export type NumberFieldIncrementProps = NumberFieldIncrementPropsWithoutHTML &
Without<TerraPrimitiveButtonAttributes, NumberFieldIncrementPropsWithoutHTML>;
export type NumberFieldDecrementPropsWithoutHTML = WithChild;
export type NumberFieldDecrementProps = NumberFieldDecrementPropsWithoutHTML &
Without<TerraPrimitiveButtonAttributes, NumberFieldDecrementPropsWithoutHTML>;
Loading…
Cancel
Save

Powered by TurnKey Linux.