InputNumber
Input numerical values with a customizable range.
Demo
Related Components
This requires the following components to be installed:
Related Composables
This requires the following composables to be installed:
Related Theme
This requires the following theme to be installed:
Component
vue
<script lang="ts">
import type { ButtonProps } from '@/ui/components/Button.vue'
import type { NumberFieldRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Button from '@/ui/components/Button.vue'
import { useFormField } from '@/ui/composables/useFormField'
import { chevronDownIcon, chevronUpIcon, minusIcon, plusIcon } from '@/ui/icons'
import theme from '@/ui/theme/input-number'
import { reactivePick } from '@vueuse/shared'
import { NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref } from 'vue'
const inputNumber = tv(theme)
type InputNumberVariants = VariantProps<typeof inputNumber>
export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue' | 'defaultValue' | 'min' | 'max' | 'step' | 'disabled' | 'required' | 'id' | 'name' | 'formatOptions'> {
as?: any
placeholder?: string
color?: InputNumberVariants['color']
variant?: InputNumberVariants['variant']
size?: InputNumberVariants['size']
highlight?: boolean
orientation?: 'vertical' | 'horizontal'
increment?: ButtonProps
decrement?: ButtonProps
autofocus?: boolean
autofocusDelay?: number
locale?: string
class?: any
ui?: Partial<typeof inputNumber.slots>
}
export interface InputNumberEmits {
(e: 'update:modelValue', payload: number): void
(e: 'blur', event: FocusEvent): void
(e: 'change', payload: Event): void
}
export interface InputNumberSlots {
increment: (props?: object) => any
decrement: (props?: object) => any
}
</script>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputNumberProps>(), {
orientation: 'horizontal',
})
const emits = defineEmits<InputNumberEmits>()
defineSlots<InputNumberSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)
const { id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
const locale = computed(() => props.locale)
const ui = computed(() => inputNumber({
color: color.value,
variant: props.variant,
size: size.value,
highlight: highlight.value,
orientation: props.orientation,
}))
const incrementIcon = computed(() => (props.orientation === 'horizontal' ? plusIcon : chevronUpIcon))
const decrementIcon = computed(() => (props.orientation === 'horizontal' ? minusIcon : chevronDownIcon))
const inputRef = ref<InstanceType<typeof NumberFieldInput> | null>(null)
function autoFocus() {
if (props.autofocus) {
inputRef.value?.$el?.focus()
}
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
function onUpdate(value: number) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
}
function onBlur(event: FocusEvent) {
emits('blur', event)
}
defineExpose({
inputRef,
})
</script>
<template>
<NumberFieldRoot
v-bind="rootProps"
:id="id"
:class="ui.root({ class: [props.class, props.ui?.root] })"
:name="name"
:disabled="disabled"
:locale="locale"
@update:model-value="onUpdate"
>
<NumberFieldInput
v-bind="{ ...$attrs, ...ariaAttrs }"
ref="inputRef"
:placeholder="placeholder"
:required="required"
:class="ui.base({ class: props.ui?.base })"
@blur="onBlur"
/>
<div :class="ui.increment({ class: props.ui?.increment })">
<NumberFieldIncrement as-child :disabled="disabled">
<slot name="increment">
<Button
:icon="incrementIcon"
:color="color"
:size="size"
variant="link"
aria-label="Increment"
v-bind="typeof increment === 'object' ? increment : undefined"
/>
</slot>
</NumberFieldIncrement>
</div>
<div :class="ui.decrement({ class: props.ui?.decrement })">
<NumberFieldDecrement as-child :disabled="disabled">
<slot name="decrement">
<Button
:icon="decrementIcon"
:color="color"
:size="size"
variant="link"
aria-label="Decrement"
v-bind="typeof decrement === 'object' ? decrement : undefined"
/>
</slot>
</NumberFieldDecrement>
</div>
</NumberFieldRoot>
</template>vue
<script lang="ts">
import type { ButtonProps } from '@/UI/Components/Button.vue'
import type { NumberFieldRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Button from '@/UI/Components/Button.vue'
import { useFormField } from '@/UI/Composables/useFormField'
import { chevronDownIcon, chevronUpIcon, minusIcon, plusIcon } from '@/UI/icons'
import theme from '@/UI/Theme/input-number'
import { reactivePick } from '@vueuse/shared'
import { NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput, NumberFieldRoot, useForwardPropsEmits } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed, onMounted, ref } from 'vue'
const inputNumber = tv(theme)
type InputNumberVariants = VariantProps<typeof inputNumber>
export interface InputNumberProps extends Pick<NumberFieldRootProps, 'modelValue' | 'defaultValue' | 'min' | 'max' | 'step' | 'disabled' | 'required' | 'id' | 'name' | 'formatOptions'> {
as?: any
placeholder?: string
color?: InputNumberVariants['color']
variant?: InputNumberVariants['variant']
size?: InputNumberVariants['size']
highlight?: boolean
orientation?: 'vertical' | 'horizontal'
increment?: ButtonProps
decrement?: ButtonProps
autofocus?: boolean
autofocusDelay?: number
locale?: string
class?: any
ui?: Partial<typeof inputNumber.slots>
}
export interface InputNumberEmits {
(e: 'update:modelValue', payload: number): void
(e: 'blur', event: FocusEvent): void
(e: 'change', payload: Event): void
}
export interface InputNumberSlots {
increment: (props?: object) => any
decrement: (props?: object) => any
}
</script>
<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<InputNumberProps>(), {
orientation: 'horizontal',
})
const emits = defineEmits<InputNumberEmits>()
defineSlots<InputNumberSlots>()
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)
const { id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
const locale = computed(() => props.locale)
const ui = computed(() => inputNumber({
color: color.value,
variant: props.variant,
size: size.value,
highlight: highlight.value,
orientation: props.orientation,
}))
const incrementIcon = computed(() => (props.orientation === 'horizontal' ? plusIcon : chevronUpIcon))
const decrementIcon = computed(() => (props.orientation === 'horizontal' ? minusIcon : chevronDownIcon))
const inputRef = ref<InstanceType<typeof NumberFieldInput> | null>(null)
function autoFocus() {
if (props.autofocus) {
inputRef.value?.$el?.focus()
}
}
onMounted(() => {
setTimeout(() => {
autoFocus()
}, props.autofocusDelay)
})
function onUpdate(value: number) {
// @ts-expect-error - 'target' does not exist in type 'EventInit'
const event = new Event('change', { target: { value } })
emits('change', event)
}
function onBlur(event: FocusEvent) {
emits('blur', event)
}
defineExpose({
inputRef,
})
</script>
<template>
<NumberFieldRoot
v-bind="rootProps"
:id="id"
:class="ui.root({ class: [props.class, props.ui?.root] })"
:name="name"
:disabled="disabled"
:locale="locale"
@update:model-value="onUpdate"
>
<NumberFieldInput
v-bind="{ ...$attrs, ...ariaAttrs }"
ref="inputRef"
:placeholder="placeholder"
:required="required"
:class="ui.base({ class: props.ui?.base })"
@blur="onBlur"
/>
<div :class="ui.increment({ class: props.ui?.increment })">
<NumberFieldIncrement as-child :disabled="disabled">
<slot name="increment">
<Button
:icon="incrementIcon"
:color="color"
:size="size"
variant="link"
aria-label="Increment"
v-bind="typeof increment === 'object' ? increment : undefined"
/>
</slot>
</NumberFieldIncrement>
</div>
<div :class="ui.decrement({ class: props.ui?.decrement })">
<NumberFieldDecrement as-child :disabled="disabled">
<slot name="decrement">
<Button
:icon="decrementIcon"
:color="color"
:size="size"
variant="link"
aria-label="Decrement"
v-bind="typeof decrement === 'object' ? decrement : undefined"
/>
</slot>
</NumberFieldDecrement>
</div>
</NumberFieldRoot>
</template>Theme
ts
export default {
slots: {
root: '',
base: '',
increment: '',
decrement: '',
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: '',
},
size: {
xs: '',
sm: '',
md: '',
lg: '',
xl: '',
},
variant: {
outline: '',
soft: '',
subtle: '',
ghost: '',
none: '',
},
disabled: {
true: {
increment: '',
decrement: '',
},
},
orientation: {
horizontal: {
base: '',
increment: '',
decrement: '',
},
vertical: {
increment: '',
decrement: '',
},
},
highlight: {
true: '',
},
},
compoundVariants: [],
defaultVariants: {
size: 'md',
color: 'primary',
variant: 'outline',
} as const,
}View Nuxt UI theme
ts
export default {
slots: {
root: 'relative inline-flex items-center',
base: 'w-full rounded-md border-0 placeholder:text-dimmed focus:outline-none disabled:cursor-not-allowed disabled:opacity-75 transition-colors',
increment: 'absolute flex items-center',
decrement: 'absolute flex items-center',
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: '',
},
size: {
xs: 'px-2 py-1 text-xs gap-1',
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
md: 'px-2.5 py-1.5 text-sm gap-1.5',
lg: 'px-3 py-2 text-sm gap-2',
xl: 'px-3 py-2 text-base gap-2',
},
variant: {
outline: 'text-highlighted bg-default ring ring-inset ring-accented',
soft: 'text-highlighted bg-elevated/50 hover:bg-elevated focus:bg-elevated disabled:bg-elevated/50',
subtle: 'text-highlighted bg-elevated ring ring-inset ring-accented',
ghost: 'text-highlighted bg-transparent hover:bg-elevated focus:bg-elevated disabled:bg-transparent dark:disabled:bg-transparent',
none: 'text-highlighted bg-transparent',
},
disabled: {
true: {
increment: 'opacity-75 cursor-not-allowed',
decrement: 'opacity-75 cursor-not-allowed',
},
},
orientation: {
horizontal: {
base: 'text-center',
increment: 'inset-y-0 end-0 pe-1',
decrement: 'inset-y-0 start-0 ps-1',
},
vertical: {
increment: 'top-0 end-0 pe-1 [&>button]:py-0 scale-80',
decrement: 'bottom-0 end-0 pe-1 [&>button]:py-0 scale-80',
},
},
highlight: {
true: '',
},
},
compoundVariants: [
{
color: 'primary' as const,
variant: ['outline' as const, 'subtle' as const],
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
},
{
color: 'primary',
highlight: true,
class: 'ring ring-inset ring-primary',
} as const,
{
color: 'secondary' as const,
variant: ['outline' as const, 'subtle' as const],
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-secondary',
},
{
color: 'secondary',
highlight: true,
class: 'ring ring-inset ring-secondary',
} as const,
{
color: 'success' as const,
variant: ['outline' as const, 'subtle' as const],
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-success',
},
{
color: 'success',
highlight: true,
class: 'ring ring-inset ring-success',
} as const,
{
color: 'info' as const,
variant: ['outline' as const, 'subtle' as const],
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-info',
},
{
color: 'info',
highlight: true,
class: 'ring ring-inset ring-info',
} as const,
{
color: 'warning' as const,
variant: ['outline' as const, 'subtle' as const],
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-warning',
},
{
color: 'warning',
highlight: true,
class: 'ring ring-inset ring-warning',
} as const,
{
color: 'error' as const,
variant: ['outline' as const, 'subtle' as const],
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-error',
},
{
color: 'error',
highlight: true,
class: 'ring ring-inset ring-error',
} as const,
{
color: 'neutral' as const,
variant: ['outline' as const, 'subtle' as const],
class: 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-inverted',
},
{
color: 'neutral',
highlight: true,
class: 'ring ring-inset ring-inverted',
} as const,
{
orientation: 'horizontal',
size: 'xs',
class: 'px-7',
} as const,
{
orientation: 'horizontal',
size: 'sm',
class: 'px-8',
} as const,
{
orientation: 'horizontal',
size: 'md',
class: 'px-9',
} as const,
{
orientation: 'horizontal',
size: 'lg',
class: 'px-10',
} as const,
{
orientation: 'horizontal',
size: 'xl',
class: 'px-11',
} as const,
{
orientation: 'vertical',
size: 'xs',
class: 'pe-7',
} as const,
{
orientation: 'vertical',
size: 'sm',
class: 'pe-8',
} as const,
{
orientation: 'vertical',
size: 'md',
class: 'pe-9',
} as const,
{
orientation: 'vertical',
size: 'lg',
class: 'pe-10',
} as const,
{
orientation: 'vertical',
size: 'xl',
class: 'pe-11',
} as const,
],
defaultVariants: {
size: 'md',
color: 'primary',
variant: 'outline',
} as const,
}Test
To test this component, you can use the following test file:
ts
import type { RenderOptions } from '@testing-library/vue'
import InputNumber from '@/ui/components/InputNumber.vue'
import theme from '@/ui/theme/input-number'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('input', () => {
const sizes = Object.keys(theme.variants.size) as any
const variants = Object.keys(theme.variants.variant) as any
it.each<[string, RenderOptions<typeof InputNumber>]>([
// Props
['with name', { props: { name: 'name' } }],
['with placeholder', { props: { placeholder: 'Number...' } }],
['with disabled', { props: { disabled: true } }],
['with required', { props: { required: true } }],
['with orientation vertical', { props: { orientation: 'vertical' } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { variant } }]),
...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { variant, color: 'neutral' } }]),
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'absolute' } }],
['with ui', { props: { ui: { base: 'rounded-full' } } }],
// Slots
['with increment slot', { slots: { increment: () => '+' } }],
['with decrement slot', { slots: { decrement: () => '-' } }],
])(`renders %s correctly`, (name, options) => {
const { html } = render(InputNumber, options)
expect(html()).toMatchSnapshot()
})
})ts
import type { RenderOptions } from '@testing-library/vue'
import InputNumber from '@/UI/Components/InputNumber.vue'
import theme from '@/UI/Theme/input-number'
import { render } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
describe('input', () => {
const sizes = Object.keys(theme.variants.size) as any
const variants = Object.keys(theme.variants.variant) as any
it.each<[string, RenderOptions<typeof InputNumber>]>([
// Props
['with name', { props: { name: 'name' } }],
['with placeholder', { props: { placeholder: 'Number...' } }],
['with disabled', { props: { disabled: true } }],
['with required', { props: { required: true } }],
['with orientation vertical', { props: { orientation: 'vertical' } }],
...sizes.map((size: string) => [`with size ${size}`, { props: { size } }]),
...variants.map((variant: string) => [`with primary variant ${variant}`, { props: { variant } }]),
...variants.map((variant: string) => [`with neutral variant ${variant}`, { props: { variant, color: 'neutral' } }]),
['with as', { props: { as: 'section' } }],
['with class', { props: { class: 'absolute' } }],
['with ui', { props: { ui: { base: 'rounded-full' } } }],
// Slots
['with increment slot', { slots: { increment: () => '+' } }],
['with decrement slot', { slots: { decrement: () => '-' } }],
])(`renders %s correctly`, (name, options) => {
const { html } = render(InputNumber, options)
expect(html()).toMatchSnapshot()
})
})