From ffdbb79234bd2b8168ad040133bb4392073cbee4 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 31 Mar 2026 23:59:09 +0200 Subject: [PATCH] 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 --- src/lib/glob/lib/numr_engine.svelte.ts | 34 +- src/lib/glob/lib/numr_engine.ts | 147 +++--- src/lib/glob/lib/numr_types.ts | 206 ++++++++- src/routes/test/number-field/+page.svelte | 366 +++++++++++++++ src/routes/test/terra/+page.svelte | 1 + src/uix/terra/index.ts | 5 +- .../components/number-field-decrement.svelte | 31 ++ .../components/number-field-increment.svelte | 31 ++ .../components/number-field-input.svelte | 39 ++ .../components/number-field-label.svelte | 31 ++ .../components/number-field-root.svelte | 44 ++ src/uix/terra/number-field/exports.ts | 13 + src/uix/terra/number-field/index.ts | 2 + .../terra/number-field/number-field.svelte.ts | 429 ++++++++++++++++++ src/uix/terra/number-field/types.ts | 109 +++++ 15 files changed, 1395 insertions(+), 93 deletions(-) create mode 100644 src/routes/test/number-field/+page.svelte create mode 100644 src/uix/terra/number-field/components/number-field-decrement.svelte create mode 100644 src/uix/terra/number-field/components/number-field-increment.svelte create mode 100644 src/uix/terra/number-field/components/number-field-input.svelte create mode 100644 src/uix/terra/number-field/components/number-field-label.svelte create mode 100644 src/uix/terra/number-field/components/number-field-root.svelte create mode 100644 src/uix/terra/number-field/exports.ts create mode 100644 src/uix/terra/number-field/index.ts create mode 100644 src/uix/terra/number-field/number-field.svelte.ts create mode 100644 src/uix/terra/number-field/types.ts diff --git a/src/lib/glob/lib/numr_engine.svelte.ts b/src/lib/glob/lib/numr_engine.svelte.ts index 7840706f7..dc5ee7711 100644 --- a/src/lib/glob/lib/numr_engine.svelte.ts +++ b/src/lib/glob/lib/numr_engine.svelte.ts @@ -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); + }, + }; } diff --git a/src/lib/glob/lib/numr_engine.ts b/src/lib/glob/lib/numr_engine.ts index 35369b179..313933ca3 100644 --- a/src/lib/glob/lib/numr_engine.ts +++ b/src/lib/glob/lib/numr_engine.ts @@ -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 = { - 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 }; } diff --git a/src/lib/glob/lib/numr_types.ts b/src/lib/glob/lib/numr_types.ts index d7afd111a..0b3c99290 100644 --- a/src/lib/glob/lib/numr_types.ts +++ b/src/lib/glob/lib/numr_types.ts @@ -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; + +// ─── 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; } diff --git a/src/routes/test/number-field/+page.svelte b/src/routes/test/number-field/+page.svelte new file mode 100644 index 000000000..52c12b367 --- /dev/null +++ b/src/routes/test/number-field/+page.svelte @@ -0,0 +1,366 @@ + + + + NumberField + + +
+
+
+

Terra

+

NumberField

+

+ Primitivo headless para entrada de valores numéricos. Formato localizado, soporte de + teclado completo (flechas, PgUp/PgDn, Home/End) e integración con Form. +

+
+ +
+ +
+
+

Básico

+
+
+
+

Sin límites

+

Valor: {basic ?? '—'}

+ +
+ Cantidad +
+ − + + + +
+
+
+
+ +
+

Con min / max / step

+

Valor: {clamped ?? '—'}

+ +
+ Rating (0–10, paso 0.5) +
+ − + + + +
+
+
+
+
+
+ +
+
+

Formato

+
+
+
+

Moneda (EUR)

+

Valor: {currency ?? '—'}

+ +
+ Importe +
+ − + + + +
+
+
+
+ +
+

Porcentaje

+

Valor: {percent ?? '—'}

+ +
+ Descuento +
+ − + + + +
+
+
+
+
+
+ +
+
+

Integración con Form

+
+
+
+

Envío nativo

+

+ El campo publica el valor en form.quantity como input hidden. Al enviar el + formulario se captura el valor sin JS extra. +

+ +
+ +
+ Cantidad +
+ − + + + +
+
+
+ +
+ + {#if formResult} +
{formResult}
+ {/if} +
+ +
+

Estado deshabilitado

+ +
+ Precio (bloqueado) +
+ − + + + +
+
+
+
+
+
+
+ + diff --git a/src/routes/test/terra/+page.svelte b/src/routes/test/terra/+page.svelte index 661710710..369c4f581 100644 --- a/src/routes/test/terra/+page.svelte +++ b/src/routes/test/terra/+page.svelte @@ -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' }, diff --git a/src/uix/terra/index.ts b/src/uix/terra/index.ts index adae80cc6..6cfb906dd 100644 --- a/src/uix/terra/index.ts +++ b/src/uix/terra/index.ts @@ -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'; diff --git a/src/uix/terra/number-field/components/number-field-decrement.svelte b/src/uix/terra/number-field/components/number-field-decrement.svelte new file mode 100644 index 000000000..4f277f2da --- /dev/null +++ b/src/uix/terra/number-field/components/number-field-decrement.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/number-field/components/number-field-increment.svelte b/src/uix/terra/number-field/components/number-field-increment.svelte new file mode 100644 index 000000000..3560ac7c9 --- /dev/null +++ b/src/uix/terra/number-field/components/number-field-increment.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/number-field/components/number-field-input.svelte b/src/uix/terra/number-field/components/number-field-input.svelte new file mode 100644 index 000000000..b5c6d2cd6 --- /dev/null +++ b/src/uix/terra/number-field/components/number-field-input.svelte @@ -0,0 +1,39 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} + +{#if root.opts.name.current} + +{/if} diff --git a/src/uix/terra/number-field/components/number-field-label.svelte b/src/uix/terra/number-field/components/number-field-label.svelte new file mode 100644 index 000000000..9332592ce --- /dev/null +++ b/src/uix/terra/number-field/components/number-field-label.svelte @@ -0,0 +1,31 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/number-field/components/number-field-root.svelte b/src/uix/terra/number-field/components/number-field-root.svelte new file mode 100644 index 000000000..3fd8ffdfe --- /dev/null +++ b/src/uix/terra/number-field/components/number-field-root.svelte @@ -0,0 +1,44 @@ + + +{@render children?.()} diff --git a/src/uix/terra/number-field/exports.ts b/src/uix/terra/number-field/exports.ts new file mode 100644 index 000000000..e6da95127 --- /dev/null +++ b/src/uix/terra/number-field/exports.ts @@ -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'; diff --git a/src/uix/terra/number-field/index.ts b/src/uix/terra/number-field/index.ts new file mode 100644 index 000000000..7994fb8f3 --- /dev/null +++ b/src/uix/terra/number-field/index.ts @@ -0,0 +1,2 @@ +export * as NumberField from './exports'; +export * from './exports'; diff --git a/src/uix/terra/number-field/number-field.svelte.ts b/src/uix/terra/number-field/number-field.svelte.ts new file mode 100644 index 000000000..8b2a2e43f --- /dev/null +++ b/src/uix/terra/number-field/number-field.svelte.ts @@ -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('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 | 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