GrTimePicker

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

Берут, когда время без даты.

Когда брать

  • время без даты — начало смены, часы работы, напоминание: день здесь либо не важен, либо задан отдельно;
  • время удобнее набратьeditable даёт ввод с клавиатуры, разбор идёт по локали, включая 12-часовой вид там, где он принят;
  • нужны секундыenableSeconds добавляет третью колонку;
  • шаг не минутныйminuteStep строит колонку с нужным шагом, и «14:07» там, где приём идёт по получасам, набрать уже нельзя.

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

НужноБерите
Дата вместе со временемGrDateTimePicker
Только датаGrDatePicker
Период «с — по» по датамGrDateRangePicker
Ввести длительность, а не момент на часахGrNumberInput
Показать длительностьGrDuration
Несколько готовых слотов на выборGrSelect

Длительность — не время. «Полтора часа» это число с единицей, а не момент: колонки часов и минут для неё врут, потому что 90 минут в них не набираются. Вводят её числом, а показывают GrDuration.

Панель не закрывается по выбору

Час, минута и период — три отдельных решения, и закрытие после первого из них означало бы, что пользователь выбрал время, которого он не выбирал. Это и есть причина, по которой панель времени ведёт себя не как календарь.

Подвал раздаёт выбор внутрь

Слот footer получает select, canSelect и close — та же тройка, что у пикеров дат: подвал у всех пикеров пакета устроен одинаково.

<GrTimePicker v-model="time" :minute-step="15">
  <template #footer="{ select, canSelect }">
    <GrButton size="sm" variant="ghost" :disabled="!canSelect(new Date())" @click="select(new Date())">
      Сейчас
    </GrButton>
  </template>
</GrTimePicker>

Время встаёт на сетку шага округлением вверх. 14:37 при шаге в 15 минут даёт 14:45, а не 14:30: время в пикере почти всегда значит «начиная с этого момента» — запись, бронь, напоминание, — и округлённое вниз уже прошло. Этим «сейчас» отличается от колонок, где показ существующего значения округляется вниз: там задача обратная — не выдумать отметку, которой в колонке нет.

Побеждает самая крупная объявленная ступень, мелкие обнуляются: при minuteStep: 15 получится 14:45:00, а не 14:45:37. Секунды выключены — их не будет и в значении, иначе в модель уехало бы то, чего на экране не было.

Границы считаются после округления, а не до. При max в 14:40 и шаге 15 минут «сейчас» в 14:37 округляется до 14:45 — уже за границей, и canSelect отдаёт false. Кнопка приходит выключенной, а не молча ничего не делает: проверка до округления пропустила бы её.

readonly и disabled запрещают выбор целиком — снаружи этих правил не видно, поэтому подвал и спрашивает canSelect, а не считает сам.

Значение остаётся `Date`

Даже когда дата не важна, наружу уходит полноценный момент — с датой из today или из текущего значения. Отдельного типа «время суток» в модели нет: он потребовал бы своей арифметики и своего адаптера ради одного компонента. Нужна строка '14:30' — это valueAdapter на стороне потребителя.

Установка

npm i @feugene/granularity-chrono

Импорт

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

API

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

Примеры 3

Basic

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

// `GrTimePicker` подставляется авто-импортом (`unplugin-vue-components`).
const value = ref<Date | null>(new Date(2026, 7, 12, 9, 30))

// Границы учитывают только время суток: рабочий день с 09:00 до 18:00.
const from = new Date(2026, 7, 12, 9, 0)
const to = new Date(2026, 7, 12, 18, 0)
</script>

<template>
  <div class="grid max-w-[320px] gap-4">
    <GrTimePicker
      v-model="value"
      :min="from"
      :max="to"
      :minute-step="15"
      :use12-hours="false"
      clearable
      placeholder="Pick a time"
      aria-label="Meeting time"
    />

    <p class="showcase-demo-text text-sm">
      <span class="opacity-70">value=</span>
      <code>{{ value ? value.toTimeString().slice(0, 5) : '—' }}</code>
    </p>
  </div>
</template>

Footer

Footerзависит от окружения витрины
<script setup lang="ts">
import { computed, ref } from 'vue'
import IconClock from '~icons/lucide/clock-arrow-up'

// `GrTimePicker`, `GrButton` и `GrSwitch` подставляются авто-импортом.
import { ceilToStep, toPlainTime, useChronoNow } from '@feugene/granularity-chrono'

/**
 * Подвал панели раздаёт выбор внутрь.
 *
 * Демо намеренно даёт переключить `max` на «сейчас плюс две минуты»: на нём
 * видно, что граница считается **после** округления к шагу, и кнопка гаснет,
 * а не молча ничего не делает.
 */
const MINUTE_STEP = 15

const value = ref<Date | null>(null)
const tight = ref(false)

/**
 * Общие часы пакета, а не свой `setInterval`: подпись на кнопке обязана
 * оставаться правдой, но будить вкладку ради этого раз в секунду незачем —
 * такт здесь минутный.
 */
const now = useChronoNow(60_000)

/** Ровно то, что положит кнопка: «сейчас», поставленное на сетку шага. */
const target = computed(() => ceilToStep(toPlainTime(now.value), MINUTE_STEP * 60))

const targetLabel = computed(() => (
  `${String(target.value.h).padStart(2, '0')}:${String(target.value.min).padStart(2, '0')}`
))

/**
 * При включённом переключателе граница стоит на две минуты позже текущего
 * момента — то есть заведомо раньше следующей отметки пятнадцатиминутной сетки.
 */
const max = computed(() => (tight.value ? new Date(now.value.getTime() + 2 * 60_000) : undefined))

const maxLabel = computed(() => (max.value
  ? `${String(max.value.getHours()).padStart(2, '0')}:${String(max.value.getMinutes()).padStart(2, '0')}`
  : null))
</script>

<template>
  <div class="grid gap-4 justify-items-start">
    <GrSwitch v-model="tight" size="sm">
      Ограничить <code>max</code> двумя минутами вперёд
    </GrSwitch>

    <GrTimePicker
      v-model="value"
      :minute-step="MINUTE_STEP"
      :max="max"
      aria-label="Start time"
    >
      <template #footer="{ select, canSelect }">
        <div class="flex w-full items-center justify-between gap-3">
          <!--
            Причина отказа рядом с кнопкой, а не вместо неё: выключенный
            контрол без объяснения читается как поломка.
          -->
          <span class="text-xs leading-tight text-[var(--gr-muted-fg)]">
            <template v-if="canSelect(now)">
              ближайшая отметка
              <strong class="font-600 text-[var(--gr-fg)]">{{ targetLabel }}</strong>
            </template>
            <template v-else>
              {{ targetLabel }} позже, чем {{ maxLabel }}
            </template>
          </span>

          <GrButton
            size="sm"
            variant="outline"
            :disabled="!canSelect(now)"
            @click="select(now)"
          >
            <template #prefix>
              <IconClock />
            </template>
            Сейчас
          </GrButton>
        </div>
      </template>
    </GrTimePicker>

    <p class="showcase-demo-text text-sm opacity-70">
      Шаг — 15 минут, и «сейчас» встаёт на <strong>следующую</strong> отметку: 14:37 даёт 14:45, а не
      14:30. Округление вверх, потому что время в пикере почти всегда значит «начиная с этого
      момента» — запись, бронь, напоминание, — и округлённое вниз уже прошло. Подпись слева называет
      будущее значение до нажатия: у кнопки, которая молча меняет время на непредсказуемое, нет
      способа сказать «не то».
    </p>

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

Twelve Hour

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

// `GrTimePicker` подставляется авто-импортом (`unplugin-vue-components`).
const value = ref<string | null>('2026-08-12T15:30:45')
</script>

<template>
  <div class="grid max-w-[320px] gap-4">
    <!-- 12/24 приходит из локали, но проп перебивает её; секунды добавляют
         третью колонку и попадают в показ. -->
    <GrTimePicker
      v-model="value"
      value-adapter="isoDateTime"
      use12-hours
      enable-seconds
      :second-step="15"
      clearable
      aria-label="Start time"
    />

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

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