Calendar

Capped Range

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

  • calendar
  • range
  • limit
  • booking

Превью

1440pxHost theme

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

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

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

апрель 2026 г.

Не больше 14 дней подряд

пнвтсрчтптсбвс

Выбрано 7 из 14 дней

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

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

# Установка и размещение «calendar-030» (Capped Range) из VibeUI

## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/calendar-030.json

Registry item: https://vibeui.ru/r/calendar-030.json
Ставится в: components/vibeui/calendar-030.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.

Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.

## 2. Что это
Диапазон с потолком в N дней: после выбора первой даты недоступные дни гаснут, а счётчик снизу показывает, сколько дней ещё осталось в лимите.

Выбор диапазона дат с жёстким ограничением длины: аренда не больше двух недель, отпуск не больше 28 дней, отчёт не глубже месяца. Правило работает не сообщением после отправки, а формой сетки — после первой даты лишние дни физически гаснут, а полоса и счётчик показывают остаток лимита. Ноль зависимостей, один файл, своя палитра.

## 3. Как использовать
import { Calendar030 } from "@/components/vibeui/calendar-030"

<Calendar030 maxDays={14} month="2026-04" />

Полный список пропсов — в установленном файле.

## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.

Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)

## 5. Сохранить как установлено
- локальную палитру --vibeui-calendar-030-* и не подменять её токенами вашей темы (bg-accent, text-muted-foreground и подобными)
- выражение лимита состоянием сетки, а не ошибкой после отправки: запрет должен быть виден до нажатия
- нативный disabled на недоступных днях — он выкидывает их из порядка табуляции и объявляется скринридером
- промежуточное состояние «выбрано только начало» с подсказкой об остатке дней
- включающий подсчёт длины (конец минус начало плюс один): диапазон с 6 по 12 — это семь дней
- полосу заполнения от --vibeui-calendar-030-fill: она показывает исчерпание лимита без чтения чисел
- итог с aria-live="polite": гашение дней невидимо для скринридера, объяснение должно звучать словами

## 6. Можно менять
- month — показываемый месяц строкой YYYY-MM
- maxDays — максимальная длина диапазона в днях
- defaultFrom и defaultTo — границы, выбранные при первом рендере
- locale — локаль заголовка месяца и дней недели
- onChange — обработчик обеих границ; пустая вторая означает незавершённый выбор
- accent — цвет краёв диапазона и полосы заполнения

## 7. Правила
- Показан один месяц: если лимит больше 20 дней, диапазон перестанет помещаться и нужен вариант с двумя месяцами.
- Кнопка «Сбросить» обязательна: без неё из состояния «выбрано начало» нельзя выйти, не выбрав конец.
- Не заменяйте disabled приглушённым цветом: клавиатура пройдёт по заблокированным дням и выберет запрещённое.
- Лимит здесь считается в календарных днях; для рабочих дней нужен другой расчёт и другой текст подписи.

## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.

Для разработчика

npx shadcn@latest add https://vibeui.ru/r/calendar-030.json
https://vibeui.ru/r/calendar-030.json

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-calendar-030-*. Клиентский: обе границы диапазона живут в useState. Ограничение выражено не сообщением об ошибке, а состоянием сетки: пока выбрана только первая дата, функция blocked гасит дни раньше начала и дальше maxDays-1 через нативный disabled, поэтому запрет объявляется и скринридеру. Число строк сетки считается по Math.ceil((offset+дней)/7)*7, чтобы месяц не оставлял пустую шестую неделю. Полоса прогресса под сеткой заполняется переменной --vibeui-calendar-030-fill из отношения длины диапазона к лимиту. Итог склоняется через pluralize и объявляется через aria-live.

Исходник компонента

Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.

"use client"

import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Calendar030Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "onChange"
> & {
  month?: string
  maxDays?: number
  defaultFrom?: string
  defaultTo?: string
  locale?: string
  onChange?: (from: string, to: string) => void
  accent?: string
}

// Идея компонента: ограничение «не больше N дней» нельзя объяснять ошибкой
// после отправки формы. Здесь после выбора первой даты недоступные дни
// физически гаснут: правило видно как форма сетки, а счётчик снизу говорит,
// сколько дней ещё осталось в лимите.
const STYLES = `
:where([data-vibeui-block="calendar-030"]){
--vibeui-calendar-030-bg:oklch(1 0 0);
--vibeui-calendar-030-fg:oklch(0.23 0.014 165);
--vibeui-calendar-030-muted:oklch(0.57 0.014 165);
--vibeui-calendar-030-faint:oklch(0.82 0.01 165);
--vibeui-calendar-030-border:oklch(0.91 0.008 165);
--vibeui-calendar-030-soft:oklch(0.97 0.008 165);
--vibeui-calendar-030-accent:oklch(0.48 0.11 165);
--vibeui-calendar-030-accentsoft:oklch(0.94 0.05 165);
--vibeui-calendar-030-radius:0.75rem;
--vibeui-calendar-030-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-030"]{
display:flex;flex-direction:column;gap:0.7rem;
width:100%;max-width:22rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-calendar-030-bg);
border:1px solid var(--vibeui-calendar-030-border);
border-radius:calc(var(--vibeui-calendar-030-radius) + 0.25rem);
color:var(--vibeui-calendar-030-fg);
font-family:var(--vibeui-calendar-030-font);
}
[data-vibeui-block="calendar-030"] [data-part="title"]{
margin:0;font-size:0.95rem;font-weight:700;letter-spacing:-0.01em;text-transform:capitalize;
}
[data-vibeui-block="calendar-030"] [data-part="rule"]{
margin:0.15rem 0 0;font-size:0.75rem;color:var(--vibeui-calendar-030-muted);
}
[data-vibeui-block="calendar-030"] [data-part="grid"]{
display:grid;grid-template-columns:repeat(7,1fr);gap:0.15rem;
}
[data-vibeui-block="calendar-030"] [data-part="dow"]{
text-align:center;font-size:0.65rem;font-weight:700;text-transform:uppercase;
color:var(--vibeui-calendar-030-muted);padding-bottom:0.2rem;
}
[data-vibeui-block="calendar-030"] [data-part="day"]{
appearance:none;cursor:pointer;font:inherit;
height:2.1rem;border:0;border-radius:0.45rem;
background:var(--vibeui-calendar-030-soft);color:inherit;
font-size:0.8rem;font-variant-numeric:tabular-nums;
transition:background-color .16s ease,color .16s ease;
}
[data-vibeui-block="calendar-030"] [data-part="day"]:hover:not(:disabled){
background:var(--vibeui-calendar-030-accentsoft);
}
[data-vibeui-block="calendar-030"] [data-part="day"]:focus-visible{
outline:2px solid var(--vibeui-calendar-030-accent);outline-offset:2px;
}
[data-vibeui-block="calendar-030"] [data-part="day"]:disabled{
cursor:not-allowed;background:transparent;color:var(--vibeui-calendar-030-faint);
}
[data-vibeui-block="calendar-030"] [data-part="day"][data-inside="true"]{
background:var(--vibeui-calendar-030-accentsoft);
}
[data-vibeui-block="calendar-030"] [data-part="day"][data-edge="true"]{
background:var(--vibeui-calendar-030-accent);color:var(--vibeui-calendar-030-bg);font-weight:700;
}
[data-vibeui-block="calendar-030"] [data-part="foot"]{
display:flex;align-items:center;justify-content:space-between;gap:0.5rem;
padding-top:0.6rem;border-top:1px solid var(--vibeui-calendar-030-border);
}
[data-vibeui-block="calendar-030"] [data-part="count"]{
margin:0;font-size:0.8125rem;color:var(--vibeui-calendar-030-muted);
}
[data-vibeui-block="calendar-030"] [data-part="count"] b{
color:var(--vibeui-calendar-030-fg);font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-030"] [data-part="reset"]{
appearance:none;cursor:pointer;font:inherit;flex:none;
padding:0.35rem 0.7rem;border-radius:0.5rem;
border:1px solid var(--vibeui-calendar-030-border);
background:transparent;color:var(--vibeui-calendar-030-muted);
font-size:0.78rem;font-weight:600;
}
[data-vibeui-block="calendar-030"] [data-part="reset"]:focus-visible{
outline:2px solid var(--vibeui-calendar-030-accent);outline-offset:2px;
}
[data-vibeui-block="calendar-030"] [data-part="bar"]{
height:0.3rem;border-radius:999px;overflow:hidden;background:var(--vibeui-calendar-030-soft);
}
[data-vibeui-block="calendar-030"] [data-part="bar"] i{
display:block;height:100%;border-radius:inherit;
width:calc(var(--vibeui-calendar-030-fill,0) * 1%);
background:var(--vibeui-calendar-030-accent);
transition:width .2s ease;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-030"] *{animation:none!important;transition:none!important}}
`

const DAY = 86400000

function stamp(date: Date) {
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, "0")
  const day = String(date.getDate()).padStart(2, "0")

  return `${year}-${month}-${day}`
}

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]
}

/**
 * Диапазон с потолком в N дней: лишние дни гаснут сразу после первой даты.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Calendar030({
  month = "2026-04",
  maxDays = 14,
  defaultFrom = "2026-04-06",
  defaultTo = "2026-04-12",
  locale = "ru-RU",
  onChange,
  accent,
  className,
  style,
  ...props
}: Calendar030Props) {
  const [from, setFrom] = useState(defaultFrom)
  const [to, setTo] = useState(defaultTo)

  const [year, index] = month.split("-").map(Number)
  const first = new Date(year, index - 1, 1)
  const offset = (first.getDay() + 6) % 7
  const total = new Date(year, index, 0).getDate()
  const cells = Array.from(
    { length: Math.ceil((offset + total) / 7) * 7 },
    (_, step) => new Date(year, index - 1, 1 - offset + step),
  )

  const weekdays = Array.from({ length: 7 }, (_, day) =>
    new Intl.DateTimeFormat(locale, { weekday: "short" })
      .format(new Date(2024, 0, 1 + day))
      .slice(0, 2),
  )

  const start = from ? new Date(`${from}T00:00:00`).getTime() : 0
  const finish = to ? new Date(`${to}T00:00:00`).getTime() : 0
  const picking = Boolean(from) && !to

  const blocked = (date: Date) => {
    if (!picking) return false

    const distance = (date.getTime() - start) / DAY

    return distance < 0 || distance > maxDays - 1
  }

  const choose = (date: Date) => {
    const value = stamp(date)

    if (picking && date.getTime() >= start) {
      setTo(value)
      onChange?.(from, value)

      return
    }

    setFrom(value)
    setTo("")
    onChange?.(value, "")
  }

  const length =
    from && to ? Math.round((finish - start) / DAY) + 1 : from ? 1 : 0

  const heading = new Intl.DateTimeFormat(locale, {
    month: "long",
    year: "numeric",
  }).format(first)

  const palette = {
    "--vibeui-calendar-030-fill": Math.min(
      100,
      Math.round((length / maxDays) * 100),
    ),
    ...(accent ? { "--vibeui-calendar-030-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-calendar-030" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="calendar-030"
        className={className}
        style={palette}
      >
        <header>
          <h3 data-part="title">{heading}</h3>
          <p data-part="rule">
            Не больше {maxDays} {pluralize(maxDays, ["дня", "дней", "дней"])}{" "}
            подряд
          </p>
        </header>
        <div data-part="grid">
          {weekdays.map((name) => (
            <span key={name} data-part="dow">
              {name}
            </span>
          ))}
          {cells.map((date) => {
            const time = date.getTime()
            const edge = stamp(date) === from || stamp(date) === to
            const inside = Boolean(to) && time > start && time < finish

            return (
              <button
                key={time}
                type="button"
                data-part="day"
                data-edge={edge}
                data-inside={inside}
                disabled={blocked(date) || date.getMonth() !== index - 1}
                aria-pressed={edge || inside}
                onClick={() => choose(date)}
              >
                {date.getDate()}
              </button>
            )
          })}
        </div>
        <div data-part="bar" aria-hidden="true">
          <i />
        </div>
        <div data-part="foot">
          <p data-part="count" aria-live="polite">
            {length === 0 ? (
              "Выберите первый день"
            ) : picking ? (
              <>
                Начало выбрано, осталось до <b>{maxDays - 1}</b>{" "}
                {pluralize(maxDays - 1, ["дня", "дней", "дней"])}
              </>
            ) : (
              <>
                Выбрано <b>{length}</b> из <b>{maxDays}</b>{" "}
                {pluralize(maxDays, ["дня", "дней", "дней"])}
              </>
            )}
          </p>
          <button
            type="button"
            data-part="reset"
            onClick={() => {
              setFrom("")
              setTo("")
              onChange?.("", "")
            }}
          >
            Сбросить
          </button>
        </div>
      </section>
    </>
  )
}