reui
Number Field
Number Field — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/number-field/NumberField.vue
<script setup lang="ts">
/**
* Порт ReUI NumberField (registry-reui/bases/radix/reui/number-field.tsx, MIT).
*
* Замена примитива: у Base UI (`@base-ui/react/number-field`) нет Vue-порта,
* используем `NumberField*` из reka-ui — набор частей соответствует
* Base UI по смыслу (Root/Input/Increment/Decrement), кроме `Group` и
* `ScrubArea`, которых в reka-ui нет вовсе (см. NumberFieldGroup.vue и
* NumberFieldScrubArea.vue).
*
* `NumberFieldRoot` в reka-ui уже сам является контейнером с `role="group"`
* и не рендерит переданный `id` на собственный DOM-узел (id уходит во
* внутренний контекст и оттуда — на сам `<input>` в NumberFieldInput).
* В оригинале `id` оседает на корневом div, а `ScrubArea`-Label ссылается
* именно на него. Здесь `for` у Label ссылается на `<input>` — де-факто
* более правильная семантика, но отличная от апстрима; фиксируем как
* осознанное расхождение, вынужденное заменой примитива.
*/
import type { HTMLAttributes } from "vue"
import { computed, provide, useId } from "vue"
import { NumberFieldRoot, type NumberFieldRootEmits, type NumberFieldRootProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { NumberFieldContextKey } from "./context"
const props = withDefaults(
defineProps<
NumberFieldRootProps & {
/** Доп. классы. Имя `class` — соглашение shadcn-vue (не `className`). */
class?: HTMLAttributes["class"]
size?: "sm" | "default" | "lg"
}
>(),
{
size: "default",
}
)
const emit = defineEmits<NumberFieldRootEmits>()
const generatedId = useId()
const fieldId = computed(() => props.id ?? generatedId)
const sizeValue = computed(() => props.size ?? "default")
const rootProps = computed(() => {
const { class: _class, size: _size, ...rest } = props
return rest
})
provide(NumberFieldContextKey, computed(() => ({
fieldId: fieldId.value,
size: sizeValue.value,
})))
</script>
<template>
<NumberFieldRoot
v-bind="rootProps"
:id="fieldId"
data-slot="number-field"
:data-size="sizeValue"
:class="cn('flex w-full flex-col items-start gap-2', props.class)"
@update:model-value="emit('update:modelValue', $event)"
>
<slot />
</NumberFieldRoot>
</template>
src/reui/number-field/NumberFieldDecrement.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, inject } from "vue"
import { NumberFieldDecrement as NumberFieldDecrementPrimitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { NumberFieldContextKey } from "./context"
import { numberFieldButtonVariants, type NumberFieldButtonVariants } from "./variants"
const props = defineProps<{
class?: HTMLAttributes["class"]
size?: NumberFieldButtonVariants["size"]
disabled?: boolean
}>()
const context = inject(NumberFieldContextKey)
if (!context) {
throw new Error("NumberFieldDecrement must be used within a NumberField component.")
}
const size = computed(() => props.size ?? context.value.size)
</script>
<template>
<NumberFieldDecrementPrimitive
:disabled="props.disabled"
:class="
cn(
numberFieldButtonVariants({ size }),
'style-vega:rounded-s-md style-nova:rounded-s-lg style-maia:rounded-s-4xl style-lyra:rounded-s-none style-mira:rounded-s-md style-luma:rounded-s-3xl style-rhea:rounded-s-2xl style-sera:rounded-s-none border-e-0',
props.class
)
"
data-slot="number-field-decrement"
>
<slot>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
</svg>
</slot>
</NumberFieldDecrementPrimitive>
</template>
src/reui/number-field/NumberFieldGroup.vue
<script setup lang="ts">
/**
* У reka-ui нет отдельного примитива `Group` для number-field — тот
* div с `role="group"` уже является самим `NumberFieldRoot` (см.
* NumberField.vue). Здесь — обычный `<div>`, повторяющий разметку и
* классы Base UI `NumberFieldPrimitive.Group`, плюс `data-disabled` /
* `data-readonly`, зеркалящие состояние примитива через
* `injectNumberFieldRootContext`.
*/
import type { HTMLAttributes } from "vue"
import { computed, inject } from "vue"
import { injectNumberFieldRootContext } from "reka-ui"
import { cn } from "@/lib/utils"
import { NumberFieldContextKey } from "./context"
import { numberFieldGroupVariants, type NumberFieldGroupVariants } from "./variants"
const props = defineProps<{
class?: HTMLAttributes["class"]
size?: NumberFieldGroupVariants["size"]
}>()
const context = inject(NumberFieldContextKey)
if (!context) {
throw new Error("NumberFieldGroup must be used within a NumberField component.")
}
const rootContext = injectNumberFieldRootContext()
const size = computed(() => props.size ?? context.value.size)
</script>
<template>
<div
data-slot="number-field-group"
:data-disabled="rootContext.disabled.value ? '' : undefined"
:data-readonly="rootContext.readonly.value ? '' : undefined"
:class="cn(numberFieldGroupVariants({ size }), props.class)"
>
<slot />
</div>
</template>
src/reui/number-field/NumberFieldIncrement.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, inject } from "vue"
import { NumberFieldIncrement as NumberFieldIncrementPrimitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { NumberFieldContextKey } from "./context"
import { numberFieldButtonVariants, type NumberFieldButtonVariants } from "./variants"
const props = defineProps<{
class?: HTMLAttributes["class"]
size?: NumberFieldButtonVariants["size"]
disabled?: boolean
}>()
const context = inject(NumberFieldContextKey)
if (!context) {
throw new Error("NumberFieldIncrement must be used within a NumberField component.")
}
const size = computed(() => props.size ?? context.value.size)
</script>
<template>
<NumberFieldIncrementPrimitive
:disabled="props.disabled"
:class="
cn(
numberFieldButtonVariants({ size }),
'style-vega:rounded-e-md style-nova:rounded-e-lg style-maia:rounded-e-4xl style-lyra:rounded-e-none style-mira:rounded-e-md style-luma:rounded-e-3xl style-rhea:rounded-e-2xl style-sera:rounded-e-none border-s-0',
props.class
)
"
data-slot="number-field-increment"
>
<slot>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
</slot>
</NumberFieldIncrementPrimitive>
</template>
src/reui/number-field/NumberFieldInput.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, inject } from "vue"
import { NumberFieldInput as NumberFieldInputPrimitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { NumberFieldContextKey } from "./context"
import { numberFieldInputVariants, type NumberFieldInputVariants } from "./variants"
const props = defineProps<{
class?: HTMLAttributes["class"]
size?: NumberFieldInputVariants["size"]
}>()
const context = inject(NumberFieldContextKey)
if (!context) {
throw new Error("NumberFieldInput must be used within a NumberField component.")
}
const size = computed(() => props.size ?? context.value.size)
</script>
<template>
<NumberFieldInputPrimitive
:class="cn(numberFieldInputVariants({ size }), props.class)"
data-slot="number-field-input"
/>
</template>
src/reui/number-field/NumberFieldScrubArea.vue
<script setup lang="ts">
/**
* У reka-ui нет аналога `NumberFieldPrimitive.ScrubArea` /
* `ScrubAreaCursor` из Base UI (перетаскивание мышью для инкремента/
* декремента значения с блокировкой курсора). Реализовано вручную поверх
* `injectNumberFieldRootContext` из reka-ui: pointer lock + слежение за
* `movementX` для вызова `handleIncrease`/`handleDecrease`.
*
* Курсор-подсказка (`ScrubAreaCursor`) рендерится только во время активного
* скраба (`isScrubbing`), как и в оригинале, — статичный рендер кейса
* (без взаимодействия) не отличается от Base UI визуально: виден только
* `<label>`.
*/
import type { HTMLAttributes } from "vue"
import { inject, ref } from "vue"
import { injectNumberFieldRootContext } from "reka-ui"
import { cn } from "@/lib/utils"
import { NumberFieldContextKey } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
label: string
}>()
const context = inject(NumberFieldContextKey)
if (!context) {
throw new Error(
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
)
}
const rootContext = injectNumberFieldRootContext()
const scrubAreaEl = ref<HTMLDivElement | null>(null)
const isScrubbing = ref(false)
const cursorPos = ref({ x: 0, y: 0 })
function handlePointerDown(event: PointerEvent) {
if (rootContext.disabled.value || rootContext.readonly.value) return
event.preventDefault()
isScrubbing.value = true
cursorPos.value = { x: event.clientX, y: event.clientY }
scrubAreaEl.value?.requestPointerLock?.()
window.addEventListener("pointermove", handlePointerMove)
window.addEventListener("pointerup", handlePointerUp)
}
function handlePointerMove(event: PointerEvent) {
if (!isScrubbing.value) return
cursorPos.value = {
x: cursorPos.value.x + event.movementX,
y: cursorPos.value.y + event.movementY,
}
if (event.movementX > 0) rootContext.handleIncrease()
else if (event.movementX < 0) rootContext.handleDecrease()
}
function handlePointerUp() {
isScrubbing.value = false
document.exitPointerLock?.()
window.removeEventListener("pointermove", handlePointerMove)
window.removeEventListener("pointerup", handlePointerUp)
}
</script>
<template>
<div
ref="scrubAreaEl"
data-slot="number-field-scrub-area"
:class="cn('flex cursor-ew-resize', props.class)"
@pointerdown="handlePointerDown"
>
<label
data-slot="label"
:for="context.fieldId"
class="cn-label flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed cursor-ew-resize"
>{{ label }}</label>
<div
v-if="isScrubbing"
data-slot="number-field-scrub-area-cursor"
class="drop-shadow-[0_1px_1px_#0008] filter fixed pointer-events-none"
:style="{
left: `${cursorPos.x}px`,
top: `${cursorPos.y}px`,
transform: 'translate(-50%, -50%)',
}"
>
<svg
fill="black"
height="14"
stroke="white"
viewBox="0 0 24 14"
width="26"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z"
/>
</svg>
</div>
</div>
</template>
src/reui/number-field/context.ts
import type { ComputedRef, InjectionKey } from "vue"
/**
* Локальный контекст ReUI number-field (fieldId + size), не путать с
* состоянием примитива reka-ui `NumberFieldRoot` (значение, min/max и т.д.),
* которое доступно отдельно через `injectNumberFieldRootContext` из reka-ui.
*
* Соответствует React-оригиналу: там тоже отдельный `NumberFieldContext`
* (createContext) поверх Base UI, хранящий только `fieldId` и `size`.
*/
export interface NumberFieldContextValue {
fieldId: string
size: "sm" | "default" | "lg"
}
export const NumberFieldContextKey: InjectionKey<
ComputedRef<NumberFieldContextValue>
> = Symbol("NumberFieldContext")
src/reui/number-field/index.ts
export { default as NumberField } from "./NumberField.vue"
export { default as NumberFieldGroup } from "./NumberFieldGroup.vue"
export { default as NumberFieldDecrement } from "./NumberFieldDecrement.vue"
export { default as NumberFieldIncrement } from "./NumberFieldIncrement.vue"
export { default as NumberFieldInput } from "./NumberFieldInput.vue"
export { default as NumberFieldScrubArea } from "./NumberFieldScrubArea.vue"
export {
numberFieldGroupVariants,
numberFieldButtonVariants,
numberFieldInputVariants,
type NumberFieldGroupVariants,
type NumberFieldButtonVariants,
type NumberFieldInputVariants,
} from "./variants"
src/reui/number-field/variants.ts
import { cva, type VariantProps } from "class-variance-authority"
/**
* Перенесено дословно из ReUI (registry-reui/bases/radix/reui/number-field.tsx, MIT).
*
* cva-блоки — чистые JS-объекты и переносятся между React и Vue без единой
* правки. Это и есть главный рычаг всего порта: вся визуальная семантика
* живёт здесь, а фреймворк-специфичной остаётся только разметка.
*
* Строки классов в `numberFieldButtonVariants` содержат искажённые
* фрагменты вида `([class*='size-'])]:size-3.5` — так выглядит апстрим.
* Согласно правилу 1 (posимвольный перенос), это не чинится.
*/
export const numberFieldGroupVariants = cva(
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 style-vega:rounded-md style-nova:rounded-lg style-maia:rounded-4xl style-lyra:rounded-none style-mira:rounded-md style-luma:rounded-3xl style-rhea:rounded-2xl style-sera:rounded-none bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
{
variants: {
size: {
sm: "h-7 text-sm",
default: "h-8 text-sm",
lg: "h-9 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
export const numberFieldButtonVariants = cva(
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
{
variants: {
size: {
sm: "px-1.5 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3.5 [&_svg:not([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-3.5",
default:
"px-2 ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5",
lg: "px-2.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5",
},
},
defaultVariants: {
size: "default",
},
}
)
export const numberFieldInputVariants = cva(
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
{
variants: {
size: {
sm: "px-2 py-0.5",
default: "px-2.5 py-1",
lg: "px-2.5 py-1.5",
},
},
defaultVariants: {
size: "default",
},
}
)
export type NumberFieldGroupVariants = VariantProps<typeof numberFieldGroupVariants>
export type NumberFieldButtonVariants = VariantProps<typeof numberFieldButtonVariants>
export type NumberFieldInputVariants = VariantProps<typeof numberFieldInputVariants>
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/number-field.jsonЗависимости реестра
npm-зависимости
- class-variance-authority
- reka-ui
Источник: порт из ReUI (Keenthemes, MIT)