Calendar

Birth Date Fields

Дата рождения тремя полями вместо сетки месяца: день, месяц и год вводятся с клавиатуры, а проверка отдельно разбирает несуществующее число, будущее и возрастной ценз.

  • calendar
  • birthday
  • form
  • validation

Превью

1440pxHost theme

Использовать с AI

  1. 1. Скопируйте ссылку.
  2. 2. Напишите агенту своими словами и вставьте её в предложение.
  3. 3. Агент откроет ссылку и поставит компонент из registry.

размести это в шапке: https://vibeui.ru/c/calendar-024

Дата рождения

31 год

Показать полную инструкцию

То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.

# Установка и размещение «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.json
https://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>
    </>
  )
}