
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
number-fielduiNumeric 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 ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/number-field.jsonnpx shadcn-vue@latest add @uipkge/number-fieldInstalls to:app/components/ui/number-field/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
size | 'small''middle''large' | — | optional |
status | 'error''warning' | — | optional |
controlsPosition | 'default''right' | 'default' | optional |
keyboard | boolean | true | optional |
precision | number | — | optional |
formatter | (value: number | undefined) => string | — | optional |
parser | (displayValue: string) => number | undefined | — | optional |
prefix | string | — | optional |
suffix | string | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
NumberFieldContextinterface 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>
}<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"
data-uipkge
data-slot="number-field"
v-bind="forwarded"
:format-options="formatOptions"
:class="cn('inline-flex', props.class)"
>
<slot v-bind="slotProps" />
</NumberFieldRoot>
</template>
<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-9 [&>[data-slot=input]]:has-[[data-slot=increment]]:pr-9',
props.class,
)
"
>
<slot />
</div>
</template>
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),
})
}
<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 z-10 -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>
<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 z-10 -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>
<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-warning focus-visible:ring-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 !== undefined && !Number.isNaN(rootContext.modelValue.value)
? rootContext.modelValue.value
: undefined
"
: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>
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