UIPackage

Number Field

Vue form
Edit on GitHub

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.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/number-field

Examples

Schema

Type aliases from this item's source — use them to shape the data you pass in.

NumberFieldContext
interface NumberFieldContext {
  size: Ref<NumberFieldSize>
  status: Ref<NumberFieldStatus | undefined>
  controlsPosition: Ref<NumberFieldControlsPosition>
  keyboard: Ref<boolean>
  formatter: Ref<((value: number | undefined) => string) | undefined>
  parser: Ref<((displayValue: string) => number | undefined) | undefined>
  prefix: Ref<string | undefined>
  suffix: Ref<string | undefined>
}

Dependencies

Files (7)

  • app/components/ui/number-field/NumberField.vue 2.6 kB
    <script setup lang="ts">
    import type { NumberFieldRootEmits, NumberFieldRootProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { computed } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
    import { cn } from '@/lib/utils'
    import { provideNumberFieldContext } from './NumberFieldContext'
    
    export type NumberFieldSize = 'small' | 'middle' | 'large'
    export type NumberFieldStatus = 'error' | 'warning'
    export type NumberFieldControlsPosition = 'default' | 'right'
    
    // Use intersection in defineProps<> instead of `interface Props extends`:
    // Vue 3.5+'s SFC compiler has its own mini-resolver for the `extends`
    // clause that bails when the external package's package.json lacks
    // `exports.types` (true for reka-ui). The defineProps<T & U>() path
    // delegates type extraction to the project's installed `typescript`
    // package, which is shipped by init.json -- this works reliably.
    interface ExtraProps {
      class?: HTMLAttributes['class']
      size?: NumberFieldSize
      status?: NumberFieldStatus
      controlsPosition?: NumberFieldControlsPosition
      keyboard?: boolean
      precision?: number
      formatter?: (value: number | undefined) => string
      parser?: (displayValue: string) => number | undefined
      prefix?: string
      suffix?: string
    }
    
    const props = withDefaults(defineProps<NumberFieldRootProps & ExtraProps>(), {
      step: 1,
      keyboard: true,
      controlsPosition: 'default',
    })
    
    const emits = defineEmits<NumberFieldRootEmits>()
    
    const delegatedProps = reactiveOmit(
      props,
      'class',
      'size',
      'status',
      'controlsPosition',
      'keyboard',
      'precision',
      'formatter',
      'parser',
      'prefix',
      'suffix',
      'formatOptions',
    )
    
    const formatOptions = computed(() => {
      if (props.precision !== undefined) {
        return {
          ...props.formatOptions,
          minimumFractionDigits: props.precision,
          maximumFractionDigits: props.precision,
        }
      }
      return props.formatOptions
    })
    
    const forwarded = useForwardPropsEmits(delegatedProps, emits)
    
    provideNumberFieldContext({
      size: computed(() => props.size ?? 'middle'),
      status: computed(() => props.status),
      controlsPosition: computed(() => props.controlsPosition),
      keyboard: computed(() => props.keyboard),
      formatter: computed(() => props.formatter),
      parser: computed(() => props.parser),
      prefix: computed(() => props.prefix),
      suffix: computed(() => props.suffix),
    })
    </script>
    
    <template>
      <NumberFieldRoot
        v-slot="slotProps"
        v-bind="forwarded"
        :format-options="formatOptions"
        :class="cn('inline-flex', props.class)"
      >
        <slot v-bind="slotProps" />
      </NumberFieldRoot>
    </template>
  • app/components/ui/number-field/NumberFieldContent.vue 0.8 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed } from 'vue'
    import { cn } from '@/lib/utils'
    import { injectNumberFieldContext } from './NumberFieldContext'
    
    const props = defineProps<{
      class?: HTMLAttributes['class']
    }>()
    
    const uiContext = injectNumberFieldContext()
    
    const isRight = computed(() => uiContext.controlsPosition.value === 'right')
    </script>
    
    <template>
      <div
        :class="
          cn(
            'relative',
            isRight &&
              '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',
            !isRight &&
              '[&>[data-slot=input]]:has-[[data-slot=decrement]]:pl-5 [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-5',
            props.class,
          )
        "
      >
        <slot />
      </div>
    </template>
  • app/components/ui/number-field/NumberFieldContext.ts 1.2 kB
    import type { InjectionKey, Ref } from 'vue'
    import { inject, provide, ref } from 'vue'
    
    export type NumberFieldSize = 'small' | 'middle' | 'large'
    export type NumberFieldStatus = 'error' | 'warning'
    export type NumberFieldControlsPosition = 'default' | 'right'
    
    export interface NumberFieldContext {
      size: Ref<NumberFieldSize>
      status: Ref<NumberFieldStatus | undefined>
      controlsPosition: Ref<NumberFieldControlsPosition>
      keyboard: Ref<boolean>
      formatter: Ref<((value: number | undefined) => string) | undefined>
      parser: Ref<((displayValue: string) => number | undefined) | undefined>
      prefix: Ref<string | undefined>
      suffix: Ref<string | undefined>
    }
    
    export const NumberFieldContextKey: InjectionKey<NumberFieldContext> = Symbol('NumberFieldContext')
    
    export function provideNumberFieldContext(context: NumberFieldContext) {
      provide(NumberFieldContextKey, context)
    }
    
    export function injectNumberFieldContext(): NumberFieldContext {
      return inject(NumberFieldContextKey, {
        size: ref('middle'),
        status: ref(undefined),
        controlsPosition: ref('default'),
        keyboard: ref(true),
        formatter: ref(undefined),
        parser: ref(undefined),
        prefix: ref(undefined),
        suffix: ref(undefined),
      })
    }
  • app/components/ui/number-field/NumberFieldDecrement.vue 1.7 kB
    <script setup lang="ts">
    import type { NumberFieldDecrementProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { computed } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { Minus } from 'lucide-vue-next'
    import { NumberFieldDecrement, useForwardProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    import { injectNumberFieldContext } from './NumberFieldContext'
    
    const props = defineProps<NumberFieldDecrementProps & { class?: HTMLAttributes['class'] }>()
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    const forwarded = useForwardProps(delegatedProps)
    
    const uiContext = injectNumberFieldContext()
    
    const isRight = computed(() => uiContext.controlsPosition.value === 'right')
    
    const iconSize = computed(() => {
      switch (uiContext.size.value) {
        case 'small':
          return 'h-3 w-3'
        case 'large':
          return 'h-5 w-5'
        default:
          return 'h-4 w-4'
      }
    })
    </script>
    
    <template>
      <NumberFieldDecrement
        data-uipkge
        data-slot="decrement"
        v-bind="forwarded"
        :class="
          cn(
            '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',
            !isRight && 'absolute top-1/2 left-0 -translate-y-1/2',
            !isRight && uiContext.size.value === 'small' && 'p-1.5',
            !isRight && uiContext.size.value === 'middle' && 'p-3',
            !isRight && uiContext.size.value === 'large' && 'p-4',
            isRight && 'hover:bg-accent col-start-2 row-start-2 h-full w-auto rounded-none border-t border-l p-0 px-2',
            props.class,
          )
        "
      >
        <slot>
          <Minus :class="iconSize" aria-hidden="true" />
        </slot>
      </NumberFieldDecrement>
    </template>
  • app/components/ui/number-field/NumberFieldIncrement.vue 1.7 kB
    <script setup lang="ts">
    import type { NumberFieldIncrementProps } from 'reka-ui'
    import type { HTMLAttributes } from 'vue'
    import { computed } from 'vue'
    import { reactiveOmit } from '@vueuse/core'
    import { Plus } from 'lucide-vue-next'
    import { NumberFieldIncrement, useForwardProps } from 'reka-ui'
    import { cn } from '@/lib/utils'
    import { injectNumberFieldContext } from './NumberFieldContext'
    
    const props = defineProps<NumberFieldIncrementProps & { class?: HTMLAttributes['class'] }>()
    
    const delegatedProps = reactiveOmit(props, 'class')
    
    const forwarded = useForwardProps(delegatedProps)
    
    const uiContext = injectNumberFieldContext()
    
    const isRight = computed(() => uiContext.controlsPosition.value === 'right')
    
    const iconSize = computed(() => {
      switch (uiContext.size.value) {
        case 'small':
          return 'h-3 w-3'
        case 'large':
          return 'h-5 w-5'
        default:
          return 'h-4 w-4'
      }
    })
    </script>
    
    <template>
      <NumberFieldIncrement
        data-uipkge
        data-slot="increment"
        v-bind="forwarded"
        :class="
          cn(
            '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',
            !isRight && 'absolute top-1/2 right-0 -translate-y-1/2',
            !isRight && uiContext.size.value === 'small' && 'p-1.5',
            !isRight && uiContext.size.value === 'middle' && 'p-3',
            !isRight && uiContext.size.value === 'large' && 'p-4',
            isRight && 'hover:bg-accent col-start-2 row-start-1 h-full w-auto rounded-none border-l p-0 px-2',
            props.class,
          )
        "
      >
        <slot>
          <Plus :class="iconSize" aria-hidden="true" />
        </slot>
      </NumberFieldIncrement>
    </template>
  • app/components/ui/number-field/NumberFieldInput.vue 5.8 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, nextTick, onMounted, ref, watch } from 'vue'
    import { injectNumberFieldRootContext } from 'reka-ui'
    import { cn } from '@/lib/utils'
    import { injectNumberFieldContext } from './NumberFieldContext'
    
    const props = defineProps<{
      class?: HTMLAttributes['class']
    }>()
    
    const rootContext = injectNumberFieldRootContext()
    const uiContext = injectNumberFieldContext()
    
    const inputRef = ref<HTMLInputElement>()
    
    onMounted(() => {
      if (inputRef.value) {
        rootContext.onInputElement(inputRef.value)
      }
    })
    
    const displayValue = ref('')
    const isUserTyping = ref(false)
    
    function updateDisplayValue(val: number | undefined) {
      const formatter = uiContext.formatter.value
      if (formatter) {
        displayValue.value = formatter(val)
      } else {
        displayValue.value = rootContext.textValue.value
      }
    }
    
    watch(
      () => rootContext.modelValue.value,
      (val) => {
        if (!isUserTyping.value) {
          updateDisplayValue(val)
        }
      },
      { immediate: true },
    )
    
    watch(
      () => rootContext.textValue.value,
      (val) => {
        if (!isUserTyping.value && !uiContext.formatter.value) {
          displayValue.value = val
        }
      },
    )
    
    function handleFocus() {
      isUserTyping.value = true
    }
    
    function handleInput(event: Event) {
      displayValue.value = (event.target as HTMLInputElement).value
    }
    
    function commitValue() {
      isUserTyping.value = false
      const raw = displayValue.value.trim()
    
      if (raw === '') {
        rootContext.modelValue.value = undefined
      } else {
        const parser = uiContext.parser.value
        let num: number | undefined
        if (parser) {
          num = parser(raw)
        } else {
          num = Number(raw)
        }
    
        if (num !== undefined && !Number.isNaN(num)) {
          rootContext.applyInputValue(String(num))
        }
      }
    
      nextTick(() => {
        updateDisplayValue(rootContext.modelValue.value)
      })
    }
    
    function handleBlur() {
      commitValue()
    }
    
    function handleKeydown(event: KeyboardEvent) {
      if (event.key === 'ArrowUp') {
        if (uiContext.keyboard.value) {
          event.preventDefault()
          rootContext.handleIncrease()
        }
      } else if (event.key === 'ArrowDown') {
        if (uiContext.keyboard.value) {
          event.preventDefault()
          rootContext.handleDecrease()
        }
      } else if (event.key === 'PageUp') {
        if (uiContext.keyboard.value) {
          event.preventDefault()
          rootContext.handleIncrease(10)
        }
      } else if (event.key === 'PageDown') {
        if (uiContext.keyboard.value) {
          event.preventDefault()
          rootContext.handleDecrease(10)
        }
      } else if (event.key === 'Home') {
        if (uiContext.keyboard.value) {
          event.preventDefault()
          rootContext.handleMinMaxValue('min')
        }
      } else if (event.key === 'End') {
        if (uiContext.keyboard.value) {
          event.preventDefault()
          rootContext.handleMinMaxValue('max')
        }
      } else if (event.key === 'Enter') {
        commitValue()
      }
    }
    
    function handleWheel(event: WheelEvent) {
      if (rootContext.disableWheelChange.value) return
      if (event.target !== document.activeElement) return
      if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return
      event.preventDefault()
      if (event.deltaY > 0) {
        rootContext.invertWheelChange.value ? rootContext.handleDecrease() : rootContext.handleIncrease()
      } else {
        rootContext.invertWheelChange.value ? rootContext.handleIncrease() : rootContext.handleDecrease()
      }
    }
    
    const isRight = computed(() => uiContext.controlsPosition.value === 'right')
    
    const sizeClasses = computed(() => {
      switch (uiContext.size.value) {
        case 'small':
          return 'h-7 text-xs px-2 py-0.5'
        case 'large':
          return 'h-11 text-base px-4 py-2'
        default:
          return 'h-9 text-sm px-3 py-1'
      }
    })
    
    const statusClasses = computed(() => {
      switch (uiContext.status.value) {
        case 'error':
          return 'border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 aria-invalid:border-destructive'
        case 'warning':
          return 'border-[var(--warning)] focus-visible:ring-[var(--warning)]/20'
        default:
          return ''
      }
    })
    </script>
    
    <template>
      <div data-uipkge data-slot="input" :class="cn('relative flex-1', isRight && 'col-span-1 row-span-2', props.class)">
        <span
          v-if="uiContext.prefix.value"
          class="text-muted-foreground pointer-events-none absolute top-1/2 left-2 -translate-y-1/2 text-sm"
        >
          {{ uiContext.prefix.value }}
        </span>
        <input
          ref="inputRef"
          v-model="displayValue"
          type="text"
          role="spinbutton"
          :aria-valuenow="rootContext.modelValue.value"
          :aria-valuemin="rootContext.min.value"
          :aria-valuemax="rootContext.max.value"
          :inputmode="rootContext.inputMode.value"
          :disabled="rootContext.disabled.value"
          :readonly="rootContext.readonly.value"
          :aria-invalid="uiContext.status.value === 'error' ? true : undefined"
          autocomplete="off"
          autocorrect="off"
          spellcheck="false"
          aria-roledescription="Number field"
          @focus="handleFocus"
          @input="handleInput"
          @blur="handleBlur"
          @keydown="handleKeydown"
          @wheel="handleWheel"
          :class="
            cn(
              'placeholder:text-muted-foreground w-full bg-transparent text-center shadow-sm transition-colors outline-none disabled:cursor-not-allowed disabled:opacity-50',
              !isRight && 'border-input focus-visible:ring-ring rounded-md border focus-visible:ring-1',
              isRight && 'rounded-none border-0 focus-visible:ring-0',
              uiContext.prefix.value && 'pl-6',
              uiContext.suffix.value && 'pr-6',
              sizeClasses,
              !isRight && statusClasses,
            )
          "
        />
        <span
          v-if="uiContext.suffix.value"
          class="text-muted-foreground pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-sm"
        >
          {{ uiContext.suffix.value }}
        </span>
      </div>
    </template>
  • app/components/ui/number-field/index.ts 0.5 kB
    export { default as NumberField } from './NumberField.vue'
    export { default as NumberFieldContent } from './NumberFieldContent.vue'
    export { default as NumberFieldDecrement } from './NumberFieldDecrement.vue'
    export { default as NumberFieldIncrement } from './NumberFieldIncrement.vue'
    export { default as NumberFieldInput } from './NumberFieldInput.vue'
    export { injectNumberFieldContext, provideNumberFieldContext } from './NumberFieldContext'
    export type { NumberFieldControlsPosition, NumberFieldSize, NumberFieldStatus } from './NumberField.vue'

Raw manifest: https://uipkge.dev/r/vue/number-field.json