GrCalendar

Пакет: @feugene/granularity-chronoспутникГруппа: Прочее

Берут, когда календарь и есть экран.

Когда брать

  • календарь и есть экран — страница брони, планировщик, панель выбора дня: сетка нужна открытой, а не по клику в поле;
  • в ячейках своё содержимое — слот day рисует загрузку, цену, число событий поверх дня;
  • сетка нужна рядом с чем-то ещё — своя панель, свои кнопки, свой подвал через header и footer;
  • выбор дня не связан с формой — значение уходит в состояние экрана, а не в поле ввода.

Когда взять другое

НужноБерите
Дата как значение поля формыGrDatePicker
Период «с — по»GrDateRangePicker
Дата со временемGrDateTimePicker
Только времяGrTimePicker
События во времени лентой, а не сеткойGrTimeline

Календаря событий здесь нет. Сетка показывает дни, а не то, что в них происходит: недельная и дневная раскладка, перетаскивание событий, пересечения и лента ресурсов — это отдельный домен, и закрывать его будет отдельный пакет. Слот day закрывает только пометки на дне — счётчик, точку, цену.

Модель — `PlainDate`, а не `Date`

Единственный компонент пакета, у которого наружу выходит внутренний кортеж { y, m, d }, а не Date через valueAdapter. Причина в том, что календарь не форм-контрол: сериализовать ему нечего, а Date на границе означал бы полночь в какой-то зоне — ровно ту неоднозначность, ради ухода от которой пакет и считает на кортежах (../model.md).

Значение нужно в виде Date — его собирают на своей стороне; пикеры делают то же самое, только внутри себя.

Первый день недели — из локали, а не из пропа

Понедельник в России и воскресенье в США — это Intl, а не настройка компонента. Проп locale меняет и названия, и порядок дней разом; порознь их менять нельзя, иначе получится календарь, которого не существует ни в одной стране.

Когда локаль всё же нужно перебить — например, рабочая неделя начинается с воскресенья независимо от языка интерфейса, — это решение приложения, а не экрана. Такое ставится один раз через GrConfigProvider:

<GrConfigProvider :component-defaults="{ GrCalendar: { weekStart: 7, showWeekNumbers: true } }">

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

Сетка рисует выбор, но не считает его

Диапазон приходит в сетку двумя пропами (rangeStart, rangeEnd), набор — одним (selectedDates), и ни один из них сетка не изменяет: складывать, снимать, сортировать и проверять длину — дело пикера. Календарь отвечает на один вопрос: как покрасить ячейку.

Из-за этого он одинаково служит и GrDatePicker с его набором, и GrDateRangePicker с отрезком, и приложению, которое ведёт выбор само.

Вхождение в набор считается по Set, собранному один раз на смену набора: обход массива на каждую из сорока двух ячеек превратил бы подсветку в квадрат.

Пять режимов, и неделя среди них — не сетка периодов

modeЧто показываетЧто кладёт в модель
day (по умолчанию)сетку днейвыбранный день
weekту же сетку днейначало выбранной недели
monthдвенадцать месяцевпервое число месяца
quarterчетыре кварталапервое число квартала
yearдвенадцать лет десятилетияпервое января

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

Начало недели берётся из локали (см. «Первый день недели» выше), а не прибивается к понедельнику: в США та же дата попадает в неделю, начинающуюся воскресеньем. В модель кладётся одна дата — начало недели, — и форма значения остаётся общей для всех режимов: valueAdapter работает как работал.

Номера недели пакет не показывает: у ISO и у США он считается по-разному, а Intl его не даёт — выдумывать локале-зависимое мимо Intl пакет не берётся.

Кварталов четыре, и сетка у них в две колонки — три оставили бы одинокую ячейку во второй строке. Подпись берётся строкой локали пакета (Q1, 1 кв.): Intl кварталы не именует вовсе, и это интерфейсный текст, а не локале-зависимые данные.

Детерминированный рендер требует `today`

Часы среды читаются один раз на экземпляр. Там, где рендер обязан совпасть с прошлым — снимки, тесты, серверная отрисовка, — «сегодня» задаётся пропом today: иначе первый клиентский рендер разойдётся с серверным ровно в полночь. Подробности — ../ssr.md.

Установка

npm i @feugene/granularity-chrono

Импорт

import { GrCalendar } from '@feugene/granularity-chrono/components/GrCalendar'

API

API этого компонента ещё не посчитан: генератор витрины пока обходит только ядро. Пока его нет, справочник — в документации пакета.

Примеры 3

Basic

Basic
<script setup lang="ts">
import { ref } from 'vue'

// `GrCalendar` подставляется авто-импортом (`unplugin-vue-components`).
import type { PlainDate } from '@feugene/granularity-chrono'
import { plainDateKey } from '@feugene/granularity-chrono'

// Дата — кортеж `{ y, m, d }`, где `m` считается с нуля. Ни `Date`, ни
// таймзоны здесь нет: сетка про календарь, а не про момент времени.
const value = ref<PlainDate | null>({ y: 2026, m: 7, d: 12 })

const min: PlainDate = { y: 2026, m: 7, d: 3 }
const max: PlainDate = { y: 2026, m: 8, d: 18 }
</script>

<template>
  <div class="grid gap-4 justify-items-start">
    <GrCalendar
      v-model="value"
      :min="min"
      :max="max"
      show-week-numbers
      aria-label="Delivery date"
    />

    <p class="showcase-demo-text text-sm">
      <span class="opacity-70">value=</span>
      <code>{{ value ? plainDateKey(value) : '—' }}</code>
    </p>
  </div>
</template>

Day Slot

Day Slot
<script setup lang="ts">
import { ref } from 'vue'

// `GrCalendar` подставляется авто-импортом (`unplugin-vue-components`).
import type { PlainDate } from '@feugene/granularity-chrono'
import { plainDateKey } from '@feugene/granularity-chrono'

const value = ref<PlainDate | null>(null)

/** Нагрузка дня: ключ `2026-08-12` совпадает с ключом ячейки сетки. */
const eventsByDay: Record<string, number> = {
  '2026-08-04': 1,
  '2026-08-12': 3,
  '2026-08-13': 2,
  '2026-08-21': 1,
}

function eventsOn(date: PlainDate): number {
  return eventsByDay[plainDateKey(date)] ?? 0
}
</script>

<template>
  <div class="grid gap-4 justify-items-start">
    <GrCalendar
      v-model="value"
      :view-date="{ y: 2026, m: 7, d: 1 }"
      aria-label="Schedule"
    >
      <!-- Слот отдаёт саму ячейку: число рисуем сами и дописываем метки. -->
      <template #day="{ cell, selected }">
        <span class="relative inline-flex flex-col items-center leading-none">
          <span>{{ cell.date.d }}</span>
          <span
            v-if="eventsOn(cell.date) && !selected"
            class="mt-0.5 h-1 w-1 rounded-[var(--gr-radius-full)] bg-[var(--gr-primary)]"
            :aria-label="`${eventsOn(cell.date)} events`"
          />
        </span>
      </template>
    </GrCalendar>

    <p class="showcase-demo-text text-sm">
      <span class="opacity-70">busy days=</span><code>{{ Object.keys(eventsByDay).length }}</code>
    </p>
  </div>
</template>

Modes

Modes
<script setup lang="ts">
import { ref } from 'vue'

// `GrCalendar` и `GrSegmented` подставляются авто-импортом.
import type { PlainDate } from '@feugene/granularity-chrono'
import { plainDateKey } from '@feugene/granularity-chrono'

type CalendarMode = 'day' | 'week' | 'month' | 'quarter' | 'year'

const mode = ref<CalendarMode>('day')
// В режимах периода значением становится первое число: месяц — это 1 августа,
// квартал — 1 июля, год — 1 января. Неделя кладёт своё начало, и оно зависит
// от локали: у `en-US` неделя начинается с воскресенья. Показываем значение
// ISO-ключом — он не зависит от языка витрины, а сетка рядом и так показывает язык.
const value = ref<PlainDate | null>({ y: 2026, m: 7, d: 12 })

const modeOptions = [
  { value: 'day', label: 'Day' },
  { value: 'week', label: 'Week' },
  { value: 'month', label: 'Month' },
  { value: 'quarter', label: 'Quarter' },
  { value: 'year', label: 'Year' },
] satisfies Array<{ value: CalendarMode, label: string }>
</script>

<template>
  <div class="grid gap-4 justify-items-start">
    <GrSegmented v-model="mode" :options="modeOptions" size="sm" />

    <GrCalendar
      v-model="value"
      :mode="mode"
      aria-label="Reporting period"
    />

    <p class="showcase-demo-text text-sm">
      <span class="opacity-70">value=</span>
      <code>{{ value ? plainDateKey(value) : '—' }}</code>
    </p>

    <p class="showcase-demo-text text-sm opacity-70">
      Неделя рисуется той же сеткой дней: клик по любому дню выбирает его неделю, и подсвечивается
      вся строка. Двенадцать недель в сетке периодов были бы четвертью года без единой подписи
      месяца — выбирать там нечего. Кварталов четыре, и сетка у них в две колонки.
    </p>
  </div>
</template>

Документация компонентаВсе компоненты