Inputs
Unit Dropdown
Число с выпадающим выбором единицы в одной рамке: у каждой единицы есть пояснение словом, а под полем видно значение в базовых единицах.
- input
- unit
- number
- listbox
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/input-010
Это 1 800 секунд — столько ссылка останется рабочей.
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «input-010» (Unit Dropdown) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/input-010.json
Registry item: https://vibeui.ru/r/input-010.json
Ставится в: components/vibeui/input-010.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Число с выпадающим выбором единицы в одной рамке: у каждой единицы есть пояснение словом, а под полем видно значение в базовых единицах.
Числовое поле с выпадающим списком единиц в общей рамке: подпись единицы словом, закрытие по Escape и по уходу фокуса, строка пересчёта в базовые единицы. Ноль зависимостей, один файл.
## 3. Как использовать
import { Input010 } from "@/components/vibeui/input-010"
<Input010
label="Время жизни ссылки"
defaultValue={30}
onChange={(value, unit) => save(value, unit)}
/>
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-input-010-* и не подменять её токенами вашей темы
- рамку на группе: у поля и у кнопки её нет, иначе внутри рамки появятся ещё две
- закрытие списка по Escape и по уходу фокуса из рамки — иначе он остаётся висеть
- возврат фокуса на кнопку после выбора: иначе фокус теряется вместе со списком
- неизменность числа при смене единицы: человек ввёл «30» и имел в виду «30»
- строку пересчёта: без неё выбор единицы ни с чем не связан
## 6. Можно менять
- текст label и стартовое число defaultValue
- массив units — свои единицы и множители
- формулировку строки пересчёта
- акцент через проп accent
## 7. Правила
- Пересчёт в базовые единицы делайте на сервере: клиентское значение — только для показа.
- Список units держите коротким: он открывается на всю ширину рамки и длинный перебор читать неудобно.
- Стрелки вверх-вниз внутри списка не реализованы: если это критично, добавьте их поверх текущей разметки.
- Не выносите CSS-переменные в globals.css: компонент обязан оставаться одним файлом.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/input-010.jsonhttps://vibeui.ru/r/input-010.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-input-010-*. Клиентский: "use client". Список — своя кнопка с role="listbox" и кнопками role="option": в нативный <option> нельзя положить пояснение. Список позиционируется от рамки (position:relative на ней), закрывается по Escape и по уходу фокуса из рамки, фокус возвращается на кнопку. Число при смене единицы не пересчитывается, пересчёт показан отдельной строкой.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useId, useRef, useState } from "react"
import type {
ComponentPropsWithoutRef,
CSSProperties,
KeyboardEvent,
} from "react"
export type Input010Unit = {
code: string
title: string
/** Сколько базовых единиц в одной этой. */
factor: number
}
export type Input010Props = Omit<
ComponentPropsWithoutRef<"div">,
"children" | "defaultValue" | "onChange"
> & {
label?: string
units?: Input010Unit[]
defaultValue?: number
onChange?: (value: number, unit: string) => void
accent?: string
}
// Идея компонента: единица выбирается из выпадающего списка внутри той же
// рамки, что и число, и подпись каждой единицы — слово целиком, а не сокращение
// в две буквы. Число при смене единицы не пересчитывается: пользователь ввёл
// «5» и имел в виду «5», а под полем показывается, сколько это в базовых
// единицах. Список — своя кнопка со списком, чтобы поместились пояснения,
// которые нативный select показать не умеет.
const STYLES = `
:where([data-vibeui-block="input-010"]){
--vibeui-input-010-surface:oklch(1 0 0);
--vibeui-input-010-shell:oklch(0.91 0.006 265);
--vibeui-input-010-fg:oklch(0.23 0.014 265);
--vibeui-input-010-muted:oklch(0.55 0.014 265);
--vibeui-input-010-field:oklch(0.985 0.002 265);
--vibeui-input-010-border:oklch(0.88 0.008 265);
--vibeui-input-010-accent:oklch(0.53 0.15 165);
--vibeui-input-010-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="input-010"]{
display:flex;flex-direction:column;gap:0.4375rem;
width:100%;max-width:21rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-input-010-surface);
border:1px solid var(--vibeui-input-010-shell);border-radius:0.875rem;
font-family:var(--vibeui-input-010-font);color:var(--vibeui-input-010-fg);
}
[data-vibeui-block="input-010"] *{box-sizing:border-box}
[data-vibeui-block="input-010"] label{font-size:0.8125rem;font-weight:600}
/* Позиционируем список от рамки, а не от корня: рамка и есть якорь. */
[data-vibeui-block="input-010"] [data-part="frame"]{
position:relative;display:flex;align-items:stretch;
height:2.75rem;
background:var(--vibeui-input-010-field);
border:1px solid var(--vibeui-input-010-border);border-radius:0.75rem;
transition:border-color .16s ease,box-shadow .16s ease;
}
[data-vibeui-block="input-010"] [data-part="frame"]:focus-within{
border-color:var(--vibeui-input-010-accent);
box-shadow:0 0 0 3px color-mix(in oklab,var(--vibeui-input-010-accent) 18%,transparent);
}
[data-vibeui-block="input-010"] input{
flex:1;min-width:0;height:100%;padding:0 0.75rem;
border:0;background:none;color:inherit;border-radius:0.75rem 0 0 0.75rem;
font:inherit;font-size:1rem;font-weight:600;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="input-010"] input:focus{outline:none}
[data-vibeui-block="input-010"] input::-webkit-outer-spin-button,
[data-vibeui-block="input-010"] input::-webkit-inner-spin-button{appearance:none;margin:0}
[data-vibeui-block="input-010"] [data-part="unit"]{
appearance:none;cursor:pointer;flex:none;
display:inline-flex;align-items:center;gap:0.375rem;
padding:0 0.75rem;border:0;border-left:1px solid var(--vibeui-input-010-border);
border-radius:0 0.75rem 0.75rem 0;
background:color-mix(in oklab,var(--vibeui-input-010-fg) 4%,transparent);
color:inherit;font:inherit;font-size:0.8125rem;font-weight:650;
transition:background-color .16s ease;
}
[data-vibeui-block="input-010"] [data-part="unit"]:hover{
background:color-mix(in oklab,var(--vibeui-input-010-fg) 8%,transparent);
}
[data-vibeui-block="input-010"] [data-part="unit"]:focus-visible{
outline:2px solid var(--vibeui-input-010-accent);outline-offset:-2px;
}
[data-vibeui-block="input-010"] [data-part="unit"] svg{
width:0.75rem;height:0.75rem;display:block;color:var(--vibeui-input-010-muted);
transition:transform .16s ease;
}
[data-vibeui-block="input-010"] [data-part="unit"][aria-expanded="true"] svg{transform:rotate(180deg)}
[data-vibeui-block="input-010"] [data-part="list"]{
position:absolute;z-index:2;top:calc(100% + 0.375rem);right:0;min-width:11rem;
margin:0;padding:0.25rem;display:flex;flex-direction:column;
background:var(--vibeui-input-010-surface);
border:1px solid var(--vibeui-input-010-border);border-radius:0.75rem;
box-shadow:0 12px 28px -12px color-mix(in oklab,var(--vibeui-input-010-fg) 40%,transparent);
}
[data-vibeui-block="input-010"] [data-part="option"]{
display:flex;align-items:baseline;gap:0.5rem;width:100%;
padding:0.4375rem 0.5rem;border:0;border-radius:0.5rem;
background:none;color:inherit;font:inherit;font-size:0.8125rem;
cursor:pointer;text-align:left;
}
[data-vibeui-block="input-010"] [data-part="option"]:hover,
[data-vibeui-block="input-010"] [data-part="option"]:focus-visible{
outline:none;background:color-mix(in oklab,var(--vibeui-input-010-accent) 12%,transparent);
}
[data-vibeui-block="input-010"] [data-part="option"][aria-selected="true"]{font-weight:650}
[data-vibeui-block="input-010"] [data-part="option"] em{
font-style:normal;color:var(--vibeui-input-010-muted);font-size:0.75rem;
}
[data-vibeui-block="input-010"] [data-part="note"]{
margin:0;font-size:0.75rem;line-height:1.4;color:var(--vibeui-input-010-muted);
}
[data-vibeui-block="input-010"] [data-part="note"] b{
color:var(--vibeui-input-010-fg);font-weight:650;font-variant-numeric:tabular-nums;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="input-010"] *{animation:none!important;transition:none!important}}
`
const UNITS: Input010Unit[] = [
{ code: "мин", title: "минуты", factor: 60 },
{ code: "ч", title: "часы", factor: 3600 },
{ code: "сут", title: "сутки", factor: 86400 },
]
/**
* Число с выпадающим выбором единицы измерения в одной рамке.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Input010({
label = "Время жизни ссылки",
units = UNITS,
defaultValue = 30,
onChange,
accent,
className,
style,
...props
}: Input010Props) {
const id = useId()
const [value, setValue] = useState(defaultValue)
const [unit, setUnit] = useState(units[0]?.code ?? "")
const [open, setOpen] = useState(false)
const trigger = useRef<HTMLButtonElement | null>(null)
const palette = {
...(accent ? { "--vibeui-input-010-accent": accent } : null),
...style,
} as CSSProperties
const current = units.find((entry) => entry.code === unit) ?? units[0]
const seconds = Math.round(value * (current?.factor ?? 1))
const choose = (code: string) => {
setUnit(code)
setOpen(false)
trigger.current?.focus()
onChange?.(value, code)
}
const onListKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault()
setOpen(false)
trigger.current?.focus()
}
}
return (
<>
<style href="vibeui-input-010" precedence="medium">
{STYLES}
</style>
<div
{...props}
data-vibeui-block="input-010"
className={className}
style={palette}
>
<label htmlFor={id}>{label}</label>
<div
data-part="frame"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
setOpen(false)
}}
>
<input
id={id}
type="number"
inputMode="numeric"
min={1}
value={value}
aria-describedby={`${id}-note`}
onChange={(event) => {
const next = Number(event.target.value)
setValue(next)
onChange?.(next, unit)
}}
/>
<button
ref={trigger}
type="button"
data-part="unit"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={`Единица измерения: ${current?.title ?? ""}`}
onClick={() => setOpen((was) => !was)}
>
{current?.code}
<svg
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
aria-hidden="true"
>
<path
d="m3 4.5 3 3 3-3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open ? (
<div
data-part="list"
role="listbox"
aria-label="Единицы"
onKeyDown={onListKeyDown}
>
{units.map((entry) => (
<button
key={entry.code}
type="button"
data-part="option"
role="option"
aria-selected={entry.code === unit}
onClick={() => choose(entry.code)}
>
{entry.code} <em>{entry.title}</em>
</button>
))}
</div>
) : null}
</div>
<p data-part="note" id={`${id}-note`} aria-live="polite">
Это <b>{seconds.toLocaleString("ru-RU")}</b> секунд — столько ссылка
останется рабочей.
</p>
</div>
</>
)
}