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)