GrDateRangePicker
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a report for a period — “from — to” is one field and one value rather than two independent
ones: two
GrDatePickerside by side do not prevent choosing an end earlier than the start; - booking and occupancy — the preview on hover shows the future range until the second click;
- filtering a list by dates — the model is a pair, so it travels into the query string as a single parameter;
- the bounds have to be consistent — the component keeps the order itself and does not let an inverted period out.
When to take something else
| Need | Take |
|---|---|
| A single date | GrDatePicker |
| A date with a time | GrDateTimePicker |
| A time only | GrTimePicker |
| The month grid without a field | GrCalendar |
| The period is chosen only from ready ones, a custom one is not needed | GrSegmented |
The ready periods live inside the panel
presets draws a row of shortcuts in the footer: “Today”, “The last 7 days”, “This month”. The
bounds can be set with a function — “the last 7 days” are counted from today rather than from the day
the prop was declared.
A neighbouring GrSegmented is unsuitable for that, although it looks alike. A period shortcut is
obliged to set both bounds and not to bypass minRange/maxRange, min, max and
disabledDates — that is, to know the insides of the picker. Those rules are not visible from the
outside, so a period that cannot be chosen is indistinguishable from an allowed one out there;
inside it arrives switched off.
A footer of your own is the footer slot; it replaces the row as a whole and receives setRange,
canSetRange and close, that is, exactly the same rules.
A period down to the minute
enable-time adds two columns of time to the panel — for the start and for the end:
<GrDateRangePicker v-model="window" enable-time />
A fresh period gets a whole day: 00:00 and 23:59. Two midnights would look symmetric, but “from
1 to 3 August” in human terms includes the whole of the third day, while [1 Aug 00:00, 3 Aug 00:00)
would silently cut off almost all of the third — the classic error of reports. With enable-seconds
the end gets 23:59:59. Both values are visible and editable.
The end is put onto the same grid as the columns: with minute-step="15" that is 23:45 rather
than 23:59. Otherwise the minutes would have no selected option — the end of the period could not be
read where it is edited. With a step of a minute, that is, by default, it is exactly 23:59.
There is no four-step wizard here. The dates are chosen in two clicks, as without a time; the time is edited whenever and in any order. A step-by-step choice would introduce a hidden “which step are we on” state, out of which someone who mis-clicked cannot be led except by going through everything again.
The panel does not close on the second date when the time is switched on: the choice does not end
there, and closing would take away from the user the very columns they switched enable-time on for.
Inside one day the order of the edges is held by the time alone. An edit after which the end would turn out earlier than the start is not applied and is announced — by the same device with which an inadmissible length is rejected: the user simply mis-clicked, and there is nothing to reset their choice for.
minRange and maxRange are still counted in days. Changing the unit of existing props would
mean breaking someone else’s code; a limit in minutes is a separate minDuration/maxDuration, and
they will appear when they are needed rather than “just in case”.
Typing
editable switches on the entry of a period as a single string:
<GrDateRangePicker v-model="period" editable />
The string is parsed not by a separator but by the count of groups of digits: they are divided in
half, and each half is parsed as a bound. 08/12/2026 — 08/14/2026, 08/12/2026 - 08/14/2026 and
08/12/2026 08/14/2026 are therefore one and the same, and en-CA with hyphens inside the dates
themselves (2026-08-12 - 2026-08-14) does not break: a list of separators would fall apart at the
very first locale, and there is none here.
A half of the day that turned out between the bounds (3:30 PM — 8/14/2026) goes to the bound the
locale assigns it to: in en-US it stands after the hour, in ko before it.
One date instead of two is rejected. One date is not a period; completing the second bound would mean inventing on the user’s behalf what they did not set.
The reverse order is normalised, as with clicks: a period can be led backwards.
A time is accepted exactly when the picker shows it. With enable-time both bounds are obliged to
carry a time, without it neither does: a picker that silently swallowed 14:30 would show a value
that was not entered.
The panel follows the typing. The first bound that has been typed is highlighted as the start of the period — by the same state as after the first click in the grid; the second one that has been typed closes the band. The grid moves to the month of what was typed. The model does not change in the process.
The period is checked by the same rules as a choice with the mouse: forbidden dates, min, max,
minRange, maxRange. A period that parsed but is inadmissible is not applied and is announced —
the user typed everything correctly, and silence would look like a lost Enter.
A successful entry removes the start of the period if it had been opened with a click in the panel: otherwise the next click would close a period from a date the field no longer says anything about.
As in GrDateTimePicker, a field with editable shows the bounds in digits — the typing has to
read back what is shown — there is no mask, and the default placeholder shows both bounds in full.
The adapter applies to every bound
The model is a pair, and valueAdapter parses and assembles both of its sides. With
valueAdapter="isoDate" what goes out is ['2026-08-10', '2026-08-14'] rather than one string with
a separator: the separator would have to be chosen on the consumer’s behalf, and it would break at
the first locale where a dash means something else.
Install
npm i @feugene/granularity-chronoImport
import { GrDateRangePicker } from '@feugene/granularity-chrono/components/GrDateRangePicker'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 5
Basic
<script setup lang="ts">
import { ref } from 'vue'
// `GrDateRangePicker` подставляется авто-импортом (`unplugin-vue-components`).
const value = ref<readonly [string, string] | null>(['2026-08-10', '2026-08-14'])
</script>
<template>
<div class="grid max-w-[360px] gap-4">
<!-- Период набирается двумя кликами; пока он открыт, сетка показывает
предпросмотр до дня под курсором. -->
<GrDateRangePicker
v-model="value"
value-adapter="isoDate"
clearable
placeholder="Pick a period"
aria-label="Stay"
/>
<p class="showcase-demo-text text-sm">
<span class="opacity-70">value=</span><code>{{ value ? value.join(' … ') : '—' }}</code>
</p>
</div>
</template>Editable
<script setup lang="ts">
import { computed, ref } from 'vue'
// `GrDateRangePicker` подставляется авто-импортом.
/**
* Период одной строкой.
*
* Панель модели рядом нужна, чтобы было видно результат разбора целиком:
* границы нормализуются по порядку, и на глаз это иначе не проверить.
*/
const TODAY = new Date(2026, 7, 12)
const period = ref<[Date, Date] | null>([new Date(2026, 7, 10), new Date(2026, 7, 14)])
const model = computed(() => (period.value
? period.value.map(date => new Intl.DateTimeFormat('ru-RU', { dateStyle: 'medium' }).format(date))
: null))
</script>
<template>
<div class="grid gap-4 justify-items-start">
<GrDateRangePicker
v-model="period"
editable
:today="TODAY"
:max-range="30"
locale="ru-RU"
aria-label="Период отчёта"
class="w-96"
/>
<pre v-if="model" class="rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] p-3 text-[length:var(--gr-control-text-sm)] leading-[var(--gr-leading-sm)]">{{ JSON.stringify(model) }}</pre>
<p class="showcase-demo-text text-sm opacity-70">
Строку делит не разделитель, а <strong>счёт групп цифр</strong>: их поровну на две границы.
Поэтому <code>10.08.2026 — 14.08.2026</code>, <code>10.08.2026 - 14.08.2026</code> и
<code>10.08.2026 14.08.2026</code> — одно и то же. Списка разделителей здесь нет намеренно:
в канадской локали дата сама пишется через дефис, и такой список развалился бы на ней первой.
</p>
<p class="showcase-demo-text text-sm opacity-70">
Панель идёт за набором: первая набранная граница подсвечивается началом периода — тем же
состоянием, что и после первого клика, — вторая закрывает полосу. Сетка переходит на месяц
набранного. Модель ждёт <strong>Enter</strong>.
</p>
<p class="showcase-demo-text text-sm opacity-70">
Наберите границы задом наперёд — порядок нормализуется, как и при кликах. Наберите одну дату —
ввод отклонится: одна дата не период, а достраивать вторую границу значит придумать за вас то,
чего вы не задавали. Период длиннее <code>max-range</code> в 30 дней тоже не применится, и об
этом скажет диктор: вы набрали всё правильно, и молчание выглядело бы как потерянный Enter.
</p>
</div>
</template>Limits
<script setup lang="ts">
import { ref } from 'vue'
// `GrDateRangePicker` подставляется авто-импортом (`unplugin-vue-components`).
const value = ref<readonly [Date, Date] | null>(null)
const from = new Date(2026, 7, 1)
</script>
<template>
<div class="grid max-w-[360px] gap-4">
<!-- Длина считает обе границы: от трёх до семи дней включительно.
Недопустимая длина не выбирается, но и не сбрасывает начало. -->
<GrDateRangePicker
v-model="value"
:min="from"
:min-range="3"
:max-range="7"
clearable
placeholder="From 3 to 7 days"
aria-label="Vacation"
/>
<p class="showcase-demo-text text-sm">
<span class="opacity-70">days=</span>
<code>{{ value ? Math.round((value[1].getTime() - value[0].getTime()) / 86400000) + 1 : '—' }}</code>
</p>
</div>
</template>Presets
<script setup lang="ts">
import { ref } from 'vue'
/**
* Шорткаты живут внутри панели, а не рядом с полем: период обязан выставить
* обе границы разом и уважать `maxRange`. Здесь потолок — 31 день, и «Последние
* 90 дней» приходит выключенным, а не молча ничего не делает.
*/
const period = ref<[Date, Date] | null>(null)
/** Границы функцией: «последние 7 дней» отсчитываются от сегодняшнего дня. */
function lastDays(count: number): () => [Date, Date] {
return () => {
const to = new Date()
const from = new Date()
from.setDate(from.getDate() - count + 1)
return [from, to]
}
}
function thisMonth(): [Date, Date] {
const now = new Date()
return [new Date(now.getFullYear(), now.getMonth(), 1), now]
}
const presets = [
{ label: 'Сегодня', range: lastDays(1) },
{ label: 'Последние 7 дней', range: lastDays(7) },
{ label: 'Этот месяц', range: thisMonth },
{ label: 'Последние 90 дней', range: lastDays(90) },
]
</script>
<template>
<div class="grid gap-3 justify-items-start">
<GrDateRangePicker
v-model="period"
:presets="presets"
:max-range="31"
placeholder="Период отчёта"
/>
<p class="showcase-demo-text text-sm opacity-70">
Период длиннее месяца запрещён, поэтому «Последние 90 дней» выключены —
кнопка, которая ничего не делает, обманывает. Свой подвал ставится слотом
<code>footer</code>: он получает <code>setRange</code> и те же правила.
</p>
</div>
</template>Time
<script setup lang="ts">
import { computed, ref } from 'vue'
// `GrDateRangePicker` подставляется авто-импортом.
/**
* Окно обслуживания: период с точностью до минут.
*
* Панель модели рядом не для красоты — на ней видно обе границы целиком, а
* именно во времени границ и весь смысл: по датам эти периоды неразличимы.
*/
const TODAY = new Date(2026, 7, 12)
const window = ref<[Date, Date] | null>(null)
function format(date: Date): string {
return new Intl.DateTimeFormat('ru-RU', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
const model = computed(() => (window.value
? { from: format(window.value[0]), to: format(window.value[1]) }
: null))
</script>
<template>
<div class="grid gap-4 justify-items-start">
<GrDateRangePicker
v-model="window"
enable-time
:minute-step="15"
:today="TODAY"
locale="ru-RU"
placeholder="Выберите окно"
aria-label="Окно обслуживания"
class="w-96"
/>
<pre v-if="model" class="rounded-[var(--gr-radius-lg)] border border-[var(--gr-brd)] bg-[var(--gr-muted)] p-3 text-[length:var(--gr-control-text-sm)] leading-[var(--gr-leading-sm)]">{{ JSON.stringify(model, null, 2) }}</pre>
<p v-else class="showcase-demo-text text-sm opacity-70">Пока ничего не выбрано.</p>
<p class="showcase-demo-text text-sm opacity-70">
Выберите две даты: время встаёт <strong>00:00 и 23:59</strong> — сутки целиком. Две полуночи
выглядели бы симметрично, но «с 12 по 14» по-человечески включает весь четырнадцатый день, а
период до 14-го 00:00 молча отрезал бы почти все эти сутки. Это классическая ошибка отчётов, и
умолчание здесь её не повторяет.
</p>
<p class="showcase-demo-text text-sm opacity-70">
Панель не закрывается на второй дате: выбор на ней не заканчивается. Время правится когда
угодно и в любом порядке — мастера из четырёх шагов нет намеренно, он завёл бы скрытое
состояние «на каком мы шаге». Выберите период в один день и попробуйте увести конец раньше
начала: правка не применится, потому что внутри одного дня порядок краёв держит только время.
</p>
</div>
</template>