GrDateRangePicker

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

Берут, когда отчёт за период.

Когда брать

  • отчёт за период — «с — по» это одно поле и одно значение, а не два независимых: два GrDatePicker рядом не мешают выбрать конец раньше начала;
  • бронирование и занятость — предпросмотр по наведению показывает будущий диапазон до второго клика;
  • фильтр списка по датам — модель это пара, поэтому в query-string она уезжает одним параметром;
  • границы обязаны быть согласованы — компонент сам держит порядок и не выпускает наружу перевёрнутый период.

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

НужноБерите
Одна датаGrDatePicker
Дата со временемGrDateTimePicker
Только времяGrTimePicker
Сетка месяца без поляGrCalendar
Период выбирают только из готовых, свой не нуженGrSegmented

Готовые периоды живут внутри панели

presets рисует ряд шорткатов в подвале: «Сегодня», «Последние 7 дней», «Этот месяц». Границы можно задать функцией — «последние 7 дней» отсчитываются от сегодняшнего дня, а не от дня, когда объявили проп.

Соседний GrSegmented для этого не годится, хотя внешне похож. Шорткат периода обязан выставить обе границы и не обойти minRange/maxRange, min, max и disabledDates — то есть знать внутренности пикера. Снаружи этих правил не видно, поэтому период, который выбрать нельзя, снаружи и не отличить от допустимого; внутри он приходит выключенным.

Свой подвал — слот footer; он заменяет ряд целиком и получает setRange, canSetRange и close, то есть ровно те же правила.

Период с точностью до минут

enable-time добавляет в панель две колонки времени — для начала и для конца:

<GrDateRangePicker v-model="window" enable-time />

Свежий период получает сутки целиком: 00:00 и 23:59. Две полуночи выглядели бы симметрично, но «с 1 по 3 августа» по-человечески включает весь третий день, а [1 авг 00:00, 3 авг 00:00) молча отрезал бы почти всё третье — классическая ошибка отчётов. С enable-seconds конец получает 23:59:59. Оба значения видны и правятся.

Конец кладётся на ту же сетку, что и колонки: при minute-step="15" это 23:45, а не 23:59. Иначе у минут не было бы выбранного варианта — конец периода нельзя было бы прочитать там, где его правят. С шагом в минуту, то есть по умолчанию, это ровно 23:59.

Мастера из четырёх шагов здесь нет. Даты выбираются двумя кликами, как и без времени; время правится когда угодно и в любом порядке. Пошаговый выбор завёл бы скрытое состояние «на каком мы шаге», из которого промахнувшегося не вывести иначе как пройдя всё заново.

Панель не закрывается на второй дате, когда время включено: выбор на ней не заканчивается, и закрытие отняло бы у пользователя колонки, ради которых он enable-time и включил.

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

minRange и maxRange по-прежнему считаются в днях. Менять единицу у существующих пропов значило бы сломать чужой код; ограничение в минутах — это отдельные minDuration/maxDuration, и они появятся, когда понадобятся, а не «на всякий случай».

Ручной ввод

editable включает набор периода одной строкой:

<GrDateRangePicker v-model="period" editable />

Строку разбирает не разделитель, а счёт групп цифр: их делят пополам, и каждая половина разбирается как граница. Поэтому 08/12/2026 — 08/14/2026, 08/12/2026 - 08/14/2026 и 08/12/2026 08/14/2026 — это одно и то же, а en-CA с дефисами внутри самих дат (2026-08-12 - 2026-08-14) не ломается: список разделителей развалился бы на первой же локали, и его тут нет.

Половина суток, оказавшаяся между границами (3:30 PM — 8/14/2026), достаётся той границе, к которой её относит локаль: в en-US она стоит после часа, в ko — перед ним.

Одна дата вместо двух отклоняется. Одна дата — не период; достроить вторую границу значило бы придумать за пользователя то, чего он не задавал.

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

Время принимается ровно тогда, когда пикер его показывает. С enable-time обе границы обязаны нести время, без него — ни одна: пикер, молча проглотивший 14:30, показал бы значение, которого не вводили.

Панель идёт за набором. Набранная первая граница подсвечивается началом периода — тем же состоянием, что и после первого клика в сетке; набранная вторая закрывает полосу. Сетка переходит на месяц набранного. Модель при этом не меняется.

Период проверяется теми же правилами, что и выбор мышью: запрещённые даты, min, max, minRange, maxRange. Разобравшийся, но недопустимый период не применяется и объявляется — пользователь набрал всё правильно, и молчание выглядело бы как потерянный Enter.

Удачный ввод снимает начало периода, если оно было открыто кликом в панели: иначе следующий клик закрыл бы период от даты, о которой в поле уже ничего не написано.

Как и у GrDateTimePicker, поле с editable показывает границы цифрами — набор обязан читать обратно показанное, — маски нет, а плейсхолдер по умолчанию показывает обе границы целиком.

Адаптер применяется к каждой границе

Модель — пара, и valueAdapter разбирает и собирает обе её стороны. При valueAdapter="isoDate" наружу уходит ['2026-08-10', '2026-08-14'], а не одна строка с разделителем: разделитель пришлось бы выбирать за потребителя, и он же сломался бы на первой локали, где тире значит что-то другое.

Установка

npm i @feugene/granularity-chrono

Импорт

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

API

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

Примеры 5

Basic

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

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

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

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

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>

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