- 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
parent
bf9042ad92
commit
ffdbb79234
@ -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 };
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
Loading…
Reference in new issue