Skip to content

NumberField — live demo

This is the real @rozie-ui/number-field-vue package running on this page (VitePress is itself a Vue app). Type a value (it parses + clamps on blur), use the +/- buttons (hold one to watch the press-and-hold acceleration ramp), or focus the field and press Arrow / PageUp·Down / Home / End. Everything below is driven by the same NumberField.rozie source that compiles to all six frameworks, built on a native <input> with no engine and no required CSS — the platform input behaviour, the clamp/snap math, and a tokenised skin all ship inside the component.

modelValue is two-way bound with v-model:modelValue — the readout updates the instant you commit, and a consumer write flows back in. The value is always clamped to [min, max] and snapped to step; set formatOptions for locale-aware display (currency above), and listen to @change for the new value. The quantity instance's buttons drive the imperative handle (increment(), clear(), focus()) grabbed through Vue's ref. See the full API for every prop, event, and handle verb, plus theming and the keyboard reference.

One source, six outputs

You author the component once as a .rozie file:

html
<!--
  NumberField.rozie — a headless, WAI-ARIA accessible numeric stepper / spinbutton.

  A pure-Rozie family (NO third-party engine) in the spirit of otp / slider /
  listbox: it fills a real cross-framework need (every form UI re-implements a
  clamped, step-snapped, locale-formatted number input with +/- controls,
  keyboard, and press-and-hold acceleration) with zero engine dependency. The
  platform IS the engine: a native <input> for text entry, browser focus, and
  `Intl.NumberFormat` for locale-aware parse/format. Rozie owns the author-side
  API: the two-way binding, clamp/snap math, keyboard choreography, the
  press-hold ramp, and the token-themed skin.

  CONTROLLED, NO DRAFT NUMERIC STATE: the numeric value IS `modelValue` (the sole
  model:true prop → Angular ControlValueAccessor; a number field IS a form
  control). `modelValue` is `number | null` — null is the empty field. We DO keep
  ONE piece of local <data> — `text`, the raw string the user is typing — because
  a half-typed entry ("1.", "-", "") is not yet a valid number; it is parsed and
  committed back to `modelValue` on blur / Enter. (`text` is the editing buffer,
  not a second source of truth: it is re-seeded from `modelValue` whenever the
  field is not focused, via `displayText()`.)

  PRESS-HOLD ACCELERATION uses a top-level mutable handle (`holdTimer`) declared
  as a TOP-LEVEL `let` so the React emitter hoists it to a `useRef` and the Solid
  `$onMount`/`onCleanup` split can still see it in teardown (the cross-phase-
  state-in-$onMount-must-be-top-level gotcha). Every started repeat is torn down
  on pointerup / pointerleave / unmount.

  FOCUS without per-cell refs: $refs.input (ONE input ref) is read only in
  $onMount / event handlers / $expose verbs (all post-mount → ROZ123-safe), and
  it works inside Lit's shadow root because the input lives there too.

  Authoring notes (collision classes — see the authoring playbook §6):
    - The focus verb is `focus` — a DELIBERATE override of the inherited
      HTMLElement.focus on the Lit custom element (ROZ137 WARNS, accepted; the
      public focus() handle is intended). Same choice slider/otp made.
    - NO helper/const is named `value` / `min` / `max` / `step` / `inputMode` /
      `id` / `title` / `tabIndex`: any of those as a top-level binding becomes a
      Lit class field and collides with an inherited DOM property (hard TS2416).
      The numeric helpers are `clampValue` / `snapValue` / `readValue` — never a
      bare `value`/`valueOf`. (`valueOf` would cascade TS1240/1271 across the Lit
      class via Object.prototype; we use `readValue`.)
    - NO plain helper is named `writeValue`/`registerOnChange`/`registerOnTouched`/
      `setDisabledState`: with a single model:true prop those collide with the
      generated Angular ControlValueAccessor (TS2300). The write funnel is
      `commitValue` (the otp precedent).
    - The editing-buffer <data> key is `text`, NOT a name shared with any
      $expose verb (a $data key == $expose verb collapses on React useState).
    - Handler params are LEFT UNTYPED so they neutralize to `any`; reading
      `e.target.value` / `e.key` then typechecks across all six strict leaves.
    - Derived strings (`displayText`, `formatted`, `ariaText`) are PLAIN functions
      called `()` — never `$computed` — because they are read both in the template
      AND inside handlers, and a $computed is a value on React but an accessor on
      Solid (aliasing/calling diverges).
    - aria booleans are bound with `!!` (the rozieAttr→Booleanish JSX edge); the
      numeric aria-value* are bound from plain helpers returning number/string.

  Consumer example:

    <NumberField r-model:modelValue="$data.qty" :min="0" :max="10" :step="1"
                 :formatOptions="{ style: 'decimal' }" @change="onChange" />
-->

<rozie name="NumberField">

<props>
{
  // The numeric value (two-way). `null` is the empty field. As the sole
  // model:true prop it drives the Angular ControlValueAccessor — a number field
  // IS a form control. Read via `$props.modelValue`; written via `$model`.
  modelValue: {
    type: Number,
    default: null,
    model: true,
    docs: {
      description:
        'The numeric value of the field (two-way `r-model`). `null` means the field is empty. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a number field **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). The value is clamped to `[min, max]` and snapped to `step` on every commit.',
      example: '<NumberField r-model:modelValue="qty" :min="0" :max="10" />',
    },
  },

  // Lower bound (inclusive). The value is clamped to >= min on every commit; Home
  // jumps the value to min. NOTE: a prop named `min` is safe on the Lit class —
  // `min` is an inherited DOM property of <input>, not of HTMLElement / LitElement.
  min: {
    type: Number,
    default: null,
    docs: {
      description:
        'Inclusive lower bound. Every commit clamps the value to `>= min`, and the **Home** key jumps to `min`. `null` (the default) means no lower bound. Also emitted as `aria-valuemin`.',
    },
  },

  // Upper bound (inclusive). The value is clamped to <= max on every commit; End
  // jumps the value to max.
  max: {
    type: Number,
    default: null,
    docs: {
      description:
        'Inclusive upper bound. Every commit clamps the value to `<= max`, and the **End** key jumps to `max`. `null` (the default) means no upper bound. Also emitted as `aria-valuemax`.',
    },
  },

  // The increment/decrement granularity. ArrowUp/Down step by this; the value is
  // snapped to the nearest multiple of `step` measured from `min` (or 0).
  step: {
    type: Number,
    default: 1,
    docs: {
      description:
        'The increment/decrement granularity. **ArrowUp** / **ArrowDown** and the +/- buttons change the value by `step`, and every commit snaps the value to the nearest multiple of `step` measured from `min` (or `0` when `min` is `null`).',
    },
  },

  // The coarse step for PageUp / PageDown.
  largeStep: {
    type: Number,
    default: 10,
    docs: {
      description:
        'The coarse step applied by **PageUp** / **PageDown**, for fast traversal of a wide range.',
    },
  },

  // Options passed to `Intl.NumberFormat` for locale-aware display formatting
  // (style, currency, minimumFractionDigits, …). Parsing strips the formatting
  // back off on commit.
  formatOptions: {
    type: Object,
    default: () => ({}),
    docs: {
      description:
        'Options forwarded to `Intl.NumberFormat` for locale-aware **display** formatting (e.g. `{ style: "currency", currency: "USD" }` or `{ minimumFractionDigits: 2 }`). The displayed text is formatted while the field is unfocused; on commit the formatting is stripped back off and the raw number is parsed.',
      example: ":formatOptions=\"{ style: 'currency', currency: 'USD' }\"",
    },
  },

  // Opt-in: drag horizontally on the field to scrub the value (±step per few px).
  // Boolean props default FALSE (the negative-opt-in convention).
  allowScrub: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opt in to **scrub-on-drag**: press and drag horizontally on the field to change the value by `step` per few pixels (a power-user affordance). Off by default.',
    },
  },

  // Disable the whole control (also sets the Angular CVA disabled state).
  disabled: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Disable the whole control — the input, both steppers, the keyboard, and scrubbing. Also sets the Angular `ControlValueAccessor` disabled state.',
    },
  },

  // Read-only: the value is shown and focusable but cannot be edited.
  readonly: {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Make the field read-only — the value is shown and focusable but cannot be changed by typing, the steppers, the keyboard, or scrubbing.',
    },
  },

  // Accessible name for the spinbutton (role="spinbutton").
  ariaLabel: {
    type: String,
    default: null,
    docs: {
      description:
        'Accessible name applied to the `role="spinbutton"` input (`aria-label`). Provide this (or an external `<label>`) so the control is announced.',
    },
  },
}
</props>

<data>
{
  // The raw text in the <input> while the user is editing (a half-typed entry
  // like "1." or "-" is not yet a valid number). Parsed + committed on blur /
  // Enter; re-seeded from modelValue via displayText() whenever NOT focused.
  // Named `text` (not shared with any $expose verb) → safe on React useState.
  text: '',
  // Whether the input currently has focus (drives raw-vs-formatted display).
  focused: false,
}
</data>

<script lang="ts">
// ---- top-level mutable handles (hook-referenced → React useRef hoist) -------
// The press-hold repeat timer + its current interval (the ramp). Declared at the
// top level so React hoists them to useRef and the Solid onMount/onCleanup split
// sees them in teardown. `null`/0 when no repeat is running.
let holdTimer: any = null
let holdInterval = 0

// Scrub-on-drag state (also top-level so teardown sees it).
let scrubbing = false
let scrubStartX = 0
let scrubStartValue = 0

// ---- numeric helpers (plain functions, uniform ×6) -------------------------
// The current value as a real number, or null when empty. Named readValue, NOT
// valueOf — a `valueOf` binding cascades TS1240/1271 across the Lit class via
// Object.prototype.
const readValue = () => {
  const v = $props.modelValue
  return typeof v === 'number' && !Number.isNaN(v) ? v : null
}

const hasMin = () => typeof $props.min === 'number' && !Number.isNaN($props.min)
const hasMax = () => typeof $props.max === 'number' && !Number.isNaN($props.max)

// Clamp n to [min, max] (whichever bounds are set).
const clampValue = (n) => {
  let out = n
  if (hasMin() && out < $props.min) out = $props.min
  if (hasMax() && out > $props.max) out = $props.max
  return out
}

// Snap n to the nearest multiple of `step` measured from `min` (or 0).
const snapValue = (n) => {
  const stepSize = typeof $props.step === 'number' && $props.step > 0 ? $props.step : 1
  const base = hasMin() ? $props.min : 0
  const snapped = base + Math.round((n - base) / stepSize) * stepSize
  // Avoid binary-float drift (e.g. 0.1 + 0.2) by rounding to step precision.
  const decimals = (String(stepSize).split('.')[1] || '').length
  return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped
}

// ---- locale formatting (plain functions, uniform ×6) -----------------------
const formatter = () => {
  try {
    return new Intl.NumberFormat(undefined, $props.formatOptions || {})
  } catch {
    return new Intl.NumberFormat()
  }
}

// The value formatted for display (empty string when null).
const formatted = () => {
  const n = readValue()
  return n === null ? '' : formatter().format(n)
}

// Parse a user-typed string back to a number, or null when it is not a number.
// Strips grouping separators + any non-numeric currency/percent chrome, keeping
// digits, a sign, a decimal point, and an exponent.
const parseText = (raw) => {
  if (raw == null) return null
  const s = String(raw).trim()
  if (s === '') return null
  const cleaned = s.replace(/[^0-9eE+\-.,]/g, '').replace(/,/g, '')
  const n = Number.parseFloat(cleaned)
  return Number.isNaN(n) ? null : n
}

// What the <input> should show: the live edit buffer while focused, otherwise
// the locale-formatted value. A plain function (read in the template + handlers).
const displayText = () => ($data.focused ? $data.text : formatted())

// ---- aria helpers (numbers/strings bound cleanly) --------------------------
const ariaText = () => {
  const n = readValue()
  return n === null ? '' : formatted()
}

// ---- write funnel (single $emit site) --------------------------------------
// Clamp + snap, write the model, mirror into the edit buffer, emit change. Named
// commitValue (NOT writeValue) so it does not collide with the generated Angular
// ControlValueAccessor.writeValue (TS2300).
const commitValue = (n) => {
  let next = n
  if (next !== null) {
    next = snapValue(next)
    next = clampValue(next)
  }
  $model.modelValue = next
  // Keep the edit buffer in sync so a focused field reflects a programmatic step.
  $data.text = next === null ? '' : String(next)
  $emit('change', { value: next })
}

// Step by a signed multiple of `step` (used by buttons + arrows). A null value
// seeds from min (or 0) so the first step lands on a sensible number.
const stepBy = (dir, size) => {
  if ($props.disabled || $props.readonly) return
  const cur = readValue()
  const stepSize = typeof size === 'number' ? size : (typeof $props.step === 'number' ? $props.step : 1)
  const base = cur === null ? (hasMin() ? $props.min : 0) : cur
  commitValue(base + dir * stepSize)
}

// ---- press-hold acceleration ----------------------------------------------
// Stop any running repeat (pointerup / pointerleave / unmount).
const stopHold = () => {
  if (holdTimer !== null) {
    clearTimeout(holdTimer)
    holdTimer = null
  }
  holdInterval = 0
}

// Start a repeating step that ramps from slow to fast while the button is held.
const startHold = (dir) => {
  if ($props.disabled || $props.readonly) return
  stopHold()
  stepBy(dir, $props.step)
  holdInterval = 300
  const tick = () => {
    stepBy(dir, $props.step)
    // Ramp: shorten the interval down to a floor for accelerating repeats.
    holdInterval = Math.max(40, Math.round(holdInterval * 0.8))
    holdTimer = setTimeout(tick, holdInterval)
  }
  holdTimer = setTimeout(tick, holdInterval)
}

// ---- input + keyboard handlers ---------------------------------------------
const onInput = (e) => {
  if ($props.readonly) return
  const raw = e && e.target ? e.target.value : ''
  $data.text = raw
}

// Commit the edit buffer on blur: parse → commit (or clear to null when empty).
const onBlur = () => {
  $data.focused = false
  const parsed = parseText($data.text)
  commitValue(parsed)
}

const onFocus = (e) => {
  $data.focused = true
  // Seed the edit buffer with the raw (unformatted) number so editing is clean.
  const n = readValue()
  $data.text = n === null ? '' : String(n)
  if (e && e.target && e.target.select) e.target.select()
}

const onKeydown = (e) => {
  if ($props.disabled || $props.readonly) return
  const key = e ? e.key : ''
  if (key === 'ArrowUp') {
    if (e) e.preventDefault()
    stepBy(1, $props.step)
  } else if (key === 'ArrowDown') {
    if (e) e.preventDefault()
    stepBy(-1, $props.step)
  } else if (key === 'PageUp') {
    if (e) e.preventDefault()
    stepBy(1, $props.largeStep)
  } else if (key === 'PageDown') {
    if (e) e.preventDefault()
    stepBy(-1, $props.largeStep)
  } else if (key === 'Home') {
    if (hasMin()) {
      if (e) e.preventDefault()
      commitValue($props.min)
    }
  } else if (key === 'End') {
    if (hasMax()) {
      if (e) e.preventDefault()
      commitValue($props.max)
    }
  } else if (key === 'Enter') {
    // Commit the buffer without losing focus.
    const parsed = parseText($data.text)
    commitValue(parsed)
  }
}

// ---- scrub-on-drag (opt-in) ------------------------------------------------
// Uses POINTER CAPTURE on the input element itself (set on pointerdown) so the
// pointermove/pointerup keep firing on the same element through the whole drag,
// even when the pointer leaves the element — no document-level <listeners> (which
// would also avoid the React-effect `$event`-in-deps emitter edge). The handlers
// are bound directly on the <input> in the template, where `@event` passes a
// properly-typed `$event`.
const onScrubDown = (e) => {
  if (!$props.allowScrub || $props.disabled || $props.readonly) return
  scrubbing = true
  scrubStartX = e && typeof e.clientX === 'number' ? e.clientX : 0
  const cur = readValue()
  scrubStartValue = cur === null ? (hasMin() ? $props.min : 0) : cur
  // Capture the pointer so move/up stay on this element for the whole drag.
  if (e && e.target && e.target.setPointerCapture && typeof e.pointerId === 'number') {
    try { e.target.setPointerCapture(e.pointerId) } catch {}
  }
}

const onScrubMove = (e) => {
  if (!scrubbing) return
  const x = e && typeof e.clientX === 'number' ? e.clientX : 0
  const dx = x - scrubStartX
  const stepSize = typeof $props.step === 'number' && $props.step > 0 ? $props.step : 1
  // One step per 8px of horizontal travel.
  const delta = Math.round(dx / 8) * stepSize
  commitValue(scrubStartValue + delta)
}

const onScrubUp = () => {
  scrubbing = false
}

// ---- lifecycle + imperative handle -----------------------------------------
$onMount(() => {
  // Seed the edit buffer so a programmatic focus shows the right text.
  const n = readValue()
  $data.text = n === null ? '' : String(n)
  // Tear down any running repeat / scrub on unmount.
  return () => {
    stopHold()
    scrubbing = false
  }
})

// focus() — move DOM focus to the input. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted). increment()/decrement() —
// step once by `step`. clear() — set the value to null and clear the buffer.
const focus = () => {
  const el = $refs.input
  // NOTE: $refs.input types to the generic HTMLElement on the tsdown/vue leaves
  // (the emitter ref-type map has no `input` → HTMLInputElement entry), so we
  // only touch HTMLElement members here (`focus`). Text selection happens in the
  // onFocus handler, where `e.target` is `any` and `.select()` typechecks.
  if (el && el.focus) el.focus()
}
const increment = () => stepBy(1, $props.step)
const decrement = () => stepBy(-1, $props.step)
const clear = () => {
  commitValue(null)
  $data.text = ''
}

$expose({ focus, increment, decrement, clear })
</script>

<template>
<div
  class="rozie-number-field"
  :class="{ 'rozie-number-field--disabled': $props.disabled }"
>
  <button
    type="button"
    class="rozie-number-field-btn rozie-number-field-btn--dec"
    tabindex="-1"
    aria-label="Decrement"
    :disabled="!!$props.disabled || !!$props.readonly"
    @pointerdown="startHold(-1)"
    @pointerup="stopHold()"
    @pointerleave="stopHold()"
  >−</button>

  <input
    ref="input"
    class="rozie-number-field-input"
    type="text"
    inputmode="decimal"
    autocomplete="off"
    role="spinbutton"
    :value="displayText()"
    :disabled="!!$props.disabled"
    :readonly="!!$props.readonly"
    :aria-label="$props.ariaLabel"
    :aria-valuemin="$props.min"
    :aria-valuemax="$props.max"
    :aria-valuenow="$props.modelValue"
    :aria-valuetext="ariaText()"
    :aria-disabled="!!$props.disabled"
    @input="onInput($event)"
    @focus="onFocus($event)"
    @blur="onBlur()"
    @keydown="onKeydown($event)"
    @pointerdown="onScrubDown($event)"
    @pointermove="onScrubMove($event)"
    @pointerup="onScrubUp()"
  />

  <button
    type="button"
    class="rozie-number-field-btn rozie-number-field-btn--inc"
    tabindex="-1"
    aria-label="Increment"
    :disabled="!!$props.disabled || !!$props.readonly"
    @pointerdown="startHold(1)"
    @pointerup="stopHold()"
    @pointerleave="stopHold()"
  >+</button>
</div>
</template>

<style>
/*
  Fully token-driven (mirrors otp/slider/themes): EVERY visual value is a
  `var(--rozie-number-field-*, <fallback>)`, so the component renders with zero
  config yet is completely re-skinnable by setting tokens at any ancestor scope.
  The shipped themes/*.css presets map these tokens onto shadcn/Radix, Material
  3, Bootstrap 5.
*/
.rozie-number-field {
  display: inline-flex;
  align-items: stretch;
  gap: var(--rozie-number-field-gap, 0);
  font: var(--rozie-number-field-font, inherit);
  border: var(--rozie-number-field-border-width, 1px) solid var(--rozie-number-field-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-number-field-radius, 0.5rem);
  background: var(--rozie-number-field-bg, #fff);
  overflow: hidden;
}
.rozie-number-field-input {
  box-sizing: border-box;
  width: var(--rozie-number-field-width, 4.5rem);
  min-width: 0;
  padding: var(--rozie-number-field-padding, 0.375rem 0.5rem);
  text-align: var(--rozie-number-field-text-align, right);
  font: inherit;
  font-size: var(--rozie-number-field-font-size, 1rem);
  color: var(--rozie-number-field-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-number-field-input:focus {
  box-shadow: inset 0 0 0 var(--rozie-number-field-focus-ring-width, 2px) var(--rozie-number-field-focus-ring-color, rgba(0, 102, 204, 0.35));
}
.rozie-number-field-btn {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-number-field-btn-size, 2rem);
  padding: 0;
  font-size: var(--rozie-number-field-btn-font-size, 1.1rem);
  line-height: 1;
  color: var(--rozie-number-field-btn-color, inherit);
  background: var(--rozie-number-field-btn-bg, rgba(0, 0, 0, 0.04));
  border: none;
  cursor: pointer;
  user-select: none;
  transition: background-color 0.15s;
}
.rozie-number-field-btn:hover {
  background: var(--rozie-number-field-btn-hover-bg, rgba(0, 0, 0, 0.08));
}
.rozie-number-field-btn:disabled {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}
.rozie-number-field--disabled {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}
</style>

</rozie>

…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/number-field-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { clsx, rozieAttr, useControllableState } from '@rozie/runtime-react';
import './NumberField.css';

interface NumberFieldProps {
  /**
   * The numeric value of the field (two-way `r-model`). `null` means the field is empty. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a number field **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). The value is clamped to `[min, max]` and snapped to `step` on every commit.
   * @example
   * <NumberField r-model:modelValue="qty" :min="0" :max="10" />
   */
  modelValue?: (number) | null;
  defaultModelValue?: (number) | null;
  onModelValueChange?: (modelValue: (number) | null) => void;
  /**
   * Inclusive lower bound. Every commit clamps the value to `>= min`, and the **Home** key jumps to `min`. `null` (the default) means no lower bound. Also emitted as `aria-valuemin`.
   */
  min?: (number) | null;
  /**
   * Inclusive upper bound. Every commit clamps the value to `<= max`, and the **End** key jumps to `max`. `null` (the default) means no upper bound. Also emitted as `aria-valuemax`.
   */
  max?: (number) | null;
  /**
   * The increment/decrement granularity. **ArrowUp** / **ArrowDown** and the +/- buttons change the value by `step`, and every commit snaps the value to the nearest multiple of `step` measured from `min` (or `0` when `min` is `null`).
   */
  step?: number;
  /**
   * The coarse step applied by **PageUp** / **PageDown**, for fast traversal of a wide range.
   */
  largeStep?: number;
  /**
   * Options forwarded to `Intl.NumberFormat` for locale-aware **display** formatting (e.g. `{ style: "currency", currency: "USD" }` or `{ minimumFractionDigits: 2 }`). The displayed text is formatted while the field is unfocused; on commit the formatting is stripped back off and the raw number is parsed.
   * @example
   * :formatOptions="{ style: 'currency', currency: 'USD' }"
   */
  formatOptions?: Record<string, any>;
  /**
   * Opt in to **scrub-on-drag**: press and drag horizontally on the field to change the value by `step` per few pixels (a power-user affordance). Off by default.
   */
  allowScrub?: boolean;
  /**
   * Disable the whole control — the input, both steppers, the keyboard, and scrubbing. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Make the field read-only — the value is shown and focusable but cannot be changed by typing, the steppers, the keyboard, or scrubbing.
   */
  readonly?: boolean;
  /**
   * Accessible name applied to the `role="spinbutton"` input (`aria-label`). Provide this (or an external `<label>`) so the control is announced.
   */
  ariaLabel?: (string) | null;
  onChange?: (...args: any[]) => void;
}

export interface NumberFieldHandle {
  focus: (...args: any[]) => any;
  increment: (...args: any[]) => any;
  decrement: (...args: any[]) => any;
  clear: (...args: any[]) => any;
}

const NumberField = forwardRef<NumberFieldHandle, NumberFieldProps>(function NumberField(_props: NumberFieldProps, ref): JSX.Element {
  const __defaultFormatOptions = useState(() => (() => ({}))())[0];
  const props: Omit<NumberFieldProps, 'min' | 'max' | 'step' | 'largeStep' | 'formatOptions' | 'allowScrub' | 'disabled' | 'readonly' | 'ariaLabel'> & { min: (number) | null; max: (number) | null; step: number; largeStep: number; formatOptions: Record<string, any>; allowScrub: boolean; disabled: boolean; readonly: boolean; ariaLabel: (string) | null } = {
    ..._props,
    min: _props.min ?? null,
    max: _props.max ?? null,
    step: _props.step ?? 1,
    largeStep: _props.largeStep ?? 10,
    formatOptions: _props.formatOptions ?? __defaultFormatOptions,
    allowScrub: _props.allowScrub ?? false,
    disabled: _props.disabled ?? false,
    readonly: _props.readonly ?? false,
    ariaLabel: _props.ariaLabel ?? null,
  };
  const attrs: Record<string, unknown> = (() => {
    const { modelValue, min, max, step, largeStep, formatOptions, allowScrub, disabled, readonly, ariaLabel, defaultValue, onModelValueChange, defaultModelValue, ...rest } = _props as NumberFieldProps & Record<string, unknown>;
    void modelValue; void min; void max; void step; void largeStep; void formatOptions; void allowScrub; void disabled; void readonly; void ariaLabel; void defaultValue; void onModelValueChange; void defaultModelValue;
    return rest;
  })();
  const scrubbing = useRef(false);
  const holdTimer = useRef<any>(null);
  const holdInterval = useRef(0);
  const [modelValue, setModelValue] = useControllableState({
    value: props.modelValue,
    defaultValue: props.defaultModelValue ?? null,
    onValueChange: props.onModelValueChange,
  });
  const [text, setText] = useState('');
  const [focused, setFocused] = useState(false);
  const input = useRef<HTMLInputElement | null>(null);

  let scrubStartX = 0;
  let scrubStartValue = 0;

  // ---- numeric helpers (plain functions, uniform ×6) -------------------------
  // The current value as a real number, or null when empty. Named readValue, NOT
  // valueOf — a `valueOf` binding cascades TS1240/1271 across the Lit class via
  // Object.prototype.
  const readValue = useCallback(() => {
    const v = modelValue;
    return typeof v === 'number' && !Number.isNaN(v) ? v : null;
  }, [modelValue]);
  function hasMin() {
    return typeof props.min === 'number' && !Number.isNaN(props.min);
  }
  function hasMax() {
    return typeof props.max === 'number' && !Number.isNaN(props.max);
  }
  function clampValue(n: any) {
    let out = n;
    if (hasMin() && out < props.min) out = props.min;
    if (hasMax() && out > props.max) out = props.max;
    return out;
  }
  function snapValue(n: any) {
    const stepSize = typeof props.step === 'number' && props.step > 0 ? props.step : 1;
    const base = hasMin() ? props.min : 0;
    const snapped = base + Math.round((n - base) / stepSize) * stepSize;
    // Avoid binary-float drift (e.g. 0.1 + 0.2) by rounding to step precision.
    const decimals = (String(stepSize).split('.')[1] || '').length;
    return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped;
  }
  function formatter() {
    try {
      return new Intl.NumberFormat(undefined, props.formatOptions || {});
    } catch {
      return new Intl.NumberFormat();
    }
  }
  function formatted() {
    const n = readValue();
    return n === null ? '' : formatter().format(n);
  }
  function parseText(raw: any) {
    if (raw == null) return null;
    const s = String(raw).trim();
    if (s === '') return null;
    const cleaned = s.replace(/[^0-9eE+\-.,]/g, '').replace(/,/g, '');
    const n = Number.parseFloat(cleaned);
    return Number.isNaN(n) ? null : n;
  }
  function displayText() {
    return focused ? text : formatted();
  }
  function ariaText() {
    const n = readValue();
    return n === null ? '' : formatted();
  }
  function commitValue(n: any) {
    let next = n;
    if (next !== null) {
      next = snapValue(next);
      next = clampValue(next);
    }
    setModelValue(next);
    // Keep the edit buffer in sync so a focused field reflects a programmatic step.
    setText(next === null ? '' : String(next));
    props.onChange && props.onChange({
      value: next
    });
  }
  function stepBy(dir: any, size: any) {
    if (props.disabled || props.readonly) return;
    const cur = readValue();
    const stepSize = typeof size === 'number' ? size : typeof props.step === 'number' ? props.step : 1;
    const base = cur === null ? hasMin() ? props.min : 0 : cur;
    commitValue(base + dir * stepSize);
  }
  const stopHold = useCallback(() => {
    if (holdTimer.current !== null) {
      clearTimeout(holdTimer.current);
      holdTimer.current = null;
    }
    holdInterval.current = 0;
  }, []);
  const startHold = useCallback((dir: any) => {
    if (props.disabled || props.readonly) return;
    stopHold();
    stepBy(dir, props.step);
    holdInterval.current = 300;
    const tick = () => {
      stepBy(dir, props.step);
      // Ramp: shorten the interval down to a floor for accelerating repeats.
      holdInterval.current = Math.max(40, Math.round(holdInterval.current * 0.8));
      holdTimer.current = setTimeout(tick, holdInterval.current);
    };
    holdTimer.current = setTimeout(tick, holdInterval.current);
  }, [props.disabled, props.readonly, props.step, stepBy, stopHold]);
  const onInput = useCallback((e: any) => {
    if (props.readonly) return;
    const raw = e && e.target ? e.target.value : '';
    setText(raw);
  }, [props.readonly]);
  const onBlur = useCallback(() => {
    setFocused(false);
    const parsed = parseText(text);
    commitValue(parsed);
  }, [commitValue, parseText, text]);
  const onFocus = useCallback((e: any) => {
    setFocused(true);
    // Seed the edit buffer with the raw (unformatted) number so editing is clean.
    const n = readValue();
    setText(n === null ? '' : String(n));
    if (e && e.target && e.target.select) e.target.select();
  }, [readValue]);
  const onKeydown = useCallback((e: any) => {
    if (props.disabled || props.readonly) return;
    const key = e ? e.key : '';
    if (key === 'ArrowUp') {
      if (e) e.preventDefault();
      stepBy(1, props.step);
    } else if (key === 'ArrowDown') {
      if (e) e.preventDefault();
      stepBy(-1, props.step);
    } else if (key === 'PageUp') {
      if (e) e.preventDefault();
      stepBy(1, props.largeStep);
    } else if (key === 'PageDown') {
      if (e) e.preventDefault();
      stepBy(-1, props.largeStep);
    } else if (key === 'Home') {
      if (hasMin()) {
        if (e) e.preventDefault();
        commitValue(props.min);
      }
    } else if (key === 'End') {
      if (hasMax()) {
        if (e) e.preventDefault();
        commitValue(props.max);
      }
    } else if (key === 'Enter') {
      // Commit the buffer without losing focus.
      const parsed = parseText(text);
      commitValue(parsed);
    }
  }, [commitValue, hasMax, hasMin, parseText, props.disabled, props.largeStep, props.max, props.min, props.readonly, props.step, stepBy, text]);
  const onScrubDown = useCallback((e: any) => {
    if (!props.allowScrub || props.disabled || props.readonly) return;
    scrubbing.current = true;
    scrubStartX = e && typeof e.clientX === 'number' ? e.clientX : 0;
    const cur = readValue();
    scrubStartValue = cur === null ? hasMin() ? props.min : 0 : cur;
    // Capture the pointer so move/up stay on this element for the whole drag.
    if (e && e.target && e.target.setPointerCapture && typeof e.pointerId === 'number') {
      try {
        e.target.setPointerCapture(e.pointerId);
      } catch {}
    }
  }, [hasMin, props.allowScrub, props.disabled, props.min, props.readonly, readValue]);
  const onScrubMove = useCallback((e: any) => {
    if (!scrubbing.current) return;
    const x = e && typeof e.clientX === 'number' ? e.clientX : 0;
    const dx = x - scrubStartX;
    const stepSize = typeof props.step === 'number' && props.step > 0 ? props.step : 1;
    // One step per 8px of horizontal travel.
    const delta = Math.round(dx / 8) * stepSize;
    commitValue(scrubStartValue + delta);
  }, [commitValue, props.step]);
  const onScrubUp = useCallback(() => {
    scrubbing.current = false;
  }, []);
  function focus() {
    const el = input.current;
    // NOTE: $refs.input types to the generic HTMLElement on the tsdown/vue leaves
    // (the emitter ref-type map has no `input` → HTMLInputElement entry), so we
    // only touch HTMLElement members here (`focus`). Text selection happens in the
    // onFocus handler, where `e.target` is `any` and `.select()` typechecks.
    if (el && el.focus) el.focus();
  }
  function increment() {
    return stepBy(1, props.step);
  }
  function decrement() {
    return stepBy(-1, props.step);
  }
  function clear() {
    commitValue(null);
    setText('');
  }

  useEffect(() => {
    // Seed the edit buffer so a programmatic focus shows the right text.
    const n = readValue();
    setText(n === null ? '' : String(n));
    // Tear down any running repeat / scrub on unmount.
    return () => {
      stopHold();
      scrubbing.current = false;
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const _rozieExposeRef = useRef({ focus, increment, decrement, clear });
  _rozieExposeRef.current = { focus, increment, decrement, clear };
  useImperativeHandle(ref, () => ({ focus: (...args: Parameters<typeof focus>): ReturnType<typeof focus> => _rozieExposeRef.current.focus(...args), increment: (...args: Parameters<typeof increment>): ReturnType<typeof increment> => _rozieExposeRef.current.increment(...args), decrement: (...args: Parameters<typeof decrement>): ReturnType<typeof decrement> => _rozieExposeRef.current.decrement(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args) }), []);

  return (
    <>
    <div {...attrs} className={clsx(clsx("rozie-number-field", { "rozie-number-field--disabled": props.disabled }), (attrs.className as string | undefined))} data-rozie-s-ceb089aa="">
      <button type="button" className={"rozie-number-field-btn rozie-number-field-btn--dec"} tabIndex={-1} aria-label="Decrement" disabled={!!props.disabled || !!props.readonly} onPointerDown={($event) => { startHold(-1); }} onPointerUp={($event) => { stopHold(); }} onPointerLeave={($event) => { stopHold(); }} data-rozie-s-ceb089aa="">−</button>

      <input ref={input} className={"rozie-number-field-input"} type="text" inputMode="decimal" autoComplete="off" role="spinbutton" value={displayText()} disabled={!!props.disabled} readOnly={!!props.readonly} aria-label={rozieAttr(props.ariaLabel)} aria-valuemin={(props.min) ?? undefined} aria-valuemax={(props.max) ?? undefined} aria-valuenow={(modelValue) ?? undefined} aria-valuetext={rozieAttr(ariaText())} aria-disabled={!!props.disabled} onInput={($event) => { onInput($event); }} onFocus={($event) => { onFocus($event); }} onBlur={($event) => { onBlur(); }} onKeyDown={($event) => { onKeydown($event); }} onPointerDown={($event) => { onScrubDown($event); }} onPointerMove={($event) => { onScrubMove($event); }} onPointerUp={($event) => { onScrubUp(); }} data-rozie-s-ceb089aa="" />

      <button type="button" className={"rozie-number-field-btn rozie-number-field-btn--inc"} tabIndex={-1} aria-label="Increment" disabled={!!props.disabled || !!props.readonly} onPointerDown={($event) => { startHold(1); }} onPointerUp={($event) => { stopHold(); }} onPointerLeave={($event) => { stopHold(); }} data-rozie-s-ceb089aa="">+</button>
    </div>
    </>
  );
});
export default NumberField;
vue
<template>

<div :class="['rozie-number-field', { 'rozie-number-field--disabled': props.disabled }]" v-bind="$attrs">
  <button type="button" class="rozie-number-field-btn rozie-number-field-btn--dec" tabindex="-1" aria-label="Decrement" :disabled="!!props.disabled || !!props.readonly" @pointerdown="startHold(-1)" @pointerup="stopHold()" @pointerleave="stopHold()">−</button>

  <input ref="inputRef" class="rozie-number-field-input" type="text" inputmode="decimal" autocomplete="off" role="spinbutton" :value="displayText()" :disabled="!!props.disabled" :readonly="!!props.readonly" :aria-label="props.ariaLabel" :aria-valuemin="(props.min) ?? undefined" :aria-valuemax="(props.max) ?? undefined" :aria-valuenow="(modelValue) ?? undefined" :aria-valuetext="ariaText()" :aria-disabled="!!props.disabled" @input="onInput($event)" @focus="onFocus($event)" @blur="onBlur()" @keydown="onKeydown($event)" @pointerdown="onScrubDown($event)" @pointermove="onScrubMove($event)" @pointerup="onScrubUp()" />

  <button type="button" class="rozie-number-field-btn rozie-number-field-btn--inc" tabindex="-1" aria-label="Increment" :disabled="!!props.disabled || !!props.readonly" @pointerdown="startHold(1)" @pointerup="stopHold()" @pointerleave="stopHold()">+</button>
</div>

</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * Inclusive lower bound. Every commit clamps the value to `>= min`, and the **Home** key jumps to `min`. `null` (the default) means no lower bound. Also emitted as `aria-valuemin`.
     */
    min?: number | null;
    /**
     * Inclusive upper bound. Every commit clamps the value to `<= max`, and the **End** key jumps to `max`. `null` (the default) means no upper bound. Also emitted as `aria-valuemax`.
     */
    max?: number | null;
    /**
     * The increment/decrement granularity. **ArrowUp** / **ArrowDown** and the +/- buttons change the value by `step`, and every commit snaps the value to the nearest multiple of `step` measured from `min` (or `0` when `min` is `null`).
     */
    step?: number;
    /**
     * The coarse step applied by **PageUp** / **PageDown**, for fast traversal of a wide range.
     */
    largeStep?: number;
    /**
     * Options forwarded to `Intl.NumberFormat` for locale-aware **display** formatting (e.g. `{ style: "currency", currency: "USD" }` or `{ minimumFractionDigits: 2 }`). The displayed text is formatted while the field is unfocused; on commit the formatting is stripped back off and the raw number is parsed.
     * @example
     * :formatOptions="{ style: 'currency', currency: 'USD' }"
     */
    formatOptions?: Record<string, any>;
    /**
     * Opt in to **scrub-on-drag**: press and drag horizontally on the field to change the value by `step` per few pixels (a power-user affordance). Off by default.
     */
    allowScrub?: boolean;
    /**
     * Disable the whole control — the input, both steppers, the keyboard, and scrubbing. Also sets the Angular `ControlValueAccessor` disabled state.
     */
    disabled?: boolean;
    /**
     * Make the field read-only — the value is shown and focusable but cannot be changed by typing, the steppers, the keyboard, or scrubbing.
     */
    readonly?: boolean;
    /**
     * Accessible name applied to the `role="spinbutton"` input (`aria-label`). Provide this (or an external `<label>`) so the control is announced.
     */
    ariaLabel?: string | null;
  }>(),
  { min: null, max: null, step: 1, largeStep: 10, formatOptions: () => ({}), allowScrub: false, disabled: false, readonly: false, ariaLabel: null }
);

/**
 * The numeric value of the field (two-way `r-model`). `null` means the field is empty. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a number field **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). The value is clamped to `[min, max]` and snapped to `step` on every commit.
 * @example
 * <NumberField r-model:modelValue="qty" :min="0" :max="10" />
 */
const modelValue = defineModel<number>('modelValue', { default: null });

const emit = defineEmits<{
  change: [...args: any[]];
}>();

const text = ref('');
const focused = ref(false);

const inputRef = ref<HTMLInputElement>();

// ---- top-level mutable handles (hook-referenced → React useRef hoist) -------
// The press-hold repeat timer + its current interval (the ramp). Declared at the
// top level so React hoists them to useRef and the Solid onMount/onCleanup split
// sees them in teardown. `null`/0 when no repeat is running.
let holdTimer: any = null;
let holdInterval = 0;

// Scrub-on-drag state (also top-level so teardown sees it).
// Scrub-on-drag state (also top-level so teardown sees it).
let scrubbing = false;
let scrubStartX = 0;
let scrubStartValue = 0;

// ---- numeric helpers (plain functions, uniform ×6) -------------------------
// The current value as a real number, or null when empty. Named readValue, NOT
// valueOf — a `valueOf` binding cascades TS1240/1271 across the Lit class via
// Object.prototype.
// ---- numeric helpers (plain functions, uniform ×6) -------------------------
// The current value as a real number, or null when empty. Named readValue, NOT
// valueOf — a `valueOf` binding cascades TS1240/1271 across the Lit class via
// Object.prototype.
const readValue = () => {
  const v = modelValue.value;
  return typeof v === 'number' && !Number.isNaN(v) ? v : null;
};
const hasMin = () => typeof props.min === 'number' && !Number.isNaN(props.min);
const hasMax = () => typeof props.max === 'number' && !Number.isNaN(props.max);

// Clamp n to [min, max] (whichever bounds are set).
// Clamp n to [min, max] (whichever bounds are set).
const clampValue = (n: any) => {
  let out = n;
  if (hasMin() && out < props.min) out = props.min;
  if (hasMax() && out > props.max) out = props.max;
  return out;
};

// Snap n to the nearest multiple of `step` measured from `min` (or 0).
// Snap n to the nearest multiple of `step` measured from `min` (or 0).
const snapValue = (n: any) => {
  const stepSize = typeof props.step === 'number' && props.step > 0 ? props.step : 1;
  const base = hasMin() ? props.min : 0;
  const snapped = base + Math.round((n - base) / stepSize) * stepSize;
  // Avoid binary-float drift (e.g. 0.1 + 0.2) by rounding to step precision.
  const decimals = (String(stepSize).split('.')[1] || '').length;
  return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped;
};

// ---- locale formatting (plain functions, uniform ×6) -----------------------
// ---- locale formatting (plain functions, uniform ×6) -----------------------
const formatter = () => {
  try {
    return new Intl.NumberFormat(undefined, props.formatOptions || {});
  } catch {
    return new Intl.NumberFormat();
  }
};

// The value formatted for display (empty string when null).
// The value formatted for display (empty string when null).
const formatted = () => {
  const n = readValue();
  return n === null ? '' : formatter().format(n);
};

// Parse a user-typed string back to a number, or null when it is not a number.
// Strips grouping separators + any non-numeric currency/percent chrome, keeping
// digits, a sign, a decimal point, and an exponent.
// Parse a user-typed string back to a number, or null when it is not a number.
// Strips grouping separators + any non-numeric currency/percent chrome, keeping
// digits, a sign, a decimal point, and an exponent.
const parseText = (raw: any) => {
  if (raw == null) return null;
  const s = String(raw).trim();
  if (s === '') return null;
  const cleaned = s.replace(/[^0-9eE+\-.,]/g, '').replace(/,/g, '');
  const n = Number.parseFloat(cleaned);
  return Number.isNaN(n) ? null : n;
};

// What the <input> should show: the live edit buffer while focused, otherwise
// the locale-formatted value. A plain function (read in the template + handlers).
// What the <input> should show: the live edit buffer while focused, otherwise
// the locale-formatted value. A plain function (read in the template + handlers).
const displayText = () => focused.value ? text.value : formatted();

// ---- aria helpers (numbers/strings bound cleanly) --------------------------
// ---- aria helpers (numbers/strings bound cleanly) --------------------------
const ariaText = () => {
  const n = readValue();
  return n === null ? '' : formatted();
};

// ---- write funnel (single $emit site) --------------------------------------
// Clamp + snap, write the model, mirror into the edit buffer, emit change. Named
// commitValue (NOT writeValue) so it does not collide with the generated Angular
// ControlValueAccessor.writeValue (TS2300).
// ---- write funnel (single $emit site) --------------------------------------
// Clamp + snap, write the model, mirror into the edit buffer, emit change. Named
// commitValue (NOT writeValue) so it does not collide with the generated Angular
// ControlValueAccessor.writeValue (TS2300).
const commitValue = (n: any) => {
  let next = n;
  if (next !== null) {
    next = snapValue(next);
    next = clampValue(next);
  }
  modelValue.value = next;
  // Keep the edit buffer in sync so a focused field reflects a programmatic step.
  text.value = next === null ? '' : String(next);
  emit('change', {
    value: next
  });
};

// Step by a signed multiple of `step` (used by buttons + arrows). A null value
// seeds from min (or 0) so the first step lands on a sensible number.
// Step by a signed multiple of `step` (used by buttons + arrows). A null value
// seeds from min (or 0) so the first step lands on a sensible number.
const stepBy = (dir: any, size: any) => {
  if (props.disabled || props.readonly) return;
  const cur = readValue();
  const stepSize = typeof size === 'number' ? size : typeof props.step === 'number' ? props.step : 1;
  const base = cur === null ? hasMin() ? props.min : 0 : cur;
  commitValue(base + dir * stepSize);
};

// ---- press-hold acceleration ----------------------------------------------
// Stop any running repeat (pointerup / pointerleave / unmount).
// ---- press-hold acceleration ----------------------------------------------
// Stop any running repeat (pointerup / pointerleave / unmount).
const stopHold = () => {
  if (holdTimer !== null) {
    clearTimeout(holdTimer);
    holdTimer = null;
  }
  holdInterval = 0;
};

// Start a repeating step that ramps from slow to fast while the button is held.
// Start a repeating step that ramps from slow to fast while the button is held.
const startHold = (dir: any) => {
  if (props.disabled || props.readonly) return;
  stopHold();
  stepBy(dir, props.step);
  holdInterval = 300;
  const tick = () => {
    stepBy(dir, props.step);
    // Ramp: shorten the interval down to a floor for accelerating repeats.
    holdInterval = Math.max(40, Math.round(holdInterval * 0.8));
    holdTimer = setTimeout(tick, holdInterval);
  };
  holdTimer = setTimeout(tick, holdInterval);
};

// ---- input + keyboard handlers ---------------------------------------------
// ---- input + keyboard handlers ---------------------------------------------
const onInput = (e: any) => {
  if (props.readonly) return;
  const raw = e && e.target ? e.target.value : '';
  text.value = raw;
};

// Commit the edit buffer on blur: parse → commit (or clear to null when empty).
// Commit the edit buffer on blur: parse → commit (or clear to null when empty).
const onBlur = () => {
  focused.value = false;
  const parsed = parseText(text.value);
  commitValue(parsed);
};
const onFocus = (e: any) => {
  focused.value = true;
  // Seed the edit buffer with the raw (unformatted) number so editing is clean.
  const n = readValue();
  text.value = n === null ? '' : String(n);
  if (e && e.target && e.target.select) e.target.select();
};
const onKeydown = (e: any) => {
  if (props.disabled || props.readonly) return;
  const key = e ? e.key : '';
  if (key === 'ArrowUp') {
    if (e) e.preventDefault();
    stepBy(1, props.step);
  } else if (key === 'ArrowDown') {
    if (e) e.preventDefault();
    stepBy(-1, props.step);
  } else if (key === 'PageUp') {
    if (e) e.preventDefault();
    stepBy(1, props.largeStep);
  } else if (key === 'PageDown') {
    if (e) e.preventDefault();
    stepBy(-1, props.largeStep);
  } else if (key === 'Home') {
    if (hasMin()) {
      if (e) e.preventDefault();
      commitValue(props.min);
    }
  } else if (key === 'End') {
    if (hasMax()) {
      if (e) e.preventDefault();
      commitValue(props.max);
    }
  } else if (key === 'Enter') {
    // Commit the buffer without losing focus.
    const parsed = parseText(text.value);
    commitValue(parsed);
  }
};

// ---- scrub-on-drag (opt-in) ------------------------------------------------
// Uses POINTER CAPTURE on the input element itself (set on pointerdown) so the
// pointermove/pointerup keep firing on the same element through the whole drag,
// even when the pointer leaves the element — no document-level <listeners> (which
// would also avoid the React-effect `$event`-in-deps emitter edge). The handlers
// are bound directly on the <input> in the template, where `@event` passes a
// properly-typed `$event`.
// ---- scrub-on-drag (opt-in) ------------------------------------------------
// Uses POINTER CAPTURE on the input element itself (set on pointerdown) so the
// pointermove/pointerup keep firing on the same element through the whole drag,
// even when the pointer leaves the element — no document-level <listeners> (which
// would also avoid the React-effect `$event`-in-deps emitter edge). The handlers
// are bound directly on the <input> in the template, where `@event` passes a
// properly-typed `$event`.
const onScrubDown = (e: any) => {
  if (!props.allowScrub || props.disabled || props.readonly) return;
  scrubbing = true;
  scrubStartX = e && typeof e.clientX === 'number' ? e.clientX : 0;
  const cur = readValue();
  scrubStartValue = cur === null ? hasMin() ? props.min : 0 : cur;
  // Capture the pointer so move/up stay on this element for the whole drag.
  if (e && e.target && e.target.setPointerCapture && typeof e.pointerId === 'number') {
    try {
      e.target.setPointerCapture(e.pointerId);
    } catch {}
  }
};
const onScrubMove = (e: any) => {
  if (!scrubbing) return;
  const x = e && typeof e.clientX === 'number' ? e.clientX : 0;
  const dx = x - scrubStartX;
  const stepSize = typeof props.step === 'number' && props.step > 0 ? props.step : 1;
  // One step per 8px of horizontal travel.
  const delta = Math.round(dx / 8) * stepSize;
  commitValue(scrubStartValue + delta);
};
const onScrubUp = () => {
  scrubbing = false;
};

// ---- lifecycle + imperative handle -----------------------------------------
// focus() — move DOM focus to the input. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted). increment()/decrement() —
// step once by `step`. clear() — set the value to null and clear the buffer.
const focus = () => {
  const el = inputRef.value;
  // NOTE: $refs.input types to the generic HTMLElement on the tsdown/vue leaves
  // (the emitter ref-type map has no `input` → HTMLInputElement entry), so we
  // only touch HTMLElement members here (`focus`). Text selection happens in the
  // onFocus handler, where `e.target` is `any` and `.select()` typechecks.
  if (el && el.focus) el.focus();
};
const increment = () => stepBy(1, props.step);
const decrement = () => stepBy(-1, props.step);
const clear = () => {
  commitValue(null);
  text.value = '';
};

let _cleanup_0: (() => void) | undefined;
onMounted(() => {
  // Seed the edit buffer so a programmatic focus shows the right text.
  const n = readValue();
  text.value = n === null ? '' : String(n);
  // Tear down any running repeat / scrub on unmount.
  _cleanup_0 = () => {
    stopHold();
    scrubbing = false;
  };
});
onBeforeUnmount(() => { _cleanup_0?.(); });

defineExpose({ focus, increment, decrement, clear });
</script>

<style scoped>
.rozie-number-field {
  display: inline-flex;
  align-items: stretch;
  gap: var(--rozie-number-field-gap, 0);
  font: var(--rozie-number-field-font, inherit);
  border: var(--rozie-number-field-border-width, 1px) solid var(--rozie-number-field-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-number-field-radius, 0.5rem);
  background: var(--rozie-number-field-bg, #fff);
  overflow: hidden;
}
.rozie-number-field-input {
  box-sizing: border-box;
  width: var(--rozie-number-field-width, 4.5rem);
  min-width: 0;
  padding: var(--rozie-number-field-padding, 0.375rem 0.5rem);
  text-align: var(--rozie-number-field-text-align, right);
  font: inherit;
  font-size: var(--rozie-number-field-font-size, 1rem);
  color: var(--rozie-number-field-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-number-field-input:focus {
  box-shadow: inset 0 0 0 var(--rozie-number-field-focus-ring-width, 2px) var(--rozie-number-field-focus-ring-color, rgba(0, 102, 204, 0.35));
}
.rozie-number-field-btn {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-number-field-btn-size, 2rem);
  padding: 0;
  font-size: var(--rozie-number-field-btn-font-size, 1.1rem);
  line-height: 1;
  color: var(--rozie-number-field-btn-color, inherit);
  background: var(--rozie-number-field-btn-bg, rgba(0, 0, 0, 0.04));
  border: none;
  cursor: pointer;
  user-select: none;
  transition: background-color 0.15s;
}
.rozie-number-field-btn:hover {
  background: var(--rozie-number-field-btn-hover-bg, rgba(0, 0, 0, 0.08));
}
.rozie-number-field-btn:disabled {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}
.rozie-number-field--disabled {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}
</style>
svelte
<script lang="ts">
import { applyListeners, rozieAttr } from '@rozie/runtime-svelte';

import { onMount } from 'svelte';

interface Props {
  /**
   * The numeric value of the field (two-way `r-model`). `null` means the field is empty. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a number field **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). The value is clamped to `[min, max]` and snapped to `step` on every commit.
   * @example
   * <NumberField r-model:modelValue="qty" :min="0" :max="10" />
   */
  modelValue?: (number) | null;
  /**
   * Inclusive lower bound. Every commit clamps the value to `>= min`, and the **Home** key jumps to `min`. `null` (the default) means no lower bound. Also emitted as `aria-valuemin`.
   */
  min?: (number) | null;
  /**
   * Inclusive upper bound. Every commit clamps the value to `<= max`, and the **End** key jumps to `max`. `null` (the default) means no upper bound. Also emitted as `aria-valuemax`.
   */
  max?: (number) | null;
  /**
   * The increment/decrement granularity. **ArrowUp** / **ArrowDown** and the +/- buttons change the value by `step`, and every commit snaps the value to the nearest multiple of `step` measured from `min` (or `0` when `min` is `null`).
   */
  step?: number;
  /**
   * The coarse step applied by **PageUp** / **PageDown**, for fast traversal of a wide range.
   */
  largeStep?: number;
  /**
   * Options forwarded to `Intl.NumberFormat` for locale-aware **display** formatting (e.g. `{ style: "currency", currency: "USD" }` or `{ minimumFractionDigits: 2 }`). The displayed text is formatted while the field is unfocused; on commit the formatting is stripped back off and the raw number is parsed.
   * @example
   * :formatOptions="{ style: 'currency', currency: 'USD' }"
   */
  formatOptions?: any;
  /**
   * Opt in to **scrub-on-drag**: press and drag horizontally on the field to change the value by `step` per few pixels (a power-user affordance). Off by default.
   */
  allowScrub?: boolean;
  /**
   * Disable the whole control — the input, both steppers, the keyboard, and scrubbing. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Make the field read-only — the value is shown and focusable but cannot be changed by typing, the steppers, the keyboard, or scrubbing.
   */
  readonly?: boolean;
  /**
   * Accessible name applied to the `role="spinbutton"` input (`aria-label`). Provide this (or an external `<label>`) so the control is announced.
   */
  ariaLabel?: (string) | null;
  onchange?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let __defaultFormatOptions = (() => ({}))();

let {
  modelValue = $bindable(null),
  min = null,
  max = null,
  step = 1,
  largeStep = 10,
  formatOptions = __defaultFormatOptions,
  allowScrub = false,
  disabled = false,
  readonly = false,
  ariaLabel = null,
  onchange,
  ...__rozieAttrs
}: Props = $props();

let text = $state('');
let focused = $state(false);

let input = $state<HTMLInputElement | undefined>(undefined);

// ---- top-level mutable handles (hook-referenced → React useRef hoist) -------
// The press-hold repeat timer + its current interval (the ramp). Declared at the
// top level so React hoists them to useRef and the Solid onMount/onCleanup split
// sees them in teardown. `null`/0 when no repeat is running.
let holdTimer: any = null;
let holdInterval = 0;

// Scrub-on-drag state (also top-level so teardown sees it).
// Scrub-on-drag state (also top-level so teardown sees it).
let scrubbing = false;
let scrubStartX = 0;
let scrubStartValue = 0;

// ---- numeric helpers (plain functions, uniform ×6) -------------------------
// The current value as a real number, or null when empty. Named readValue, NOT
// valueOf — a `valueOf` binding cascades TS1240/1271 across the Lit class via
// Object.prototype.
// ---- numeric helpers (plain functions, uniform ×6) -------------------------
// The current value as a real number, or null when empty. Named readValue, NOT
// valueOf — a `valueOf` binding cascades TS1240/1271 across the Lit class via
// Object.prototype.
const readValue = () => {
  const v = modelValue;
  return typeof v === 'number' && !Number.isNaN(v) ? v : null;
};
const hasMin = () => typeof min === 'number' && !Number.isNaN(min);
const hasMax = () => typeof max === 'number' && !Number.isNaN(max);

// Clamp n to [min, max] (whichever bounds are set).
// Clamp n to [min, max] (whichever bounds are set).
const clampValue = (n: any) => {
  let out = n;
  if (hasMin() && out < min) out = min;
  if (hasMax() && out > max) out = max;
  return out;
};

// Snap n to the nearest multiple of `step` measured from `min` (or 0).
// Snap n to the nearest multiple of `step` measured from `min` (or 0).
const snapValue = (n: any) => {
  const stepSize = typeof step === 'number' && step > 0 ? step : 1;
  const base = hasMin() ? min : 0;
  const snapped = base + Math.round((n - base) / stepSize) * stepSize;
  // Avoid binary-float drift (e.g. 0.1 + 0.2) by rounding to step precision.
  const decimals = (String(stepSize).split('.')[1] || '').length;
  return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped;
};

// ---- locale formatting (plain functions, uniform ×6) -----------------------
// ---- locale formatting (plain functions, uniform ×6) -----------------------
const formatter = () => {
  try {
    return new Intl.NumberFormat(undefined, formatOptions || {});
  } catch {
    return new Intl.NumberFormat();
  }
};

// The value formatted for display (empty string when null).
// The value formatted for display (empty string when null).
const formatted = () => {
  const n = readValue();
  return n === null ? '' : formatter().format(n);
};

// Parse a user-typed string back to a number, or null when it is not a number.
// Strips grouping separators + any non-numeric currency/percent chrome, keeping
// digits, a sign, a decimal point, and an exponent.
// Parse a user-typed string back to a number, or null when it is not a number.
// Strips grouping separators + any non-numeric currency/percent chrome, keeping
// digits, a sign, a decimal point, and an exponent.
const parseText = (raw: any) => {
  if (raw == null) return null;
  const s = String(raw).trim();
  if (s === '') return null;
  const cleaned = s.replace(/[^0-9eE+\-.,]/g, '').replace(/,/g, '');
  const n = Number.parseFloat(cleaned);
  return Number.isNaN(n) ? null : n;
};

// What the <input> should show: the live edit buffer while focused, otherwise
// the locale-formatted value. A plain function (read in the template + handlers).
// What the <input> should show: the live edit buffer while focused, otherwise
// the locale-formatted value. A plain function (read in the template + handlers).
const displayText = () => focused ? text : formatted();

// ---- aria helpers (numbers/strings bound cleanly) --------------------------
// ---- aria helpers (numbers/strings bound cleanly) --------------------------
const ariaText = () => {
  const n = readValue();
  return n === null ? '' : formatted();
};

// ---- write funnel (single $emit site) --------------------------------------
// Clamp + snap, write the model, mirror into the edit buffer, emit change. Named
// commitValue (NOT writeValue) so it does not collide with the generated Angular
// ControlValueAccessor.writeValue (TS2300).
// ---- write funnel (single $emit site) --------------------------------------
// Clamp + snap, write the model, mirror into the edit buffer, emit change. Named
// commitValue (NOT writeValue) so it does not collide with the generated Angular
// ControlValueAccessor.writeValue (TS2300).
const commitValue = (n: any) => {
  let next = n;
  if (next !== null) {
    next = snapValue(next);
    next = clampValue(next);
  }
  modelValue = next;
  // Keep the edit buffer in sync so a focused field reflects a programmatic step.
  text = next === null ? '' : String(next);
  onchange?.({
    value: next
  });
};

// Step by a signed multiple of `step` (used by buttons + arrows). A null value
// seeds from min (or 0) so the first step lands on a sensible number.
// Step by a signed multiple of `step` (used by buttons + arrows). A null value
// seeds from min (or 0) so the first step lands on a sensible number.
const stepBy = (dir: any, size: any) => {
  if (disabled || readonly) return;
  const cur = readValue();
  const stepSize = typeof size === 'number' ? size : typeof step === 'number' ? step : 1;
  const base = cur === null ? hasMin() ? min : 0 : cur;
  commitValue(base + dir * stepSize);
};

// ---- press-hold acceleration ----------------------------------------------
// Stop any running repeat (pointerup / pointerleave / unmount).
// ---- press-hold acceleration ----------------------------------------------
// Stop any running repeat (pointerup / pointerleave / unmount).
const stopHold = () => {
  if (holdTimer !== null) {
    clearTimeout(holdTimer);
    holdTimer = null;
  }
  holdInterval = 0;
};

// Start a repeating step that ramps from slow to fast while the button is held.
// Start a repeating step that ramps from slow to fast while the button is held.
const startHold = (dir: any) => {
  if (disabled || readonly) return;
  stopHold();
  stepBy(dir, step);
  holdInterval = 300;
  const tick = () => {
    stepBy(dir, step);
    // Ramp: shorten the interval down to a floor for accelerating repeats.
    holdInterval = Math.max(40, Math.round(holdInterval * 0.8));
    holdTimer = setTimeout(tick, holdInterval);
  };
  holdTimer = setTimeout(tick, holdInterval);
};

// ---- input + keyboard handlers ---------------------------------------------
// ---- input + keyboard handlers ---------------------------------------------
const onInput = (e: any) => {
  if (readonly) return;
  const raw = e && e.target ? e.target.value : '';
  text = raw;
};

// Commit the edit buffer on blur: parse → commit (or clear to null when empty).
// Commit the edit buffer on blur: parse → commit (or clear to null when empty).
const onBlur = () => {
  focused = false;
  const parsed = parseText(text);
  commitValue(parsed);
};
const onFocus = (e: any) => {
  focused = true;
  // Seed the edit buffer with the raw (unformatted) number so editing is clean.
  const n = readValue();
  text = n === null ? '' : String(n);
  if (e && e.target && e.target.select) e.target.select();
};
const onKeydown = (e: any) => {
  if (disabled || readonly) return;
  const key = e ? e.key : '';
  if (key === 'ArrowUp') {
    if (e) e.preventDefault();
    stepBy(1, step);
  } else if (key === 'ArrowDown') {
    if (e) e.preventDefault();
    stepBy(-1, step);
  } else if (key === 'PageUp') {
    if (e) e.preventDefault();
    stepBy(1, largeStep);
  } else if (key === 'PageDown') {
    if (e) e.preventDefault();
    stepBy(-1, largeStep);
  } else if (key === 'Home') {
    if (hasMin()) {
      if (e) e.preventDefault();
      commitValue(min);
    }
  } else if (key === 'End') {
    if (hasMax()) {
      if (e) e.preventDefault();
      commitValue(max);
    }
  } else if (key === 'Enter') {
    // Commit the buffer without losing focus.
    const parsed = parseText(text);
    commitValue(parsed);
  }
};

// ---- scrub-on-drag (opt-in) ------------------------------------------------
// Uses POINTER CAPTURE on the input element itself (set on pointerdown) so the
// pointermove/pointerup keep firing on the same element through the whole drag,
// even when the pointer leaves the element — no document-level <listeners> (which
// would also avoid the React-effect `$event`-in-deps emitter edge). The handlers
// are bound directly on the <input> in the template, where `@event` passes a
// properly-typed `$event`.
// ---- scrub-on-drag (opt-in) ------------------------------------------------
// Uses POINTER CAPTURE on the input element itself (set on pointerdown) so the
// pointermove/pointerup keep firing on the same element through the whole drag,
// even when the pointer leaves the element — no document-level <listeners> (which
// would also avoid the React-effect `$event`-in-deps emitter edge). The handlers
// are bound directly on the <input> in the template, where `@event` passes a
// properly-typed `$event`.
const onScrubDown = (e: any) => {
  if (!allowScrub || disabled || readonly) return;
  scrubbing = true;
  scrubStartX = e && typeof e.clientX === 'number' ? e.clientX : 0;
  const cur = readValue();
  scrubStartValue = cur === null ? hasMin() ? min : 0 : cur;
  // Capture the pointer so move/up stay on this element for the whole drag.
  if (e && e.target && e.target.setPointerCapture && typeof e.pointerId === 'number') {
    try {
      e.target.setPointerCapture(e.pointerId);
    } catch {}
  }
};
const onScrubMove = (e: any) => {
  if (!scrubbing) return;
  const x = e && typeof e.clientX === 'number' ? e.clientX : 0;
  const dx = x - scrubStartX;
  const stepSize = typeof step === 'number' && step > 0 ? step : 1;
  // One step per 8px of horizontal travel.
  const delta = Math.round(dx / 8) * stepSize;
  commitValue(scrubStartValue + delta);
};
const onScrubUp = () => {
  scrubbing = false;
};

// ---- lifecycle + imperative handle -----------------------------------------
// focus() — move DOM focus to the input. DELIBERATELY overrides
// HTMLElement.focus on Lit (ROZ137 warn, accepted). increment()/decrement() —
// step once by `step`. clear() — set the value to null and clear the buffer.
export const focus = () => {
  const el = input;
  // NOTE: $refs.input types to the generic HTMLElement on the tsdown/vue leaves
  // (the emitter ref-type map has no `input` → HTMLInputElement entry), so we
  // only touch HTMLElement members here (`focus`). Text selection happens in the
  // onFocus handler, where `e.target` is `any` and `.select()` typechecks.
  if (el && el.focus) el.focus();
};
export const increment = () => stepBy(1, step);
export const decrement = () => stepBy(-1, step);
export const clear = () => {
  commitValue(null);
  text = '';
};

onMount(() => {
  // Seed the edit buffer so a programmatic focus shows the right text.
  const n = readValue();
  text = n === null ? '' : String(n);
  // Tear down any running repeat / scrub on unmount.
  return () => {
    stopHold();
    scrubbing = false;
  };
});
</script>

<div {...__rozieAttrs} class={["rozie-number-field", { 'rozie-number-field--disabled': disabled }, (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-ceb089aa><button type="button" class="rozie-number-field-btn rozie-number-field-btn--dec" tabindex="-1" aria-label="Decrement" disabled={!!disabled || !!readonly} onpointerdown={($event) => { startHold(-1); }} onpointerup={($event) => { stopHold(); }} onpointerleave={($event) => { stopHold(); }} data-rozie-s-ceb089aa>−</button><input bind:this={input} class="rozie-number-field-input" type="text" inputmode="decimal" autocomplete="off" role="spinbutton" value={displayText()} disabled={!!disabled} readonly={!!readonly} aria-label={ariaLabel} aria-valuemin={min} aria-valuemax={max} aria-valuenow={modelValue} aria-valuetext={rozieAttr(ariaText())} aria-disabled={!!disabled} oninput={($event) => { onInput($event); }} onfocus={($event) => { onFocus($event); }} onblur={($event) => { onBlur(); }} onkeydown={($event) => { onKeydown($event); }} onpointerdown={($event) => { onScrubDown($event); }} onpointermove={($event) => { onScrubMove($event); }} onpointerup={($event) => { onScrubUp(); }} data-rozie-s-ceb089aa /><button type="button" class="rozie-number-field-btn rozie-number-field-btn--inc" tabindex="-1" aria-label="Increment" disabled={!!disabled || !!readonly} onpointerdown={($event) => { startHold(1); }} onpointerup={($event) => { stopHold(); }} onpointerleave={($event) => { stopHold(); }} data-rozie-s-ceb089aa>+</button></div>

<style>
:global {
  .rozie-number-field[data-rozie-s-ceb089aa] {
    display: inline-flex;
    align-items: stretch;
    gap: var(--rozie-number-field-gap, 0);
    font: var(--rozie-number-field-font, inherit);
    border: var(--rozie-number-field-border-width, 1px) solid var(--rozie-number-field-border-color, rgba(0, 0, 0, 0.25));
    border-radius: var(--rozie-number-field-radius, 0.5rem);
    background: var(--rozie-number-field-bg, #fff);
    overflow: hidden;
  }
  .rozie-number-field-input[data-rozie-s-ceb089aa] {
    box-sizing: border-box;
    width: var(--rozie-number-field-width, 4.5rem);
    min-width: 0;
    padding: var(--rozie-number-field-padding, 0.375rem 0.5rem);
    text-align: var(--rozie-number-field-text-align, right);
    font: inherit;
    font-size: var(--rozie-number-field-font-size, 1rem);
    color: var(--rozie-number-field-color, inherit);
    background: transparent;
    border: none;
    outline: none;
  }
  .rozie-number-field-input[data-rozie-s-ceb089aa]:focus {
    box-shadow: inset 0 0 0 var(--rozie-number-field-focus-ring-width, 2px) var(--rozie-number-field-focus-ring-color, rgba(0, 102, 204, 0.35));
  }
  .rozie-number-field-btn[data-rozie-s-ceb089aa] {
    box-sizing: border-box;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--rozie-number-field-btn-size, 2rem);
    padding: 0;
    font-size: var(--rozie-number-field-btn-font-size, 1.1rem);
    line-height: 1;
    color: var(--rozie-number-field-btn-color, inherit);
    background: var(--rozie-number-field-btn-bg, rgba(0, 0, 0, 0.04));
    border: none;
    cursor: pointer;
    user-select: none;
    transition: background-color 0.15s;
  }
  .rozie-number-field-btn[data-rozie-s-ceb089aa]:hover {
    background: var(--rozie-number-field-btn-hover-bg, rgba(0, 0, 0, 0.08));
  }
  .rozie-number-field-btn[data-rozie-s-ceb089aa]:disabled {
    cursor: not-allowed;
    opacity: var(--rozie-number-field-disabled-opacity, 0.55);
  }
  .rozie-number-field--disabled[data-rozie-s-ceb089aa] {
    cursor: not-allowed;
    opacity: var(--rozie-number-field-disabled-opacity, 0.55);
  }
}
</style>
ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, viewChild } from '@angular/core';
import { NgClass } from '@angular/common';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

function __rozieDisplay(v: unknown): string {
  if (v == null) return '';
  if (typeof v === 'string') return v;
  if (typeof v === 'object') {
    try {
      return JSON.stringify(v, null, 2);
    } catch {
      // Circular structure or a non-serialisable value (BigInt nested in an
      // object). Degrade to a non-throwing form so the wrap never crashes the
      // render — that is the entire point of "safe" interpolation (SPEC-1).
      return String(v);
    }
  }
  return String(v);
}

function __rozieAttr(v: unknown): string | null {
  return v == null ? null : __rozieDisplay(v);
}

@Component({
  selector: 'rozie-number-field',
  standalone: true,
  imports: [NgClass],
  template: `

    <div class="rozie-number-field" [ngClass]="{ 'rozie-number-field--disabled': (disabled() || this.__rozieCvaDisabled()) }" #rozieSpread_0 #rozieListenersTarget_1>
      <button type="button" class="rozie-number-field-btn rozie-number-field-btn--dec" tabindex="-1" aria-label="Decrement" [disabled]="!!(disabled() || this.__rozieCvaDisabled()) || !!readonly()" (pointerdown)="startHold(-1)" (pointerup)="stopHold()" (pointerleave)="stopHold()">−</button>

      <input #input class="rozie-number-field-input" type="text" inputmode="decimal" autocomplete="off" role="spinbutton" [value]="displayText()" [disabled]="!!(disabled() || this.__rozieCvaDisabled())" [readOnly]="!!readonly()" [attr.aria-label]="ariaLabel()" [attr.aria-valuemin]="min()" [attr.aria-valuemax]="max()" [attr.aria-valuenow]="modelValue()" [attr.aria-valuetext]="rozieAttr(ariaText())" [attr.aria-disabled]="!!(disabled() || this.__rozieCvaDisabled())" (input)="onInput($event)" (focus)="onFocus($event)" (blur)="onBlur()" (keydown)="onKeydown($event)" (pointerdown)="onScrubDown($event)" (pointermove)="onScrubMove($event)" (pointerup)="onScrubUp()" />

      <button type="button" class="rozie-number-field-btn rozie-number-field-btn--inc" tabindex="-1" aria-label="Increment" [disabled]="!!(disabled() || this.__rozieCvaDisabled()) || !!readonly()" (pointerdown)="startHold(1)" (pointerup)="stopHold()" (pointerleave)="stopHold()">+</button>
    </div>

  `,
  styles: [`
    .rozie-number-field {
      display: inline-flex;
      align-items: stretch;
      gap: var(--rozie-number-field-gap, 0);
      font: var(--rozie-number-field-font, inherit);
      border: var(--rozie-number-field-border-width, 1px) solid var(--rozie-number-field-border-color, rgba(0, 0, 0, 0.25));
      border-radius: var(--rozie-number-field-radius, 0.5rem);
      background: var(--rozie-number-field-bg, #fff);
      overflow: hidden;
    }
    .rozie-number-field-input {
      box-sizing: border-box;
      width: var(--rozie-number-field-width, 4.5rem);
      min-width: 0;
      padding: var(--rozie-number-field-padding, 0.375rem 0.5rem);
      text-align: var(--rozie-number-field-text-align, right);
      font: inherit;
      font-size: var(--rozie-number-field-font-size, 1rem);
      color: var(--rozie-number-field-color, inherit);
      background: transparent;
      border: none;
      outline: none;
    }
    .rozie-number-field-input:focus {
      box-shadow: inset 0 0 0 var(--rozie-number-field-focus-ring-width, 2px) var(--rozie-number-field-focus-ring-color, rgba(0, 102, 204, 0.35));
    }
    .rozie-number-field-btn {
      box-sizing: border-box;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: var(--rozie-number-field-btn-size, 2rem);
      padding: 0;
      font-size: var(--rozie-number-field-btn-font-size, 1.1rem);
      line-height: 1;
      color: var(--rozie-number-field-btn-color, inherit);
      background: var(--rozie-number-field-btn-bg, rgba(0, 0, 0, 0.04));
      border: none;
      cursor: pointer;
      user-select: none;
      transition: background-color 0.15s;
    }
    .rozie-number-field-btn:hover {
      background: var(--rozie-number-field-btn-hover-bg, rgba(0, 0, 0, 0.08));
    }
    .rozie-number-field-btn:disabled {
      cursor: not-allowed;
      opacity: var(--rozie-number-field-disabled-opacity, 0.55);
    }
    .rozie-number-field--disabled {
      cursor: not-allowed;
      opacity: var(--rozie-number-field-disabled-opacity, 0.55);
    }
  `],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => NumberField),
      multi: true,
    },
  ],
  host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class NumberField {
  /**
   * The numeric value of the field (two-way `r-model`). `null` means the field is empty. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a number field **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). The value is clamped to `[min, max]` and snapped to `step` on every commit.
   * @example
   * <NumberField r-model:modelValue="qty" :min="0" :max="10" />
   */
  modelValue = model<(number) | null>(null);
  /**
   * Inclusive lower bound. Every commit clamps the value to `>= min`, and the **Home** key jumps to `min`. `null` (the default) means no lower bound. Also emitted as `aria-valuemin`.
   */
  min = input<(number) | null>(null);
  /**
   * Inclusive upper bound. Every commit clamps the value to `<= max`, and the **End** key jumps to `max`. `null` (the default) means no upper bound. Also emitted as `aria-valuemax`.
   */
  max = input<(number) | null>(null);
  /**
   * The increment/decrement granularity. **ArrowUp** / **ArrowDown** and the +/- buttons change the value by `step`, and every commit snaps the value to the nearest multiple of `step` measured from `min` (or `0` when `min` is `null`).
   */
  step = input<number>(1);
  /**
   * The coarse step applied by **PageUp** / **PageDown**, for fast traversal of a wide range.
   */
  largeStep = input<number>(10);
  /**
   * Options forwarded to `Intl.NumberFormat` for locale-aware **display** formatting (e.g. `{ style: "currency", currency: "USD" }` or `{ minimumFractionDigits: 2 }`). The displayed text is formatted while the field is unfocused; on commit the formatting is stripped back off and the raw number is parsed.
   * @example
   * :formatOptions="{ style: 'currency', currency: 'USD' }"
   */
  formatOptions = input<Record<string, any>>((() => ({}))());
  /**
   * Opt in to **scrub-on-drag**: press and drag horizontally on the field to change the value by `step` per few pixels (a power-user affordance). Off by default.
   */
  allowScrub = input<boolean>(false);
  /**
   * Disable the whole control — the input, both steppers, the keyboard, and scrubbing. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled = input<boolean>(false);
  /**
   * Make the field read-only — the value is shown and focusable but cannot be changed by typing, the steppers, the keyboard, or scrubbing.
   */
  readonly = input<boolean>(false);
  /**
   * Accessible name applied to the `role="spinbutton"` input (`aria-label`). Provide this (or an external `<label>`) so the control is announced.
   */
  ariaLabel = input<(string) | null>(null);
  text = signal('');
  focused = signal(false);
  input = viewChild<ElementRef<HTMLInputElement>>('input');
  change = output<unknown>();
  private __rozieDestroyRef = inject(DestroyRef);

  ngAfterViewInit() {
    // Seed the edit buffer so a programmatic focus shows the right text.
    const n = this.readValue();
    this.text.set(n === null ? '' : String(n));
    // Tear down any running repeat / scrub on unmount.
    this.__rozieDestroyRef.onDestroy(() => {
      this.stopHold();
      this.scrubbing = false;
    });
  }

  holdTimer: any = null;
  holdInterval = 0;
  scrubbing = false;
  scrubStartX = 0;
  scrubStartValue = 0;
  readValue = () => {
    const v = this.modelValue();
    return typeof v === 'number' && !Number.isNaN(v) ? v : null;
  };
  hasMin = () => typeof this.min() === 'number' && !Number.isNaN(this.min());
  hasMax = () => typeof this.max() === 'number' && !Number.isNaN(this.max());
  clampValue = (n: any) => {
    const __min = this.min();
    const __max = this.max();
    let out = n;
    if (this.hasMin() && out < __min) out = __min;
    if (this.hasMax() && out > __max) out = __max;
    return out;
  };
  snapValue = (n: any) => {
    const __step = this.step();
    const stepSize = typeof __step === 'number' && __step > 0 ? __step : 1;
    const base = this.hasMin() ? this.min() : 0;
    const snapped = base + Math.round((n - base) / stepSize) * stepSize;
    // Avoid binary-float drift (e.g. 0.1 + 0.2) by rounding to step precision.
    const decimals = (String(stepSize).split('.')[1] || '').length;
    return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped;
  };
  formatter = () => {
    try {
      return new Intl.NumberFormat(undefined, this.formatOptions() || {});
    } catch {
      return new Intl.NumberFormat();
    }
  };
  formatted = () => {
    const n = this.readValue();
    return n === null ? '' : this.formatter().format(n);
  };
  parseText = (raw: any) => {
    if (raw == null) return null;
    const s = String(raw).trim();
    if (s === '') return null;
    const cleaned = s.replace(/[^0-9eE+\-.,]/g, '').replace(/,/g, '');
    const n = Number.parseFloat(cleaned);
    return Number.isNaN(n) ? null : n;
  };
  displayText = () => this.focused() ? this.text() : this.formatted();
  ariaText = () => {
    const n = this.readValue();
    return n === null ? '' : this.formatted();
  };
  commitValue = (n: any) => {
    let next = n;
    if (next !== null) {
      next = this.snapValue(next);
      next = this.clampValue(next);
    }
    this.modelValue.set(next), this.__rozieCvaOnChange(next);
    // Keep the edit buffer in sync so a focused field reflects a programmatic step.
    this.text.set(next === null ? '' : String(next));
    this.change.emit({
      value: next
    });
  };
  stepBy = (dir: any, size: any) => {
    const __step = this.step();
    if ((this.disabled() || this.__rozieCvaDisabled()) || this.readonly()) return;
    const cur = this.readValue();
    const stepSize = typeof size === 'number' ? size : typeof __step === 'number' ? __step : 1;
    const base = cur === null ? this.hasMin() ? this.min() : 0 : cur;
    this.commitValue(base + dir * stepSize);
  };
  stopHold = () => {
    if (this.holdTimer !== null) {
      clearTimeout(this.holdTimer);
      this.holdTimer = null;
    }
    this.holdInterval = 0;
  };
  startHold = (dir: any) => {
    if ((this.disabled() || this.__rozieCvaDisabled()) || this.readonly()) return;
    this.stopHold();
    this.stepBy(dir, this.step());
    this.holdInterval = 300;
    const tick = () => {
      this.stepBy(dir, this.step());
      // Ramp: shorten the interval down to a floor for accelerating repeats.
      this.holdInterval = Math.max(40, Math.round(this.holdInterval * 0.8));
      this.holdTimer = setTimeout(tick, this.holdInterval);
    };
    this.holdTimer = setTimeout(tick, this.holdInterval);
  };
  onInput = (e: any) => {
    if (this.readonly()) return;
    const raw = e && e.target ? e.target.value : '';
    this.text.set(raw);
  };
  onBlur = () => {
    this.focused.set(false);
    const parsed = this.parseText(this.text());
    this.commitValue(parsed);
  };
  onFocus = (e: any) => {
    this.focused.set(true);
    // Seed the edit buffer with the raw (unformatted) number so editing is clean.
    const n = this.readValue();
    this.text.set(n === null ? '' : String(n));
    if (e && e.target && e.target.select) e.target.select();
  };
  onKeydown = (e: any) => {
    const __step = this.step();
    const __largeStep = this.largeStep();
    if ((this.disabled() || this.__rozieCvaDisabled()) || this.readonly()) return;
    const key = e ? e.key : '';
    if (key === 'ArrowUp') {
      if (e) e.preventDefault();
      this.stepBy(1, __step);
    } else if (key === 'ArrowDown') {
      if (e) e.preventDefault();
      this.stepBy(-1, __step);
    } else if (key === 'PageUp') {
      if (e) e.preventDefault();
      this.stepBy(1, __largeStep);
    } else if (key === 'PageDown') {
      if (e) e.preventDefault();
      this.stepBy(-1, __largeStep);
    } else if (key === 'Home') {
      if (this.hasMin()) {
        if (e) e.preventDefault();
        this.commitValue(this.min());
      }
    } else if (key === 'End') {
      if (this.hasMax()) {
        if (e) e.preventDefault();
        this.commitValue(this.max());
      }
    } else if (key === 'Enter') {
      // Commit the buffer without losing focus.
      const parsed = this.parseText(this.text());
      this.commitValue(parsed);
    }
  };
  onScrubDown = (e: any) => {
    if (!this.allowScrub() || (this.disabled() || this.__rozieCvaDisabled()) || this.readonly()) return;
    this.scrubbing = true;
    this.scrubStartX = e && typeof e.clientX === 'number' ? e.clientX : 0;
    const cur = this.readValue();
    this.scrubStartValue = cur === null ? this.hasMin() ? this.min() : 0 : cur;
    // Capture the pointer so move/up stay on this element for the whole drag.
    if (e && e.target && e.target.setPointerCapture && typeof e.pointerId === 'number') {
      try {
        e.target.setPointerCapture(e.pointerId);
      } catch {}
    }
  };
  onScrubMove = (e: any) => {
    const __step = this.step();
    if (!this.scrubbing) return;
    const x = e && typeof e.clientX === 'number' ? e.clientX : 0;
    const dx = x - this.scrubStartX;
    const stepSize = typeof __step === 'number' && __step > 0 ? __step : 1;
    // One step per 8px of horizontal travel.
    const delta = Math.round(dx / 8) * stepSize;
    this.commitValue(this.scrubStartValue + delta);
  };
  onScrubUp = () => {
    this.scrubbing = false;
  };
  focus = () => {
    const el = this.input()?.nativeElement;
    // NOTE: $refs.input types to the generic HTMLElement on the tsdown/vue leaves
    // (the emitter ref-type map has no `input` → HTMLInputElement entry), so we
    // only touch HTMLElement members here (`focus`). Text selection happens in the
    // onFocus handler, where `e.target` is `any` and `.select()` typechecks.
    if (el && el.focus) el.focus();
  };
  increment = () => this.stepBy(1, this.step());
  decrement = () => this.stepBy(-1, this.step());
  clear = () => {
    this.commitValue(null);
    this.text.set('');
  };

  private __rozieCvaOnChange: (v: number) => void = () => {};
  private __rozieCvaOnTouchedFn: () => void = () => {};
  protected __rozieCvaDisabled = signal(false);

  writeValue(v: number | null): void {
    this.modelValue.set(v ?? null);
  }
  registerOnChange(fn: (v: number) => void): void {
    this.__rozieCvaOnChange = fn;
  }
  registerOnTouched(fn: () => void): void {
    this.__rozieCvaOnTouchedFn = fn;
  }
  setDisabledState(isDisabled: boolean): void {
    this.__rozieCvaDisabled.set(isDisabled);
  }
  __rozieCvaOnTouched(): void {
    this.__rozieCvaOnTouchedFn();
  }

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = (() => {
    const renderer = inject(Renderer2);
    const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
    const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
    const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
    const parseClassTokens = (value: unknown): string[] => {
      if (typeof value !== 'string') return [];
      const out: string[] = [];
      for (const tok of value.split(/\s+/)) {
        if (tok.length > 0) out.push(tok);
      }
      return out;
    };
    const parseStyleDecls = (value: unknown): Array<[string, string]> => {
      if (typeof value !== 'string') return [];
      const out: Array<[string, string]> = [];
      for (const decl of value.split(';')) {
        const colon = decl.indexOf(':');
        if (colon < 0) continue;
        const prop = decl.slice(0, colon).trim();
        const val = decl.slice(colon + 1).trim();
        if (prop.length > 0) out.push([prop, val]);
      }
      return out;
    };
    const applyClassMerge = (el: HTMLElement, value: unknown) => {
      const next = parseClassTokens(value);
      const prev = prevClassTokensByElement.get(el) ?? [];
      const nextSet = new Set(next);
      for (const tok of prev) {
        if (!nextSet.has(tok)) el.classList.remove(tok);
      }
      for (const tok of next) el.classList.add(tok);
      prevClassTokensByElement.set(el, next);
    };
    const applyStyleMerge = (el: HTMLElement, value: unknown) => {
      const next = parseStyleDecls(value);
      const prev = prevStylePropsByElement.get(el) ?? [];
      const nextProps = next.map(([p]) => p);
      const nextSet = new Set(nextProps);
      for (const prop of prev) {
        if (!nextSet.has(prop)) el.style.removeProperty(prop);
      }
      for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
      prevStylePropsByElement.set(el, nextProps);
    };
    return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
      const safeObj: Record<string, unknown> = obj ?? {};
      const prevKeys = prevKeysByElement.get(el) ?? [];
      for (const k of prevKeys) {
        if (k === 'class' || k === 'style') continue;
        if (!(k in safeObj)) renderer.removeAttribute(el, k);
      }
      if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
        applyClassMerge(el, '');
      }
      if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
        applyStyleMerge(el, '');
      }
      for (const [k, v] of Object.entries(safeObj)) {
        if (k === 'class') {
          applyClassMerge(el, v);
        } else if (k === 'style') {
          applyStyleMerge(el, v);
        } else if (v === null || v === false) {
          renderer.removeAttribute(el, k);
        } else {
          renderer.setAttribute(el, k, String(v));
        }
      }
      prevKeysByElement.set(el, Object.keys(safeObj));
    };
  })();

  private __rozieGetHostAttrs = (() => {
    const host = inject(ElementRef);
    return () => {
      const el = host.nativeElement as HTMLElement;
      const out: Record<string, unknown> = {};
      for (const a of Array.from(el.attributes)) out[a.name] = a.value;
      return out;
    };
  })();

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default NumberField;
tsx
import type { JSX } from 'solid-js';
import { createSignal, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal, rozieAttr, rozieClass } from '@rozie/runtime-solid';

__rozieInjectStyle('NumberField-ceb089aa', `.rozie-number-field[data-rozie-s-ceb089aa] {
  display: inline-flex;
  align-items: stretch;
  gap: var(--rozie-number-field-gap, 0);
  font: var(--rozie-number-field-font, inherit);
  border: var(--rozie-number-field-border-width, 1px) solid var(--rozie-number-field-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-number-field-radius, 0.5rem);
  background: var(--rozie-number-field-bg, #fff);
  overflow: hidden;
}
.rozie-number-field-input[data-rozie-s-ceb089aa] {
  box-sizing: border-box;
  width: var(--rozie-number-field-width, 4.5rem);
  min-width: 0;
  padding: var(--rozie-number-field-padding, 0.375rem 0.5rem);
  text-align: var(--rozie-number-field-text-align, right);
  font: inherit;
  font-size: var(--rozie-number-field-font-size, 1rem);
  color: var(--rozie-number-field-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-number-field-input[data-rozie-s-ceb089aa]:focus {
  box-shadow: inset 0 0 0 var(--rozie-number-field-focus-ring-width, 2px) var(--rozie-number-field-focus-ring-color, rgba(0, 102, 204, 0.35));
}
.rozie-number-field-btn[data-rozie-s-ceb089aa] {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-number-field-btn-size, 2rem);
  padding: 0;
  font-size: var(--rozie-number-field-btn-font-size, 1.1rem);
  line-height: 1;
  color: var(--rozie-number-field-btn-color, inherit);
  background: var(--rozie-number-field-btn-bg, rgba(0, 0, 0, 0.04));
  border: none;
  cursor: pointer;
  user-select: none;
  transition: background-color 0.15s;
}
.rozie-number-field-btn[data-rozie-s-ceb089aa]:hover {
  background: var(--rozie-number-field-btn-hover-bg, rgba(0, 0, 0, 0.08));
}
.rozie-number-field-btn[data-rozie-s-ceb089aa]:disabled {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}
.rozie-number-field--disabled[data-rozie-s-ceb089aa] {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}`);

interface NumberFieldProps {
  /**
   * The numeric value of the field (two-way `r-model`). `null` means the field is empty. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a number field **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). The value is clamped to `[min, max]` and snapped to `step` on every commit.
   * @example
   * <NumberField r-model:modelValue="qty" :min="0" :max="10" />
   */
  modelValue?: (number) | null;
  defaultModelValue?: (number) | null;
  onModelValueChange?: (modelValue: (number) | null) => void;
  /**
   * Inclusive lower bound. Every commit clamps the value to `>= min`, and the **Home** key jumps to `min`. `null` (the default) means no lower bound. Also emitted as `aria-valuemin`.
   */
  min?: (number) | null;
  /**
   * Inclusive upper bound. Every commit clamps the value to `<= max`, and the **End** key jumps to `max`. `null` (the default) means no upper bound. Also emitted as `aria-valuemax`.
   */
  max?: (number) | null;
  /**
   * The increment/decrement granularity. **ArrowUp** / **ArrowDown** and the +/- buttons change the value by `step`, and every commit snaps the value to the nearest multiple of `step` measured from `min` (or `0` when `min` is `null`).
   */
  step?: number;
  /**
   * The coarse step applied by **PageUp** / **PageDown**, for fast traversal of a wide range.
   */
  largeStep?: number;
  /**
   * Options forwarded to `Intl.NumberFormat` for locale-aware **display** formatting (e.g. `{ style: "currency", currency: "USD" }` or `{ minimumFractionDigits: 2 }`). The displayed text is formatted while the field is unfocused; on commit the formatting is stripped back off and the raw number is parsed.
   * @example
   * :formatOptions="{ style: 'currency', currency: 'USD' }"
   */
  formatOptions?: Record<string, any>;
  /**
   * Opt in to **scrub-on-drag**: press and drag horizontally on the field to change the value by `step` per few pixels (a power-user affordance). Off by default.
   */
  allowScrub?: boolean;
  /**
   * Disable the whole control — the input, both steppers, the keyboard, and scrubbing. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  disabled?: boolean;
  /**
   * Make the field read-only — the value is shown and focusable but cannot be changed by typing, the steppers, the keyboard, or scrubbing.
   */
  readonly?: boolean;
  /**
   * Accessible name applied to the `role="spinbutton"` input (`aria-label`). Provide this (or an external `<label>`) so the control is announced.
   */
  ariaLabel?: (string) | null;
  onChange?: (...args: unknown[]) => void;
  ref?: (h: NumberFieldHandle) => void;
}

export interface NumberFieldHandle {
  focus: (...args: any[]) => any;
  increment: (...args: any[]) => any;
  decrement: (...args: any[]) => any;
  clear: (...args: any[]) => any;
}

export default function NumberField(_props: NumberFieldProps): JSX.Element {
  const _merged = mergeProps({ min: null, max: null, step: 1, largeStep: 10, formatOptions: (() => ({}))(), allowScrub: false, disabled: false, readonly: false, ariaLabel: null }, _props);
  const [local, attrs] = splitProps(_merged, ['modelValue', 'min', 'max', 'step', 'largeStep', 'formatOptions', 'allowScrub', 'disabled', 'readonly', 'ariaLabel', 'ref']);
  onMount(() => { local.ref?.({ focus, increment, decrement, clear }); });

  const [modelValue, setModelValue] = createControllableSignal<number>(_props as unknown as Record<string, unknown>, 'modelValue', null);
  const [text, setText] = createSignal('');
  const [focused, setFocused] = createSignal(false);
  onMount(() => {
    const _cleanup = (() => {
    // Seed the edit buffer so a programmatic focus shows the right text.
    const n = readValue();
    setText(n === null ? '' : String(n));
    // Tear down any running repeat / scrub on unmount.
  })() as unknown;
    if (_cleanup) onCleanup(_cleanup as () => void);
    onCleanup(() => {
    stopHold();
    scrubbing = false;
  });
  });
  let inputRef: HTMLElement | null = null;

  // ---- top-level mutable handles (hook-referenced → React useRef hoist) -------
  // The press-hold repeat timer + its current interval (the ramp). Declared at the
  // top level so React hoists them to useRef and the Solid onMount/onCleanup split
  // sees them in teardown. `null`/0 when no repeat is running.
  let holdTimer: any = null;
  let holdInterval = 0;

  // Scrub-on-drag state (also top-level so teardown sees it).
  let scrubbing = false;
  let scrubStartX = 0;
  let scrubStartValue = 0;

  // ---- numeric helpers (plain functions, uniform ×6) -------------------------
  // The current value as a real number, or null when empty. Named readValue, NOT
  // valueOf — a `valueOf` binding cascades TS1240/1271 across the Lit class via
  // Object.prototype.
  function readValue() {
    const v = modelValue();
    return typeof v === 'number' && !Number.isNaN(v) ? v : null;
  }
  function hasMin() {
    return typeof local.min === 'number' && !Number.isNaN(local.min);
  }
  function hasMax() {
    return typeof local.max === 'number' && !Number.isNaN(local.max);
  }

  // Clamp n to [min, max] (whichever bounds are set).
  function clampValue(n: any) {
    let out = n;
    if (hasMin() && out < local.min) out = local.min;
    if (hasMax() && out > local.max) out = local.max;
    return out;
  }

  // Snap n to the nearest multiple of `step` measured from `min` (or 0).
  function snapValue(n: any) {
    const stepSize = typeof local.step === 'number' && local.step > 0 ? local.step : 1;
    const base = hasMin() ? local.min : 0;
    const snapped = base + Math.round((n - base) / stepSize) * stepSize;
    // Avoid binary-float drift (e.g. 0.1 + 0.2) by rounding to step precision.
    const decimals = (String(stepSize).split('.')[1] || '').length;
    return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped;
  }

  // ---- locale formatting (plain functions, uniform ×6) -----------------------
  function formatter() {
    try {
      return new Intl.NumberFormat(undefined, local.formatOptions || {});
    } catch {
      return new Intl.NumberFormat();
    }
  }

  // The value formatted for display (empty string when null).
  function formatted() {
    const n = readValue();
    return n === null ? '' : formatter().format(n);
  }

  // Parse a user-typed string back to a number, or null when it is not a number.
  // Strips grouping separators + any non-numeric currency/percent chrome, keeping
  // digits, a sign, a decimal point, and an exponent.
  function parseText(raw: any) {
    if (raw == null) return null;
    const s = String(raw).trim();
    if (s === '') return null;
    const cleaned = s.replace(/[^0-9eE+\-.,]/g, '').replace(/,/g, '');
    const n = Number.parseFloat(cleaned);
    return Number.isNaN(n) ? null : n;
  }

  // What the <input> should show: the live edit buffer while focused, otherwise
  // the locale-formatted value. A plain function (read in the template + handlers).
  function displayText() {
    return focused() ? text() : formatted();
  }

  // ---- aria helpers (numbers/strings bound cleanly) --------------------------
  function ariaText() {
    const n = readValue();
    return n === null ? '' : formatted();
  }

  // ---- write funnel (single $emit site) --------------------------------------
  // Clamp + snap, write the model, mirror into the edit buffer, emit change. Named
  // commitValue (NOT writeValue) so it does not collide with the generated Angular
  // ControlValueAccessor.writeValue (TS2300).
  function commitValue(n: any) {
    let next = n;
    if (next !== null) {
      next = snapValue(next);
      next = clampValue(next);
    }
    setModelValue(next);
    // Keep the edit buffer in sync so a focused field reflects a programmatic step.
    setText(next === null ? '' : String(next));
    _props.onChange?.({
      value: next
    });
  }

  // Step by a signed multiple of `step` (used by buttons + arrows). A null value
  // seeds from min (or 0) so the first step lands on a sensible number.
  function stepBy(dir: any, size: any) {
    if (local.disabled || local.readonly) return;
    const cur = readValue();
    const stepSize = typeof size === 'number' ? size : typeof local.step === 'number' ? local.step : 1;
    const base = cur === null ? hasMin() ? local.min : 0 : cur;
    commitValue(base + dir * stepSize);
  }

  // ---- press-hold acceleration ----------------------------------------------
  // Stop any running repeat (pointerup / pointerleave / unmount).
  function stopHold() {
    if (holdTimer !== null) {
      clearTimeout(holdTimer);
      holdTimer = null;
    }
    holdInterval = 0;
  }

  // Start a repeating step that ramps from slow to fast while the button is held.
  function startHold(dir: any) {
    if (local.disabled || local.readonly) return;
    stopHold();
    stepBy(dir, local.step);
    holdInterval = 300;
    const tick = () => {
      stepBy(dir, local.step);
      // Ramp: shorten the interval down to a floor for accelerating repeats.
      holdInterval = Math.max(40, Math.round(holdInterval * 0.8));
      holdTimer = setTimeout(tick, holdInterval);
    };
    holdTimer = setTimeout(tick, holdInterval);
  }

  // ---- input + keyboard handlers ---------------------------------------------
  function onInput(e: any) {
    if (local.readonly) return;
    const raw = e && e.target ? e.target.value : '';
    setText(raw);
  }

  // Commit the edit buffer on blur: parse → commit (or clear to null when empty).
  function onBlur() {
    setFocused(false);
    const parsed = parseText(text());
    commitValue(parsed);
  }
  function onFocus(e: any) {
    setFocused(true);
    // Seed the edit buffer with the raw (unformatted) number so editing is clean.
    const n = readValue();
    setText(n === null ? '' : String(n));
    if (e && e.target && e.target.select) e.target.select();
  }
  function onKeydown(e: any) {
    if (local.disabled || local.readonly) return;
    const key = e ? e.key : '';
    if (key === 'ArrowUp') {
      if (e) e.preventDefault();
      stepBy(1, local.step);
    } else if (key === 'ArrowDown') {
      if (e) e.preventDefault();
      stepBy(-1, local.step);
    } else if (key === 'PageUp') {
      if (e) e.preventDefault();
      stepBy(1, local.largeStep);
    } else if (key === 'PageDown') {
      if (e) e.preventDefault();
      stepBy(-1, local.largeStep);
    } else if (key === 'Home') {
      if (hasMin()) {
        if (e) e.preventDefault();
        commitValue(local.min);
      }
    } else if (key === 'End') {
      if (hasMax()) {
        if (e) e.preventDefault();
        commitValue(local.max);
      }
    } else if (key === 'Enter') {
      // Commit the buffer without losing focus.
      const parsed = parseText(text());
      commitValue(parsed);
    }
  }

  // ---- scrub-on-drag (opt-in) ------------------------------------------------
  // Uses POINTER CAPTURE on the input element itself (set on pointerdown) so the
  // pointermove/pointerup keep firing on the same element through the whole drag,
  // even when the pointer leaves the element — no document-level <listeners> (which
  // would also avoid the React-effect `$event`-in-deps emitter edge). The handlers
  // are bound directly on the <input> in the template, where `@event` passes a
  // properly-typed `$event`.
  function onScrubDown(e: any) {
    if (!local.allowScrub || local.disabled || local.readonly) return;
    scrubbing = true;
    scrubStartX = e && typeof e.clientX === 'number' ? e.clientX : 0;
    const cur = readValue();
    scrubStartValue = cur === null ? hasMin() ? local.min : 0 : cur;
    // Capture the pointer so move/up stay on this element for the whole drag.
    if (e && e.target && e.target.setPointerCapture && typeof e.pointerId === 'number') {
      try {
        e.target.setPointerCapture(e.pointerId);
      } catch {}
    }
  }
  function onScrubMove(e: any) {
    if (!scrubbing) return;
    const x = e && typeof e.clientX === 'number' ? e.clientX : 0;
    const dx = x - scrubStartX;
    const stepSize = typeof local.step === 'number' && local.step > 0 ? local.step : 1;
    // One step per 8px of horizontal travel.
    const delta = Math.round(dx / 8) * stepSize;
    commitValue(scrubStartValue + delta);
  }
  function onScrubUp() {
    scrubbing = false;
  }

  // ---- lifecycle + imperative handle -----------------------------------------

  // focus() — move DOM focus to the input. DELIBERATELY overrides
  // HTMLElement.focus on Lit (ROZ137 warn, accepted). increment()/decrement() —
  // step once by `step`. clear() — set the value to null and clear the buffer.
  function focus() {
    const el = inputRef;
    // NOTE: $refs.input types to the generic HTMLElement on the tsdown/vue leaves
    // (the emitter ref-type map has no `input` → HTMLInputElement entry), so we
    // only touch HTMLElement members here (`focus`). Text selection happens in the
    // onFocus handler, where `e.target` is `any` and `.select()` typechecks.
    if (el && el.focus) el.focus();
  }
  function increment() {
    return stepBy(1, local.step);
  }
  function decrement() {
    return stepBy(-1, local.step);
  }
  function clear() {
    commitValue(null);
    setText('');
  }

  return (
    <>
    <div {...attrs} class={"rozie-number-field" + " " + rozieClass({ 'rozie-number-field--disabled': local.disabled }) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-ceb089aa="">
      <button type="button" aria-label="Decrement" class={"rozie-number-field-btn rozie-number-field-btn--dec"} tabIndex={-1} disabled={!!local.disabled || !!local.readonly} onPointerDown={($event) => { startHold(-1); }} onPointerUp={($event) => { stopHold(); }} onPointerLeave={($event) => { stopHold(); }} data-rozie-s-ceb089aa="">−</button>

      <input type="text" inputMode="decimal" autocomplete="off" role="spinbutton" aria-label={rozieAttr(local.ariaLabel)} aria-valuemin={rozieAttr(local.min)} aria-valuemax={rozieAttr(local.max)} aria-valuenow={rozieAttr(modelValue())} aria-valuetext={rozieAttr(ariaText())} aria-disabled={!!local.disabled} ref={(el) => { inputRef = el as HTMLElement; }} class={"rozie-number-field-input"} value={displayText()} disabled={!!local.disabled} readOnly={!!local.readonly} onInput={($event) => { onInput($event); }} onFocus={($event) => { onFocus($event); }} onBlur={($event) => { onBlur(); }} onKeyDown={($event) => { onKeydown($event); }} onPointerDown={($event) => { onScrubDown($event); }} onPointerMove={($event) => { onScrubMove($event); }} onPointerUp={($event) => { onScrubUp(); }} data-rozie-s-ceb089aa="" />

      <button type="button" aria-label="Increment" class={"rozie-number-field-btn rozie-number-field-btn--inc"} tabIndex={-1} disabled={!!local.disabled || !!local.readonly} onPointerDown={($event) => { startHold(1); }} onPointerUp={($event) => { stopHold(); }} onPointerLeave={($event) => { stopHold(); }} data-rozie-s-ceb089aa="">+</button>
    </div>
    </>
  );
}
ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieAttr, rozieListeners, rozieSpread } from '@rozie/runtime-lit';

@customElement('rozie-number-field')
export default class NumberField extends SignalWatcher(LitElement) {
  static styles = css`
.rozie-number-field[data-rozie-s-ceb089aa] {
  display: inline-flex;
  align-items: stretch;
  gap: var(--rozie-number-field-gap, 0);
  font: var(--rozie-number-field-font, inherit);
  border: var(--rozie-number-field-border-width, 1px) solid var(--rozie-number-field-border-color, rgba(0, 0, 0, 0.25));
  border-radius: var(--rozie-number-field-radius, 0.5rem);
  background: var(--rozie-number-field-bg, #fff);
  overflow: hidden;
}
.rozie-number-field-input[data-rozie-s-ceb089aa] {
  box-sizing: border-box;
  width: var(--rozie-number-field-width, 4.5rem);
  min-width: 0;
  padding: var(--rozie-number-field-padding, 0.375rem 0.5rem);
  text-align: var(--rozie-number-field-text-align, right);
  font: inherit;
  font-size: var(--rozie-number-field-font-size, 1rem);
  color: var(--rozie-number-field-color, inherit);
  background: transparent;
  border: none;
  outline: none;
}
.rozie-number-field-input[data-rozie-s-ceb089aa]:focus {
  box-shadow: inset 0 0 0 var(--rozie-number-field-focus-ring-width, 2px) var(--rozie-number-field-focus-ring-color, rgba(0, 102, 204, 0.35));
}
.rozie-number-field-btn[data-rozie-s-ceb089aa] {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-number-field-btn-size, 2rem);
  padding: 0;
  font-size: var(--rozie-number-field-btn-font-size, 1.1rem);
  line-height: 1;
  color: var(--rozie-number-field-btn-color, inherit);
  background: var(--rozie-number-field-btn-bg, rgba(0, 0, 0, 0.04));
  border: none;
  cursor: pointer;
  user-select: none;
  transition: background-color 0.15s;
}
.rozie-number-field-btn[data-rozie-s-ceb089aa]:hover {
  background: var(--rozie-number-field-btn-hover-bg, rgba(0, 0, 0, 0.08));
}
.rozie-number-field-btn[data-rozie-s-ceb089aa]:disabled {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}
.rozie-number-field--disabled[data-rozie-s-ceb089aa] {
  cursor: not-allowed;
  opacity: var(--rozie-number-field-disabled-opacity, 0.55);
}
`;

  /**
   * The numeric value of the field (two-way `r-model`). `null` means the field is empty. As the sole `model: true` prop it drives the Angular `ControlValueAccessor`, so a number field **is** a form control (`[(ngModel)]` / `[formControl]` bind directly). The value is clamped to `[min, max]` and snapped to `step` on every commit.
   * @example
   * <NumberField r-model:modelValue="qty" :min="0" :max="10" />
   */
  @property({ type: Number, attribute: 'model-value' }) _modelValue_attr: number | null = null;
  private _modelValueControllable = createLitControllableProperty<number>({ host: this, eventName: 'model-value-change', defaultValue: null, initialControlledValue: undefined });
  /**
   * Inclusive lower bound. Every commit clamps the value to `>= min`, and the **Home** key jumps to `min`. `null` (the default) means no lower bound. Also emitted as `aria-valuemin`.
   */
  @property({ type: Number, reflect: true }) min: number | null = null;
  /**
   * Inclusive upper bound. Every commit clamps the value to `<= max`, and the **End** key jumps to `max`. `null` (the default) means no upper bound. Also emitted as `aria-valuemax`.
   */
  @property({ type: Number, reflect: true }) max: number | null = null;
  /**
   * The increment/decrement granularity. **ArrowUp** / **ArrowDown** and the +/- buttons change the value by `step`, and every commit snaps the value to the nearest multiple of `step` measured from `min` (or `0` when `min` is `null`).
   */
  @property({ type: Number, reflect: true }) step: number = 1;
  /**
   * The coarse step applied by **PageUp** / **PageDown**, for fast traversal of a wide range.
   */
  @property({ type: Number, reflect: true }) largeStep: number = 10;
  /**
   * Options forwarded to `Intl.NumberFormat` for locale-aware **display** formatting (e.g. `{ style: "currency", currency: "USD" }` or `{ minimumFractionDigits: 2 }`). The displayed text is formatted while the field is unfocused; on commit the formatting is stripped back off and the raw number is parsed.
   * @example
   * :formatOptions="{ style: 'currency', currency: 'USD' }"
   */
  @property({ type: Object }) formatOptions: any = {};
  /**
   * Opt in to **scrub-on-drag**: press and drag horizontally on the field to change the value by `step` per few pixels (a power-user affordance). Off by default.
   */
  @property({ type: Boolean, reflect: true }) allowScrub: boolean = false;
  /**
   * Disable the whole control — the input, both steppers, the keyboard, and scrubbing. Also sets the Angular `ControlValueAccessor` disabled state.
   */
  @property({ type: Boolean, reflect: true }) disabled: boolean = false;
  /**
   * Make the field read-only — the value is shown and focusable but cannot be changed by typing, the steppers, the keyboard, or scrubbing.
   */
  @property({ type: Boolean, reflect: true }) readonly: boolean = false;
  /**
   * Accessible name applied to the `role="spinbutton"` input (`aria-label`). Provide this (or an external `<label>`) so the control is announced.
   */
  @property({ type: String, reflect: true }) ariaLabel: string | null = null;
  private _text = signal('');
  private _focused = signal(false);
  @query('[data-rozie-ref="input"]') private _refInput!: HTMLElement;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  firstUpdated(): void {
    this._disconnectCleanups.push((() => {
      this.stopHold();
      this.scrubbing = false;
    }));

    // Seed the edit buffer so a programmatic focus shows the right text.
    const n = this.readValue();
    this._text.value = n === null ? '' : String(n);
    // Tear down any running repeat / scrub on unmount.
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  attributeChangedCallback(name: string, old: string | null, value: string | null): void {
    super.attributeChangedCallback(name, old, value);
    if (name === 'model-value') this._modelValueControllable.notifyAttributeChange(value === null ? null : Number(value));
  }

  render() {
    return html`
<div class="${Object.entries({ "rozie-number-field": true, 'rozie-number-field--disabled': this.disabled }).filter(([, v]) => v).map(([k]) => k).join(' ')}" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-ceb089aa>
  <button class="rozie-number-field-btn rozie-number-field-btn--dec" type="button" tabindex="-1" aria-label="Decrement" ?disabled=${!!this.disabled || !!this.readonly} @pointerdown=${($event: Event) => { this.startHold(-1); }} @pointerup=${($event: Event) => { this.stopHold(); }} @pointerleave=${($event: Event) => { this.stopHold(); }} data-rozie-s-ceb089aa>−</button>

  <input class="rozie-number-field-input" type="text" inputmode="decimal" autocomplete="off" role="spinbutton" .value=${this.displayText()} ?disabled=${!!this.disabled} ?readonly=${!!this.readonly} aria-label=${this.ariaLabel} aria-valuemin=${this.min} aria-valuemax=${this.max} aria-valuenow=${this.modelValue} aria-valuetext=${rozieAttr(this.ariaText())} aria-disabled=${!!this.disabled} @input=${($event: Event) => { this.onInput($event); }} @focus=${($event: Event) => { this.onFocus($event); }} @blur=${($event: Event) => { this.onBlur(); }} @keydown=${($event: Event) => { this.onKeydown($event); }} @pointerdown=${($event: Event) => { this.onScrubDown($event); }} @pointermove=${($event: Event) => { this.onScrubMove($event); }} @pointerup=${($event: Event) => { this.onScrubUp(); }} data-rozie-ref="input" data-rozie-s-ceb089aa />

  <button class="rozie-number-field-btn rozie-number-field-btn--inc" type="button" tabindex="-1" aria-label="Increment" ?disabled=${!!this.disabled || !!this.readonly} @pointerdown=${($event: Event) => { this.startHold(1); }} @pointerup=${($event: Event) => { this.stopHold(); }} @pointerleave=${($event: Event) => { this.stopHold(); }} data-rozie-s-ceb089aa>+</button>
</div>
`;
  }

  holdTimer: any = null;

  holdInterval = 0;

  scrubbing = false;

  scrubStartX = 0;

  scrubStartValue = 0;

  readValue = () => {
  const v = this.modelValue;
  return typeof v === 'number' && !Number.isNaN(v) ? v : null;
};

  hasMin = () => typeof this.min === 'number' && !Number.isNaN(this.min);

  hasMax = () => typeof this.max === 'number' && !Number.isNaN(this.max);

  clampValue = (n: any) => {
  let out = n;
  if (this.hasMin() && out < this.min) out = this.min;
  if (this.hasMax() && out > this.max) out = this.max;
  return out;
};

  snapValue = (n: any) => {
  const stepSize = typeof this.step === 'number' && this.step > 0 ? this.step : 1;
  const base = this.hasMin() ? this.min : 0;
  const snapped = base + Math.round((n - base) / stepSize) * stepSize;
  // Avoid binary-float drift (e.g. 0.1 + 0.2) by rounding to step precision.
  const decimals = (String(stepSize).split('.')[1] || '').length;
  return decimals > 0 ? Number(snapped.toFixed(decimals)) : snapped;
};

  formatter = () => {
  try {
    return new Intl.NumberFormat(undefined, this.formatOptions || {});
  } catch {
    return new Intl.NumberFormat();
  }
};

  formatted = () => {
  const n = this.readValue();
  return n === null ? '' : this.formatter().format(n);
};

  parseText = (raw: any) => {
  if (raw == null) return null;
  const s = String(raw).trim();
  if (s === '') return null;
  const cleaned = s.replace(/[^0-9eE+\-.,]/g, '').replace(/,/g, '');
  const n = Number.parseFloat(cleaned);
  return Number.isNaN(n) ? null : n;
};

  displayText = () => this._focused.value ? this._text.value : this.formatted();

  ariaText = () => {
  const n = this.readValue();
  return n === null ? '' : this.formatted();
};

  commitValue = (n: any) => {
  let next = n;
  if (next !== null) {
    next = this.snapValue(next);
    next = this.clampValue(next);
  }
  this._modelValueControllable.write(next);
  // Keep the edit buffer in sync so a focused field reflects a programmatic step.
  this._text.value = next === null ? '' : String(next);
  this.dispatchEvent(new CustomEvent("change", {
    detail: {
      value: next
    },
    bubbles: true,
    composed: true
  }));
};

  stepBy = (dir: any, size: any) => {
  if (this.disabled || this.readonly) return;
  const cur = this.readValue();
  const stepSize = typeof size === 'number' ? size : typeof this.step === 'number' ? this.step : 1;
  const base = cur === null ? this.hasMin() ? this.min : 0 : cur;
  this.commitValue(base + dir * stepSize);
};

  stopHold = () => {
  if (this.holdTimer !== null) {
    clearTimeout(this.holdTimer);
    this.holdTimer = null;
  }
  this.holdInterval = 0;
};

  startHold = (dir: any) => {
  if (this.disabled || this.readonly) return;
  this.stopHold();
  this.stepBy(dir, this.step);
  this.holdInterval = 300;
  const tick = () => {
    this.stepBy(dir, this.step);
    // Ramp: shorten the interval down to a floor for accelerating repeats.
    this.holdInterval = Math.max(40, Math.round(this.holdInterval * 0.8));
    this.holdTimer = setTimeout(tick, this.holdInterval);
  };
  this.holdTimer = setTimeout(tick, this.holdInterval);
};

  onInput = (e: any) => {
  if (this.readonly) return;
  const raw = e && e.target ? e.target.value : '';
  this._text.value = raw;
};

  onBlur = () => {
  this._focused.value = false;
  const parsed = this.parseText(this._text.value);
  this.commitValue(parsed);
};

  onFocus = (e: any) => {
  this._focused.value = true;
  // Seed the edit buffer with the raw (unformatted) number so editing is clean.
  const n = this.readValue();
  this._text.value = n === null ? '' : String(n);
  if (e && e.target && e.target.select) e.target.select();
};

  onKeydown = (e: any) => {
  if (this.disabled || this.readonly) return;
  const key = e ? e.key : '';
  if (key === 'ArrowUp') {
    if (e) e.preventDefault();
    this.stepBy(1, this.step);
  } else if (key === 'ArrowDown') {
    if (e) e.preventDefault();
    this.stepBy(-1, this.step);
  } else if (key === 'PageUp') {
    if (e) e.preventDefault();
    this.stepBy(1, this.largeStep);
  } else if (key === 'PageDown') {
    if (e) e.preventDefault();
    this.stepBy(-1, this.largeStep);
  } else if (key === 'Home') {
    if (this.hasMin()) {
      if (e) e.preventDefault();
      this.commitValue(this.min);
    }
  } else if (key === 'End') {
    if (this.hasMax()) {
      if (e) e.preventDefault();
      this.commitValue(this.max);
    }
  } else if (key === 'Enter') {
    // Commit the buffer without losing focus.
    const parsed = this.parseText(this._text.value);
    this.commitValue(parsed);
  }
};

  onScrubDown = (e: any) => {
  if (!this.allowScrub || this.disabled || this.readonly) return;
  this.scrubbing = true;
  this.scrubStartX = e && typeof e.clientX === 'number' ? e.clientX : 0;
  const cur = this.readValue();
  this.scrubStartValue = cur === null ? this.hasMin() ? this.min : 0 : cur;
  // Capture the pointer so move/up stay on this element for the whole drag.
  if (e && e.target && e.target.setPointerCapture && typeof e.pointerId === 'number') {
    try {
      e.target.setPointerCapture(e.pointerId);
    } catch {}
  }
};

  onScrubMove = (e: any) => {
  if (!this.scrubbing) return;
  const x = e && typeof e.clientX === 'number' ? e.clientX : 0;
  const dx = x - this.scrubStartX;
  const stepSize = typeof this.step === 'number' && this.step > 0 ? this.step : 1;
  // One step per 8px of horizontal travel.
  const delta = Math.round(dx / 8) * stepSize;
  this.commitValue(this.scrubStartValue + delta);
};

  onScrubUp = () => {
  this.scrubbing = false;
};

  focus = () => {
  const el = this._refInput;
  // NOTE: $refs.input types to the generic HTMLElement on the tsdown/vue leaves
  // (the emitter ref-type map has no `input` → HTMLInputElement entry), so we
  // only touch HTMLElement members here (`focus`). Text selection happens in the
  // onFocus handler, where `e.target` is `any` and `.select()` typechecks.
  if (el && el.focus) el.focus();
};

  increment = () => this.stepBy(1, this.step);

  decrement = () => this.stepBy(-1, this.step);

  clear = () => {
  this.commitValue(null);
  this._text.value = '';
};

  get modelValue(): number { return this._modelValueControllable.read(); }
  set modelValue(v: number) { this._modelValueControllable.notifyPropertyWrite(v); }

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['model-value', 'modelvalue', 'min', 'max', 'step', 'large-step', 'largestep', 'format-options', 'formatoptions', 'allow-scrub', 'allowscrub', 'disabled', 'readonly', 'aria-label', 'arialabel']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component (with ControlValueAccessor), a Solid component, and a Lit custom element. Same props, same change event, same two-way modelValue, same imperative handle — all from the one source above, built on a native <input> with no third-party engine behind it.

See also

Pre-v1.0 — internal monorepo.