Calendar
ISO Week Grid
Месячная сетка с колонкой номеров недель по ISO 8601: выбирается не день, а строка целиком, и подпись снизу переводит «неделя 16» в конкретные даты.
- calendar
- week
- iso-8601
- planning
Превью
Использовать с AI
- 1. Скопируйте ссылку.
- 2. Напишите агенту своими словами и вставьте её в предложение.
- 3. Агент откроет ссылку и поставит компонент из registry.
размести это в шапке: https://vibeui.ru/c/calendar-021
апрель 2026 г.
Номера недель по ISO 8601
| нед | пн | вт | ср | чт | пт | сб | вс |
|---|---|---|---|---|---|---|---|
| 30 | 31 | 1 | 2 | 3 | 4 | 5 | |
| 6 | 7 | 8 | 9 | 10 | 11 | 12 | |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 | |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 | |
| 27 | 28 | 29 | 30 | 1 | 2 | 3 |
Неделя 16: 13 апр. — 19 апр.
Показать полную инструкцию
То, что лежит по ссылке в развёрнутом виде. Нужна, если агент не может открыть ссылку — тогда вставьте этот текст целиком.
# Установка и размещение «calendar-021» (ISO Week Grid) из VibeUI
## 1. Сначала установка — не пропускай, не пересоздавай
Выполни эту команду до того, как писать код:
npx shadcn@latest add https://vibeui.ru/r/calendar-021.json
Registry item: https://vibeui.ru/r/calendar-021.json
Ставится в: components/vibeui/calendar-021.tsx (точный путь следует алиасам components.json этого проекта).
npm-зависимости: нет.
Registry-зависимости: нет.
Ставь из реестра. Не пересоздавай по описанию, не подменяй похожим
компонентом из другой библиотеки и не переписывай под стиль проекта.
## 2. Что это
Месячная сетка с колонкой номеров недель по ISO 8601: выбирается не день, а строка целиком, и подпись снизу переводит «неделя 16» в конкретные даты.
Календарь месяца, в котором единица выбора — неделя, а не день. Слева колонка номеров недель по ISO 8601, каждый номер — кнопка, выбирающая всю строку; подпись снизу показывает границы выбранной недели датами. Нужен там, где планируют производство, отгрузки и спринты: там говорят «на 16-й неделе», а не «с 13 по 19 апреля». Ноль зависимостей, один файл, своя палитра.
## 3. Как использовать
import { Calendar021 } from "@/components/vibeui/calendar-021"
<Calendar021 month="2026-04" defaultWeek={16} />
Полный список пропсов — в установленном файле.
## 4. Куда поставить
Это мелкий inline-компонент. Поставь его ровно туда, куда просил
пользователь, внутрь существующей разметки. Не создавай ради него
новую страницу, секцию или обёртку. Если на этом месте уже стоит похожий
контрол — замени его, а не добавляй второй.
Куда поставить: ___
(Эту строку заполняет пользователь. Если она осталась пустой — спроси,
а не угадывай.)
## 5. Сохранить как установлено
- локальную палитру --vibeui-calendar-021-* и не подменять её токенами вашей темы (bg-card, text-muted-foreground и подобными)
- функцию isoWeek целиком: номер недели по ISO определяется её четвергом, наивное деление дней на семь даёт другой ответ в начале и конце года
- расчёт в UTC внутри isoWeek — в локальном времени ночь перевода часов даёт 23- и 25-часовые сутки и сбивает счёт
- таблицу с <th scope="row"> для номера недели: связь номера и семи ячеек строки объявляется разметкой, а не aria-описаниями
- aria-label у кнопки недели с границами дат — иначе скринридер прочитает голое число
- старт недели с понедельника ((getDay()+6)%7): ISO-неделя начинается в понедельник, воскресный старт сделает номера неверными
- подпись снизу с aria-live="polite": выбор недели меняет только цвет строки, без объявления это изменение не слышно
## 6. Можно менять
- month — показываемый месяц строкой YYYY-MM
- locale — локаль подписей месяца и дней недели
- today — опорная дата для обводки «сегодня»
- defaultWeek — номер недели, выбранной при первом рендере
- onSelect — обработчик, получает номер недели и обе границы в формате YYYY-MM-DD
- accent — цвет выделения; всё остальное красится от него
## 7. Правила
- Не считайте номер недели как Math.ceil(день/7): у ISO своя разметка года, и 1 января часто относится к последней неделе предыдущего года.
- В США и Израиле неделя начинается с воскресенья — там нужен не этот компонент, а сетка с другим стартом и без ISO-номеров.
- Не превращайте номер недели в подпись без кнопки: смысл варианта в том, что выбирается интервал, а не день.
- onSelect отдаёт даты строкой без часового пояса — не прогоняйте их через new Date(...).toISOString(), это сдвинет день.
## 8. Проверка
- рендерится без ошибок в консоли;
- выглядит так же, как превью на странице VibeUI, откуда взята ссылка;
- если нет — ты изменил что-то из раздела 5, верни как было.Для разработчика
npx shadcn@latest add https://vibeui.ru/r/calendar-021.jsonhttps://vibeui.ru/r/calendar-021.jsonКомпонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-calendar-021-*. Клиентский: номер выбранной недели живёт в useState. Разметка — обычная <table>, где заголовок строки <th scope="row"> содержит кнопку с номером недели, поэтому связь «неделя → дни» читается скринридером без ARIA-костылей. Номер считает isoWeek: дата сдвигается на четверг своей недели и сравнивается с началом года в UTC, поэтому переход на летнее время и январские недели-перебежчики не ломают счёт. Сетка строится от понедельника ((getDay()+6)%7), пустые ведущие недели отбрасываются фильтром. Подсветка выбранной строки повешена на tr[data-picked="true"], скругления краёв — на td:first-of-type и td:last-child.
Исходник компонента
Тот же файл, который поставит агент. Нужен, если вы предпочитаете скопировать код руками.
"use client"
import { useMemo, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Calendar021Props = Omit<
ComponentPropsWithoutRef<"section">,
"children" | "onSelect"
> & {
/** Месяц строкой YYYY-MM: сервер и клиент считают сетку одинаково. */
month?: string
today?: string
locale?: string
defaultWeek?: number
onSelect?: (week: number, start: string, end: string) => void
accent?: string
}
// Идея компонента: в производстве, логистике и отчётности живут не даты,
// а номера недель по ISO 8601. Поэтому здесь неделя — это строка таблицы
// с заголовком-кнопкой: выбирают не день, а весь семидневный интервал,
// и подпись снизу переводит «неделя 15» в понятные человеку числа.
const STYLES = `
:where([data-vibeui-block="calendar-021"]){
--vibeui-calendar-021-bg:oklch(1 0 0);
--vibeui-calendar-021-fg:oklch(0.24 0.014 255);
--vibeui-calendar-021-muted:oklch(0.63 0.014 255);
--vibeui-calendar-021-faint:oklch(0.8 0.012 255);
--vibeui-calendar-021-border:oklch(0.91 0.006 255);
--vibeui-calendar-021-soft:oklch(0.965 0.006 255);
--vibeui-calendar-021-accent:oklch(0.52 0.14 255);
--vibeui-calendar-021-accentsoft:oklch(0.94 0.04 255);
--vibeui-calendar-021-radius:0.75rem;
--vibeui-calendar-021-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-021"]{
display:flex;flex-direction:column;gap:0.75rem;
width:100%;max-width:23rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-calendar-021-bg);
border:1px solid var(--vibeui-calendar-021-border);
border-radius:calc(var(--vibeui-calendar-021-radius) + 0.25rem);
color:var(--vibeui-calendar-021-fg);
font-family:var(--vibeui-calendar-021-font);
}
[data-vibeui-block="calendar-021"] [data-part="title"]{
margin:0;font-size:0.95rem;font-weight:700;letter-spacing:-0.01em;text-transform:capitalize;
}
[data-vibeui-block="calendar-021"] [data-part="hint"]{
margin:0.15rem 0 0;font-size:0.75rem;color:var(--vibeui-calendar-021-muted);
}
[data-vibeui-block="calendar-021"] table{
width:100%;border-collapse:collapse;table-layout:fixed;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-021"] th,
[data-vibeui-block="calendar-021"] td{padding:0;text-align:center}
[data-vibeui-block="calendar-021"] thead th{
padding-bottom:0.35rem;font-size:0.7rem;font-weight:600;
color:var(--vibeui-calendar-021-muted);text-transform:uppercase;letter-spacing:0.04em;
}
[data-vibeui-block="calendar-021"] [data-part="weekcol"]{width:2.4rem}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"]{
appearance:none;cursor:pointer;font:inherit;
width:2.1rem;height:2.1rem;border-radius:0.5rem;
border:1px dashed var(--vibeui-calendar-021-border);
background:var(--vibeui-calendar-021-soft);
color:var(--vibeui-calendar-021-muted);
font-size:0.75rem;font-weight:700;
transition:background-color .16s ease,color .16s ease,border-color .16s ease;
}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"]:hover{
color:var(--vibeui-calendar-021-fg);border-color:var(--vibeui-calendar-021-accent);
}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"]:focus-visible{
outline:2px solid var(--vibeui-calendar-021-accent);outline-offset:2px;
}
[data-vibeui-block="calendar-021"] [data-part="weekbtn"][aria-pressed="true"]{
background:var(--vibeui-calendar-021-accent);border-color:transparent;
color:var(--vibeui-calendar-021-bg);
}
[data-vibeui-block="calendar-021"] [data-part="day"]{
display:flex;align-items:center;justify-content:center;
height:2.1rem;font-size:0.8125rem;
}
[data-vibeui-block="calendar-021"] tbody tr[data-picked="true"] [data-part="day"]{
background:var(--vibeui-calendar-021-accentsoft);
}
[data-vibeui-block="calendar-021"] tbody tr[data-picked="true"] td:first-of-type [data-part="day"]{
border-radius:0.5rem 0 0 0.5rem;
}
[data-vibeui-block="calendar-021"] tbody tr[data-picked="true"] td:last-child [data-part="day"]{
border-radius:0 0.5rem 0.5rem 0;
}
[data-vibeui-block="calendar-021"] [data-part="day"][data-outside="true"]{color:var(--vibeui-calendar-021-faint)}
[data-vibeui-block="calendar-021"] [data-part="day"][data-today="true"]{
font-weight:700;box-shadow:inset 0 0 0 1px var(--vibeui-calendar-021-accent);border-radius:0.5rem;
}
[data-vibeui-block="calendar-021"] [data-part="foot"]{
margin:0;padding-top:0.6rem;border-top:1px solid var(--vibeui-calendar-021-border);
font-size:0.8125rem;color:var(--vibeui-calendar-021-muted);
}
[data-vibeui-block="calendar-021"] [data-part="foot"] b{color:var(--vibeui-calendar-021-fg)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-021"] *{animation:none!important;transition:none!important}}
`
/**
* Номер недели по ISO 8601: неделя принадлежит тому году, в котором лежит
* её четверг. Считаем в UTC, чтобы переход на летнее время не сдвинул сутки.
*/
function isoWeek(date: Date) {
const point = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
)
const weekday = point.getUTCDay() || 7
point.setUTCDate(point.getUTCDate() + 4 - weekday)
const yearStart = new Date(Date.UTC(point.getUTCFullYear(), 0, 1))
return Math.ceil(((point.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
}
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}`
}
/**
* Месячная сетка с колонкой номеров ISO-недель: выбирается неделя целиком.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Calendar021({
month = "2026-04",
today = "2026-04-15",
locale = "ru-RU",
defaultWeek = 16,
onSelect,
accent,
className,
style,
...props
}: Calendar021Props) {
const [picked, setPicked] = useState(defaultWeek)
const rows = useMemo(() => {
const [year, index] = month.split("-").map(Number)
const first = new Date(year, index - 1, 1)
const offset = (first.getDay() + 6) % 7
return Array.from({ length: 6 }, (_, week) =>
Array.from(
{ length: 7 },
(_, day) => new Date(year, index - 1, 1 - offset + week * 7 + day),
),
)
.filter((days) => days.some((date) => date.getMonth() === index - 1))
.map((days) => ({ days, number: isoWeek(days[3]) }))
}, [month])
const weekdays = useMemo(() => {
const format = new Intl.DateTimeFormat(locale, { weekday: "short" })
return Array.from({ length: 7 }, (_, day) =>
format.format(new Date(2024, 0, 1 + day)),
)
}, [locale])
const heading = new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
}).format(new Date(`${month}-01T00:00:00`))
const span = new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
})
const current = rows.find((row) => row.number === picked)
const palette = {
...(accent ? { "--vibeui-calendar-021-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-calendar-021" precedence="medium">
{STYLES}
</style>
<section
{...props}
data-vibeui-block="calendar-021"
className={className}
style={palette}
>
<header>
<h3 data-part="title">{heading}</h3>
<p data-part="hint">Номера недель по ISO 8601</p>
</header>
<table>
<thead>
<tr>
<th data-part="weekcol" scope="col">
нед
</th>
{weekdays.map((name) => (
<th key={name} scope="col">
{name}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.number} data-picked={row.number === picked}>
<th scope="row" data-part="weekcol">
<button
type="button"
data-part="weekbtn"
aria-pressed={row.number === picked}
aria-label={`Неделя ${row.number}, с ${span.format(row.days[0])} по ${span.format(row.days[6])}`}
onClick={() => {
setPicked(row.number)
onSelect?.(
row.number,
stamp(row.days[0]),
stamp(row.days[6]),
)
}}
>
{row.number}
</button>
</th>
{row.days.map((date) => (
<td key={date.getTime()}>
<span
data-part="day"
data-outside={
date.getMonth() !== Number(month.split("-")[1]) - 1
}
data-today={stamp(date) === today}
>
{date.getDate()}
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
<p data-part="foot" aria-live="polite">
{current ? (
<>
Неделя <b>{current.number}</b>: {span.format(current.days[0])} —{" "}
{span.format(current.days[6])}
</>
) : (
"Неделя не выбрана"
)}
</p>
</section>
</>
)
}