GrCalendar

Package: @feugene/granularity-chronocompanionGroup: misc

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • the calendar is the screen — a booking page, a planner, a day-selection panel: the grid has to be open rather than opened by a click in a field;
  • the cells have content of their own — the day slot draws the load, the price, the number of events over a day;
  • the grid is needed next to something else — a panel of your own, buttons of your own, a footer of your own through header and footer;
  • the choice of a day is not related to a form — the value goes into the state of the screen rather than into an input field.

When to take something else

NeedTake
A date as the value of a form fieldGrDatePicker
A “from — to” periodGrDateRangePicker
A date with a timeGrDateTimePicker
A time onlyGrTimePicker
Events in time as a feed rather than as a gridGrTimeline

There is no event calendar here. The grid shows the days rather than what happens in them: a week and a day layout, dragging events, overlaps and a feed of resources are a separate domain, and a separate package will cover it. The day slot covers only the marks on a day — a counter, a dot, a price.

The model is a `PlainDate` rather than a `Date`

The only component of the package whose internal { y, m, d } tuple goes outside rather than a Date through a valueAdapter. The reason is that a calendar is not a form control: it has nothing to serialise, and a Date at the boundary would mean midnight in some zone — exactly the ambiguity for the sake of avoiding which the package computes on tuples (../model.md).

If the value is needed as a Date, it is assembled on your own side; the pickers do the same, only inside themselves.

The first day of the week comes from the locale rather than from a prop

Monday in Russia and Sunday in the USA is Intl rather than a setting of the component. The locale prop changes both the names and the order of the days at once; changing them separately is not allowed, otherwise a calendar comes out that does not exist in any country.

When the locale does have to be overridden — for instance the working week starts on Sunday regardless of the language of the interface — that is a decision of the application rather than of a screen. Such a thing is set once through GrConfigProvider:

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

The setting goes under the GrCalendar key and reaches all of the pickers: their panel is shared, and GrDatePicker and its neighbours deliberately have no key of their own. The resolution order is the usual one — the prop in place is stronger than the config, and the config is stronger than the locale; if nothing is set, Intl decides.

The grid draws the selection but does not compute it

A range arrives in the grid as two props (rangeStart, rangeEnd) and a set as one (selectedDates), and the grid changes neither of them: adding, removing, sorting and checking the length are the business of the picker. The calendar answers one question: how to colour a cell.

Because of that it serves equally well GrDatePicker with its set, GrDateRangePicker with its segment, and an application that runs the selection itself.

Membership of the set is computed with a Set assembled once per change of the set: walking the array for each of the forty-two cells would turn the highlighting into a quadratic.

Five modes, and the week among them is not a grid of periods

modeWhat it showsWhat it puts into the model
day (the default)a grid of daysthe selected day
weekthe same grid of daysthe beginning of the selected week
monthtwelve monthsthe first day of the month
quarterfour quartersthe first day of the quarter
yearthe twelve years of a decadethe first of January

A week is drawn as a grid of days rather than as a grid of periods. Twelve cells in three columns are twelve weeks on the screen, a quarter of a year without a single month label: there is nothing to choose in such a grid. Instead a click on any day selects the week it fell into, and the row is highlighted as a whole.

The beginning of the week is taken from the locale (see “The first day of the week” above) rather than nailed to Monday: in the USA the same date falls into a week starting on Sunday. One date is put into the model — the beginning of the week — and the shape of the value stays shared across all of the modes: valueAdapter works as it worked.

The package does not show week numbers: ISO and the USA count them differently, and Intl does not provide it — the package does not undertake to invent something locale-dependent past Intl.

There are four quarters, and their grid has two columns — three would leave a lonely cell in the second row. The label is taken from a string of the package locale (Q1, 1 qtr): Intl does not name quarters at all, and this is interface text rather than locale-dependent data.

A deterministic render requires `today`

The clock of the environment is read once per instance. Where the render is obliged to match a past one — snapshots, tests, server rendering — “today” is set with the today prop: otherwise the first client render will diverge from the server one exactly at midnight. The details — ../ssr.md.

Install

npm i @feugene/granularity-chrono

Import

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

API

The API for this component has not been generated yet: the showcase generator only covers the core so far. Until it does, the reference lives in the package documentation.

Examples 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>

Component documentationAll components