Calendar
Birth Date Fields
Дата рождения тремя полями вместо сетки месяца: день, месяц и год вводятся с клавиатуры, а проверка отдельно разбирает несуществующее число, будущее и возрастной ценз.
- calendar
- birthday
- form
- validation
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/calendar-024
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «calendar-024» (Birth Date Fields) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/calendar-024.json
Registry item: https://vibeui.ru/r/calendar-024.json
Ставится в: components/vibeui/calendar-024.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Дата рождения тремя полями вместо сетки месяца: день, месяц и год вводятся с клавиатуры, а проверка отдельно разбирает несуществующее число, будущее и возрастной ценз.
Ввод даты рождения тремя полями: день, месяц выпадающим списком и год. Календарной сетке до 1985 года нужно двадцать нажатий «назад», а тут дата набирается за секунду. Валидация разбирается на понятные случаи: 31 февраля, дата в будущем, слишком маленький возраст, невероятный год. Ноль зависимостей, один файл, своя палитра.
## 3. Как использовать
import { Calendar024 } from "@/components/vibeui/calendar-024"
<Calendar024 minAge={18} defaultValue="1994-07-19" />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-calendar-024-* и не подменять её токенами вашей темы (border-input, text-destructive и подобными)
- три отдельных поля вместо календаря: выбор года рождения в сетке месяца — это десятки нажатий
- атрибуты autoComplete bday-day, bday-month и bday-year: без них браузер не подставит сохранённую дату
- месяц списком названий, а не числом: 03/04 читается по-разному в разных странах
- разбор ошибок на случаи вместо одного «неверная дата» — человек должен понять, что именно исправлять
- aria-invalid и aria-describedby на всех трёх полях, связанные с одной подписью-объяснением
- подпись с aria-live="polite" и точкой-индикатором: ошибка не должна существовать только как красная рамка
## 6. Можно менять
- label — заголовок группы полей
- defaultValue — начальная дата строкой YYYY-MM-DD или пустая строка
- minAge — минимальный возраст, ниже которого форма ругается
- maxAge — верхняя граница правдоподобия возраста
- today — опорная дата расчёта возраста; locale — локаль названий месяцев
- onChange — обработчик собранной даты; accent — цвет фокуса
## 7. Правила
- Не считайте возраст делением разницы в миллисекундах на 365 дней: високосные годы дают ошибку в день, и на границе ценза это важно.
- Не подставляйте текущую дату из Date.now() внутрь компонента: серверный и клиентский рендер разойдутся, поэтому «сегодня» приходит пропом.
- Не блокируйте ввод цифр в поле дня: люди печатают быстрее, чем думают, и правку удобнее делать после, а не во время набора.
- Возрастной ценз — не единственная проверка: год из двух цифр («94») тоже надо ловить, иначе получите 94-й год нашей эры.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/calendar-024.jsonhttps://vibeui.ru/r/calendar-024.jsonКомпонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-calendar-024-*. Клиентский: три части даты живут в отдельных useState, наружу onChange отдаёт собранную строку YYYY-MM-DD или пустую строку, если дата неполная. Корень — <fieldset> с <legend>, у каждого поля своя <label>, а автозаполнение включено атрибутами autoComplete="bday-day|bday-month|bday-year". Месяц сделан <select> с названиями из Intl, поэтому цифра 07 не спорит с форматом даты в стране пользователя. Проверка собрана в один useMemo и возвращает не булево, а состояние idle/ok/bad с текстом: количество дней в месяце считается через new Date(year, month, 0).getDate(), возраст — сравнением месяца и дня. Состояние поднимается на корень как data-state, поэтому рамки полей и цвет подписи красятся одним правилом.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useId, useMemo, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Calendar024Props = Omit<
ComponentPropsWithoutRef<"fieldset">,
"children" | "onChange"
> & {
label?: string
/** Начальное значение строкой YYYY-MM-DD или пустая строка. */
defaultValue?: string
minAge?: number
maxAge?: number
today?: string
locale?: string
onChange?: (value: string) => void
accent?: string
}
// Идея компонента: дату рождения выбирают не в сетке месяца — до 1985 года
// из неё листать двадцать раз. Три поля вводятся с клавиатуры за секунду,
// а вся сложность уезжает в проверку: 31 февраля, будущее и возрастной ценз
// разбираются отдельными сообщениями, а не одним «неверная дата».
const STYLES = `
:where([data-vibeui-block="calendar-024"]){
--vibeui-calendar-024-bg:oklch(1 0 0);
--vibeui-calendar-024-fg:oklch(0.23 0.014 275);
--vibeui-calendar-024-muted:oklch(0.56 0.014 275);
--vibeui-calendar-024-border:oklch(0.9 0.008 275);
--vibeui-calendar-024-field:oklch(0.985 0.004 275);
--vibeui-calendar-024-accent:oklch(0.51 0.13 275);
--vibeui-calendar-024-ok:oklch(0.5 0.11 155);
--vibeui-calendar-024-bad:oklch(0.55 0.18 25);
--vibeui-calendar-024-radius:0.625rem;
--vibeui-calendar-024-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-024"]{
display:flex;flex-direction:column;gap:0.6rem;
width:100%;max-width:23rem;box-sizing:border-box;
margin:0;padding:1rem;
background:var(--vibeui-calendar-024-bg);
border:1px solid var(--vibeui-calendar-024-border);
border-radius:calc(var(--vibeui-calendar-024-radius) + 0.25rem);
color:var(--vibeui-calendar-024-fg);
font-family:var(--vibeui-calendar-024-font);
}
[data-vibeui-block="calendar-024"] legend{
padding:0;font-size:0.9rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="calendar-024"] [data-part="row"]{
display:grid;grid-template-columns:4.5rem 1fr 5.5rem;gap:0.4rem;align-items:end;
}
[data-vibeui-block="calendar-024"] [data-part="cell"]{
display:flex;flex-direction:column;gap:0.2rem;min-width:0;
}
[data-vibeui-block="calendar-024"] [data-part="cell"] label{
font-size:0.7rem;font-weight:600;letter-spacing:0.03em;text-transform:uppercase;
color:var(--vibeui-calendar-024-muted);
}
[data-vibeui-block="calendar-024"] input,
[data-vibeui-block="calendar-024"] select{
box-sizing:border-box;width:100%;height:2.4rem;padding:0 0.55rem;
border:1px solid var(--vibeui-calendar-024-border);
border-radius:var(--vibeui-calendar-024-radius);
background:var(--vibeui-calendar-024-field);
color:inherit;font:inherit;font-size:0.875rem;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-024"] select{text-transform:capitalize}
[data-vibeui-block="calendar-024"] input:focus-visible,
[data-vibeui-block="calendar-024"] select:focus-visible{
outline:2px solid var(--vibeui-calendar-024-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="calendar-024"][data-state="bad"] input,
[data-vibeui-block="calendar-024"][data-state="bad"] select{
border-color:var(--vibeui-calendar-024-bad);
}
[data-vibeui-block="calendar-024"] [data-part="note"]{
margin:0;display:flex;align-items:center;gap:0.35rem;
min-height:1.15rem;font-size:0.8125rem;color:var(--vibeui-calendar-024-muted);
}
[data-vibeui-block="calendar-024"][data-state="bad"] [data-part="note"]{color:var(--vibeui-calendar-024-bad)}
[data-vibeui-block="calendar-024"][data-state="ok"] [data-part="note"]{color:var(--vibeui-calendar-024-ok)}
[data-vibeui-block="calendar-024"] [data-part="dot"]{
width:0.45rem;height:0.45rem;border-radius:50%;flex:none;background:currentColor;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-024"] *{animation:none!important;transition:none!important}}
`
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate()
}
function pluralize(count: number, forms: [string, string, string]) {
const tens = count % 100
const ones = count % 10
if (tens > 10 && tens < 20) return forms[2]
if (ones === 1) return forms[0]
if (ones > 1 && ones < 5) return forms[1]
return forms[2]
}
/**
* Дата рождения тремя полями с разбором ошибок и подсчётом возраста.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Calendar024({
label = "Дата рождения",
defaultValue = "1994-07-19",
minAge = 18,
maxAge = 120,
today = "2026-04-15",
locale = "ru-RU",
onChange,
accent,
className,
style,
...props
}: Calendar024Props) {
const id = useId()
const parts = defaultValue.split("-")
const [year, setYear] = useState(parts[0] ?? "")
const [month, setMonth] = useState(parts[1] ?? "")
const [day, setDay] = useState(parts[2] ?? "")
const months = useMemo(() => {
const format = new Intl.DateTimeFormat(locale, { month: "long" })
return Array.from({ length: 12 }, (_, index) => ({
value: String(index + 1).padStart(2, "0"),
title: format.format(new Date(2024, index, 1)),
}))
}, [locale])
const check = useMemo(() => {
if (!year || !month || !day) {
return { state: "idle", note: "Введите день, месяц и год" }
}
const numbers = {
year: Number(year),
month: Number(month),
day: Number(day),
}
if (numbers.year < 1000) {
return { state: "idle", note: "Год из четырёх цифр" }
}
if (
numbers.day < 1 ||
numbers.day > daysInMonth(numbers.year, numbers.month)
) {
return {
state: "bad",
note: `В этом месяце ${daysInMonth(numbers.year, numbers.month)} дней`,
}
}
const born = new Date(numbers.year, numbers.month - 1, numbers.day)
const now = new Date(`${today}T00:00:00`)
if (born.getTime() > now.getTime()) {
return { state: "bad", note: "Дата в будущем" }
}
let age = now.getFullYear() - born.getFullYear()
const passed =
now.getMonth() > born.getMonth() ||
(now.getMonth() === born.getMonth() && now.getDate() >= born.getDate())
if (!passed) age -= 1
if (age < minAge) {
return { state: "bad", note: `Нужно не меньше ${minAge} лет` }
}
if (age > maxAge) {
return { state: "bad", note: "Проверьте год: возраст слишком большой" }
}
return {
state: "ok",
note: `${age} ${pluralize(age, ["год", "года", "лет"])}`,
}
}, [year, month, day, today, minAge, maxAge])
const push = (next: { year: string; month: string; day: string }) => {
onChange?.(
next.year && next.month && next.day
? `${next.year}-${next.month.padStart(2, "0")}-${next.day.padStart(2, "0")}`
: "",
)
}
const palette = {
...(accent ? { "--vibeui-calendar-024-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-calendar-024" precedence="medium">
{STYLES}
</style>
<fieldset
{...props}
data-vibeui-block="calendar-024"
data-state={check.state}
className={className}
style={palette}
>
<legend>{label}</legend>
<div data-part="row">
<div data-part="cell">
<label htmlFor={`${id}-day`}>День</label>
<input
id={`${id}-day`}
inputMode="numeric"
autoComplete="bday-day"
maxLength={2}
aria-invalid={check.state === "bad"}
aria-describedby={`${id}-note`}
value={day}
onChange={(event) => {
const next = event.target.value.replace(/\D/g, "").slice(0, 2)
setDay(next)
push({ year, month, day: next })
}}
/>
</div>
<div data-part="cell">
<label htmlFor={`${id}-month`}>Месяц</label>
<select
id={`${id}-month`}
autoComplete="bday-month"
aria-invalid={check.state === "bad"}
aria-describedby={`${id}-note`}
value={month}
onChange={(event) => {
const next = event.target.value
setMonth(next)
push({ year, month: next, day })
}}
>
<option value="">—</option>
{months.map((entry) => (
<option key={entry.value} value={entry.value}>
{entry.title}
</option>
))}
</select>
</div>
<div data-part="cell">
<label htmlFor={`${id}-year`}>Год</label>
<input
id={`${id}-year`}
inputMode="numeric"
autoComplete="bday-year"
maxLength={4}
aria-invalid={check.state === "bad"}
aria-describedby={`${id}-note`}
value={year}
onChange={(event) => {
const next = event.target.value.replace(/\D/g, "").slice(0, 4)
setYear(next)
push({ year: next, month, day })
}}
/>
</div>
</div>
<p id={`${id}-note`} data-part="note" aria-live="polite">
<span data-part="dot" aria-hidden="true" />
{check.note}
</p>
</fieldset>
</>
)
}