{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "number-field",
  "title": "Number Field",
  "type": "registry:ui",
  "description": "Numeric input with stepper buttons, min/max bounds, step size, and decimal precision. Use for quantities, prices, and any field that should be a number rather than free text.",
  "categories": [
    "form"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/number-field/number-field.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { live } from 'lit/directives/live.js'\nimport { Minus, Plus } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nexport type NumberFieldSize = 'small' | 'middle' | 'large'\nexport type NumberFieldStatus = 'error' | 'warning'\nexport type NumberFieldControlsPosition = 'default' | 'right'\n\nfunction clamp(value: number, min?: number, max?: number) {\n  let next = value\n  if (min !== undefined) next = Math.max(min, next)\n  if (max !== undefined) next = Math.min(max, next)\n  return next\n}\n\nconst trueUnlessFalse = {\n  fromAttribute: (v: string | null) => v !== 'false',\n}\n\nconst decrementIncrementBase =\n  'focus-visible:ring-ring inline-flex shrink-0 items-center justify-center transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-30'\n\n/**\n * <uip-number-field> — the registry NumberField as a web component.\n *\n * Form-associated: submits `name=value` with its <form>.\n */\nexport class UipNumberField extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: inline-block; vertical-align: middle; }`]\n\n  static properties = {\n    value: { type: Number, reflect: true },\n    defaultValue: { type: Number, attribute: 'default-value' },\n    min: { type: Number },\n    max: { type: Number },\n    step: { type: Number },\n    precision: { type: Number },\n    disabled: { type: Boolean, reflect: true },\n    readOnly: { type: Boolean, attribute: 'readonly', reflect: true },\n    size: { reflect: true },\n    status: { reflect: true },\n    controlsPosition: { attribute: 'controls-position' },\n    keyboard: { converter: trueUnlessFalse },\n    prefixText: { attribute: 'prefix' },\n    suffixText: { attribute: 'suffix' },\n    placeholder: {},\n    name: { reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    displayValue: { state: true },\n  }\n\n  value?: number\n  defaultValue?: number\n  min?: number\n  max?: number\n  step = 1\n  precision?: number\n  disabled = false\n  readOnly = false\n  size: NumberFieldSize = 'middle'\n  status?: NumberFieldStatus\n  controlsPosition: NumberFieldControlsPosition = 'default'\n  keyboard = true\n  prefixText?: string\n  suffixText?: string\n  placeholder?: string\n  name?: string\n  accessibleLabel?: string\n\n  private displayValue = ''\n  private isUserTyping = false\n  private initialValue?: number\n  private internals = this.attachInternals()\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'number-field')\n    if (this.defaultValue !== undefined && this.getAttribute('value') === null) {\n      this.value = this.defaultValue\n    }\n    this.initialValue = this.value\n    this.displayValue = this.formatValue(this.value)\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value')) {\n      this.internals.setFormValue(this.value !== undefined ? String(this.value) : '')\n      if (!this.isUserTyping) {\n        this.displayValue = this.formatValue(this.value)\n      }\n    }\n  }\n\n  formResetCallback() {\n    this.emit(this.initialValue)\n  }\n\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private formatValue(val: number | undefined): string {\n    if (val === undefined || Number.isNaN(val)) return ''\n    if (this.precision !== undefined) {\n      return val.toFixed(this.precision)\n    }\n    return String(val)\n  }\n\n  private emit(next: number | undefined) {\n    const changed = this.value !== next\n    this.value = next\n    this.displayValue = this.formatValue(next)\n    if (changed) {\n      this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n      this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n      this.dispatchEvent(new CustomEvent('value-change', { detail: { value: next }, bubbles: true, composed: true }))\n    }\n  }\n\n  private stepBy(delta: number) {\n    if (this.disabled || this.readOnly) return\n    const base = this.value ?? 0\n    const next = clamp(base + delta, this.min, this.max)\n    this.isUserTyping = false\n    this.emit(next)\n  }\n\n  private handleIncrease(mult = 1) {\n    this.stepBy(this.step * mult)\n  }\n\n  private handleDecrease(mult = 1) {\n    this.stepBy(-this.step * mult)\n  }\n\n  private commitValue() {\n    this.isUserTyping = false\n    const raw = this.displayValue.trim()\n    if (raw === '') {\n      this.emit(undefined)\n      return\n    }\n    // Parse cleaned number\n    const cleaned = raw.replace(/[^0-9.-]/g, '')\n    const num = Number(cleaned)\n    if (!Number.isNaN(num)) {\n      const next = clamp(num, this.min, this.max)\n      this.emit(next)\n    } else {\n      this.displayValue = this.formatValue(this.value)\n    }\n  }\n\n  private handleKeyDown(event: KeyboardEvent) {\n    if (event.key === 'ArrowUp') {\n      if (this.keyboard) {\n        event.preventDefault()\n        this.handleIncrease()\n      }\n    } else if (event.key === 'ArrowDown') {\n      if (this.keyboard) {\n        event.preventDefault()\n        this.handleDecrease()\n      }\n    } else if (event.key === 'PageUp') {\n      if (this.keyboard) {\n        event.preventDefault()\n        this.handleIncrease(10)\n      }\n    } else if (event.key === 'PageDown') {\n      if (this.keyboard) {\n        event.preventDefault()\n        this.handleDecrease(10)\n      }\n    } else if (event.key === 'Home') {\n      if (this.keyboard && this.min !== undefined) {\n        event.preventDefault()\n        this.emit(this.min)\n      }\n    } else if (event.key === 'End') {\n      if (this.keyboard && this.max !== undefined) {\n        event.preventDefault()\n        this.emit(this.max)\n      }\n    } else if (event.key === 'Enter') {\n      this.commitValue()\n    }\n  }\n\n  private handleWheel(event: WheelEvent) {\n    if (!this.matches(':focus-within') && !this.matches(':hover')) return\n    if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return\n    event.preventDefault()\n    if (event.deltaY > 0) this.handleDecrease()\n    else this.handleIncrease()\n  }\n\n  render() {\n    const isRight = this.controlsPosition === 'right'\n    const currentValue = this.value\n    const iconSize = this.size === 'small' ? 'h-3 w-3' : this.size === 'large' ? 'h-5 w-5' : 'h-4 w-4'\n    const buttonPadding = !isRight ? (this.size === 'small' ? 'p-1.5' : this.size === 'large' ? 'p-4' : 'p-3') : ''\n\n    const inputSizeClasses =\n      this.size === 'small'\n        ? 'h-7 text-xs px-2 py-0.5'\n        : this.size === 'large'\n          ? 'h-11 text-base px-4 py-2'\n          : 'h-9 text-sm px-3 py-1'\n\n    const inputStatusClasses = !isRight\n      ? this.status === 'error'\n        ? 'border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive'\n        : this.status === 'warning'\n          ? 'border-warning focus-visible:ring-warning/20'\n          : ''\n      : ''\n\n    const contentClasses = cn(\n      'relative inline-flex',\n      isRight &&\n        'border-input focus-within:ring-ring inline-grid grid-cols-[1fr_auto] grid-rows-[1fr_1fr] items-stretch overflow-hidden rounded-md border focus-within:ring-1',\n    )\n\n    const decrement = html`\n      <button\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"decrement\"\n        tabindex=\"-1\"\n        aria-label=\"Decrease\"\n        ?disabled=${this.disabled || this.readOnly || (this.min !== undefined && (currentValue ?? 0) <= this.min)}\n        @click=${() => this.handleDecrease()}\n        class=${cn(\n          decrementIncrementBase,\n          !isRight && 'absolute top-1/2 left-0 z-10 -translate-y-1/2',\n          buttonPadding,\n          isRight && 'hover:bg-accent col-start-2 row-start-2 h-full w-auto rounded-none border-t border-l p-0 px-2',\n        )}\n      >\n        ${icon(Minus, 'minus', iconSize)}\n      </button>\n    `\n\n    const increment = html`\n      <button\n        type=\"button\"\n        data-uipkge=\"\"\n        data-slot=\"increment\"\n        tabindex=\"-1\"\n        aria-label=\"Increase\"\n        ?disabled=${this.disabled || this.readOnly || (this.max !== undefined && (currentValue ?? 0) >= this.max)}\n        @click=${() => this.handleIncrease()}\n        class=${cn(\n          decrementIncrementBase,\n          !isRight && 'absolute top-1/2 right-0 z-10 -translate-y-1/2',\n          buttonPadding,\n          isRight && 'hover:bg-accent col-start-2 row-start-1 h-full w-auto rounded-none border-l p-0 px-2',\n        )}\n      >\n        ${icon(Plus, 'plus', iconSize)}\n      </button>\n    `\n\n    const inputBlock = html`\n      <div\n        data-uipkge=\"\"\n        data-slot=\"input\"\n        class=${cn(\n          'relative flex-1',\n          isRight && 'col-span-1 row-span-2',\n          !isRight && '[&>input]:pr-9 [&>input]:pl-9',\n        )}\n      >\n        ${this.prefixText\n          ? html`<span class=\"text-muted-foreground pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm\"\n              >${this.prefixText}</span\n            >`\n          : nothing}\n        <input\n          part=\"input\"\n          .value=${live(this.displayValue)}\n          type=\"text\"\n          role=\"spinbutton\"\n          name=${this.name ?? nothing}\n          aria-label=${this.accessibleLabel ?? nothing}\n          aria-valuenow=${currentValue !== undefined && !Number.isNaN(currentValue) ? currentValue : nothing}\n          aria-valuemin=${this.min ?? nothing}\n          aria-valuemax=${this.max ?? nothing}\n          aria-invalid=${this.status === 'error' ? 'true' : nothing}\n          inputmode=${this.precision !== undefined ? 'decimal' : 'numeric'}\n          ?disabled=${this.disabled}\n          ?readonly=${this.readOnly}\n          placeholder=${this.placeholder ?? nothing}\n          autocomplete=\"off\"\n          autocorrect=\"off\"\n          spellcheck=\"false\"\n          aria-roledescription=\"Number field\"\n          @focus=${() => (this.isUserTyping = true)}\n          @input=${(e: Event) => (this.displayValue = (e.target as HTMLInputElement).value)}\n          @blur=${this.commitValue}\n          @keydown=${this.handleKeyDown}\n          @wheel=${this.handleWheel}\n          class=${cn(\n            'placeholder:text-muted-foreground w-full bg-transparent text-center shadow-xs transition-colors outline-none disabled:cursor-not-allowed disabled:opacity-50 text-foreground',\n            !isRight && 'border-input focus-visible:ring-ring rounded-md border focus-visible:ring-1',\n            isRight && 'rounded-none border-0 focus-visible:ring-0',\n            this.prefixText && 'pl-6',\n            this.suffixText && 'pr-6',\n            inputSizeClasses,\n            inputStatusClasses,\n          )}\n        />\n        ${this.suffixText\n          ? html`<span class=\"text-muted-foreground pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-sm\"\n              >${this.suffixText}</span\n            >`\n          : nothing}\n      </div>\n    `\n\n    return html`\n      <div data-uipkge=\"\" data-slot=\"number-field\" class=\"inline-flex\">\n        <div class=${contentClasses}>\n          ${decrement}\n          ${inputBlock}\n          ${increment}\n        </div>\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-number-field') || customElements.define('uip-number-field', UipNumberField)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-number-field': UipNumberField\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/number-field/number-field.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
