Перейти к содержимому
v3.0.0
English
Русский
Документация

TimeField

TimeField — составной компонент для ввода времени по маске.
time-field.tsx · 8 строк
import { Label, TimeField, TimeFieldInput } from '@vega-ui/react';

<div>
  <Label htmlFor='preview-time'>Reminder</Label>
  <TimeField defaultValue='09:30'>
    <TimeFieldInput id='preview-time' placeholder='hh:mm' />
  </TimeField>
</div>
Поддержка браузеров
Широко доступно
  • Chrome 111
  • Edge 111
  • Firefox 121
  • Safari 15.4
  • Safari на iOS 15.4
Размер в бандле
История версий
Добавлен в v2.2.0, последнее изменение в v3.0.0

Кратко

TimeField — составной компонент для ввода времени по маске. Он построен на TextField и добавляет ограничения формата, min/max и шага для строк времени, когда продуктовое значение — только время.

Импорты

import {
  TimeField,
  TimeFieldInput,
  type TimeFieldInputProps,
  type TimeFieldProps,
} from '@vega-ui/react';

Экспортируемые типы

  • TimeFieldProps
  • TimeFieldInputProps

Минимальная композиция

minimal-composition.tsx · 9 строк
import { TimeField, TimeFieldInput } from '@vega-ui/react';

export default function MinimalComposition() {
  return (
    <TimeField>
      <TimeFieldInput aria-label='Reminder time' placeholder='hh:mm' />
    </TimeField>
  );
}

Обязательные части

  • TimeField: корень, предоставляющий ограничения времени и формат
  • TimeFieldInput: текстовое поле ввода с маской

Необязательные части

  • кроме поля ввода, дополнительных публичных частей нет

Порядок композиции

  1. TimeField
  2. TimeFieldInput

Варианты

  • Формат: HH:mm или HH:mm:ss
  • Границы: необязательные min и max
  • Состояние: контролируемое или неконтролируемое value на корне, отключение через пропсы поля ввода

Пограничные случаи

  • Формат, min и max должны согласовываться друг с другом.
  • Частично введённые значения не следует слишком рано считать полным временем.
  • В текстах продукта пользователям нужно ясно сообщать, что ожидается 24-часовой формат.
  • Если функция на самом деле хранит временную метку, DateTimeField обычно понятнее, чем разделять правила для даты и времени вручную.

Частые ошибки

  • Полагаться на форматирование по локали вместо того, чтобы описать ожидаемую маску.
  • Передавать границы, не соответствующие выбранному формату.
  • Считать неполный текст в маске валидными данными для отправки.

Примеры

Базовый

Базовый: время ежедневного напоминания

Поле с подписью для времени в 24-часовом формате. defaultValue на корне — строка HH:mm, а вспомогательный текст подсказывает, по каким часам и в каком часовом поясе указано время.

reminder-time.tsx · 13 строк
import { HelperText, Label, TimeField, TimeFieldInput } from '@vega-ui/react';

export default function ReminderTime() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260 }}>
      <Label htmlFor='daily-reminder'>Daily reminder</Label>
      <TimeField defaultValue='08:30'>
        <TimeFieldInput id='daily-reminder' placeholder='hh:mm' aria-describedby='daily-reminder-hint' />
      </TimeField>
      <HelperText id='daily-reminder-hint'>24-hour time in your local time zone.</HelperText>
    </div>
  );
}

Контролируемый/с состоянием

Контролируемый/с состоянием: начало и конец смены

value и onChangeValue на корне держат оба времени в состоянии родителя. Начало — это min поля окончания, поэтому окончание раньше начала ограничивается, а длительность смены считается по двум значениям. Пока время неполное, его значение равно ''.

controlled-time-value.tsx · 42 строки
import { useState } from 'react';
import { Fieldset, FieldsetLegend, Label, Text, TimeField, TimeFieldInput } from '@vega-ui/react';

const toMinutes = (time: string) => {
  const [hours, minutes] = time.split(':').map(Number);

  return hours * 60 + minutes;
};

const describeShift = (start: string, end: string) => {
  if (!start || !end) return 'Enter both times';

  const length = toMinutes(end) - toMinutes(start);

  return `${Math.floor(length / 60)} h ${length % 60} min`;
};

export default function ShiftHours() {
  const [start, setStart] = useState('09:00');
  const [end, setEnd] = useState('17:30');

  return (
    <Fieldset style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%', maxWidth: 260 }}>
      <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-6)' }}>Shift</FieldsetLegend>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor='shift-start'>Starts</Label>
        <TimeField value={start} onChangeValue={setStart}>
          <TimeFieldInput id='shift-start' placeholder='hh:mm' />
        </TimeField>
      </div>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor='shift-end'>Ends</Label>
        <TimeField min={start} value={end} onChangeValue={setEnd}>
          <TimeFieldInput id='shift-end' placeholder='hh:mm' />
        </TimeField>
      </div>
      <Text size={2} aria-live='polite' style={{ color: 'var(--label-secondary)' }}>
        Length: {describeShift(start, end)}
      </Text>
    </Fieldset>
  );
}

Форма/интеграция

Форма/интеграция: отправка времени

Для HH:mm и HH:mm:ss введённый текст уже и есть значение, поэтому name на TimeFieldInput отправляет его как есть. required и pattern не пропускают пустое или неполное время, а кнопка сброса возвращает defaultValue корня.

submitting-a-time.tsx · 36 строк
import { FormEvent, useState } from 'react';
import { Button, HelperText, Label, Text, TimeField, TimeFieldInput } from '@vega-ui/react';

export default function QuietHoursForm() {
  const [submitted, setSubmitted] = useState('');

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    setSubmitted(JSON.stringify(Object.fromEntries(data)));
  };

  return (
    <form onSubmit={onSubmit} onReset={() => setSubmitted('')} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 260 }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor='quiet-from'>Mute notifications from</Label>
        <TimeField defaultValue='22:00'>
          <TimeFieldInput
            id='quiet-from'
            name='quietFrom'
            required
            pattern='\d{2}:\d{2}'
            placeholder='hh:mm'
            aria-describedby='quiet-from-hint'
          />
        </TimeField>
        <HelperText id='quiet-from-hint'>24-hour time, for example 22:00.</HelperText>
      </div>
      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
        <Button type='reset' variant='secondary' appearance='ghost'>Reset</Button>
        <Button type='submit'>Save</Button>
      </div>
      {submitted && <Text size={2} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>Sent: <code>{submitted}</code></Text>}
    </form>
  );
}

Раскладка/оверлей

Раскладка/оверлей: таблица часов работы

Небольшие поля в ячейках таблицы, по строке на каждый день недели. У input нет своей видимой подписи, поэтому каждый получает aria-label с днём и столбцом.

opening-hours-table.tsx · 49 строк
import {
  Table,
  TableBody,
  TableData,
  TableHead,
  TableHeading,
  TableRow,
  TimeField,
  TimeFieldInput,
} from '@vega-ui/react';

const DAYS = [
  { day: 'Monday', opens: '09:00', closes: '18:00' },
  { day: 'Tuesday', opens: '09:00', closes: '18:00' },
  { day: 'Wednesday', opens: '09:00', closes: '20:00' },
  { day: 'Thursday', opens: '09:00', closes: '18:00' },
  { day: 'Friday', opens: '10:00', closes: '16:00' },
];

export default function OpeningHoursTable() {
  return (
    <Table aria-label='Opening hours'>
      <TableHead>
        <TableRow>
          <TableHeading>Day</TableHeading>
          <TableHeading>Opens</TableHeading>
          <TableHeading>Closes</TableHeading>
        </TableRow>
      </TableHead>
      <TableBody>
        {DAYS.map(({ day, opens, closes }) => (
          <TableRow key={day}>
            <TableData>{day}</TableData>
            <TableData>
              <TimeField size='sm' defaultValue={opens} style={{ width: 96 }}>
                <TimeFieldInput name={`${day.toLowerCase()}Opens`} aria-label={`${day} opens`} />
              </TimeField>
            </TableData>
            <TableData>
              <TimeField size='sm' defaultValue={closes} style={{ width: 96 }}>
                <TimeFieldInput name={`${day.toLowerCase()}Closes`} aria-label={`${day} closes`} />
              </TimeField>
            </TableData>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Ошибка

Ошибка: время вне ночного окна

min и max не описывают окно, которое переходит через полночь, поэтому у поля нет границ, а обработчик отправки сам проверяет правило 22:00–06:00. error и aria-invalid помечают поле, а следующее изменение значения сбрасывает ошибку.

time-outside-allowed-range.tsx · 44 строки
import { FormEvent, useState } from 'react';
import { Button, HelperText, Label, TimeField, TimeFieldInput } from '@vega-ui/react';

const isOvernight = (time: string) => time >= '22:00' || time <= '06:00';

export default function NightDeliveryForm() {
  const [time, setTime] = useState('');
  const [error, setError] = useState('');
  const invalid = error !== '';

  const onChangeValue = (value: string) => {
    setTime(value);
    setError('');
  };

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    if (time && !isOvernight(time)) {
      setError('Night deliveries arrive between 22:00 and 06:00.');
    }
  };

  return (
    <form onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 260 }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor='night-delivery'>Night delivery at</Label>
        <TimeField error={invalid} value={time} onChangeValue={onChangeValue}>
          <TimeFieldInput
            id='night-delivery'
            required
            placeholder='hh:mm'
            aria-invalid={invalid}
            aria-describedby='night-delivery-hint'
          />
        </TimeField>
        <HelperText id='night-delivery-hint' error={invalid}>{invalid ? error : 'Between 22:00 and 06:00.'}</HelperText>
      </div>
      <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)' }}>
        <Button type='submit'>Book</Button>
      </div>
    </form>
  );
}

Отключённый

Отключённый: слот расписания только для чтения

readOnly на TimeFieldInput оставляет время фокусируемым и отправляемым с формой, но не даёт его редактировать.

read-only-schedule-slot.tsx · 13 строк
import { HelperText, Label, TimeField, TimeFieldInput } from '@vega-ui/react';

export default function ReadOnlyScheduleSlot() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260 }}>
      <Label htmlFor='backup-slot'>Nightly backup</Label>
      <TimeField defaultValue='03:00'>
        <TimeFieldInput id='backup-slot' name='backupSlot' readOnly aria-describedby='backup-slot-hint' />
      </TimeField>
      <HelperText id='backup-slot-hint'>Set by your administrator.</HelperText>
    </div>
  );
}

Отключённый: отключение через fieldset

У TimeField нет своего пропа disabled: отключайте input или окружающий <fieldset disabled>, как здесь, где переключатель отключает оба времени для события на весь день.

disabled-by-fieldset.tsx · 42 строки
import { useState } from 'react';
import {
  Fieldset,
  FieldsetLegend,
  Label,
  Switch,
  SwitchIndicator,
  SwitchInput,
  TimeField,
  TimeFieldInput,
} from '@vega-ui/react';

export default function DisabledByFieldset() {
  const [allDay, setAllDay] = useState(true);

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-12)', width: '100%', maxWidth: 260 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
        <Switch>
          <SwitchInput id='all-day' checked={allDay} onChange={(event) => setAllDay(event.currentTarget.checked)} />
          <SwitchIndicator />
        </Switch>
        <Label htmlFor='all-day'>All-day event</Label>
      </div>
      <Fieldset disabled={allDay} style={{ display: 'grid', gap: 'var(--spacing-8)' }}>
        <FieldsetLegend style={{ marginBlockEnd: 'var(--spacing-6)' }}>Time</FieldsetLegend>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor='event-start'>Starts</Label>
          <TimeField defaultValue='10:00'>
            <TimeFieldInput id='event-start' name='eventStart' />
          </TimeField>
        </div>
        <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
          <Label htmlFor='event-end'>Ends</Label>
          <TimeField defaultValue='11:00'>
            <TimeFieldInput id='event-end' name='eventEnd' />
          </TimeField>
        </div>
      </Fieldset>
    </div>
  );
}

Пограничный случай

Пограничный случай: ограниченное окно времени

Как и в нативном <input type="time">, полное время вне min и max ограничивается ближайшей границей, поэтому 07:15 становится 09:00. Под полем видно ограниченное значение.

bound-time-window.tsx · 21 строка
import { useState } from 'react';
import { HelperText, Label, Text, TimeField, TimeFieldInput } from '@vega-ui/react';

export default function BoundTimeWindow() {
  const [callback, setCallback] = useState('');

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%', maxWidth: 260 }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor='callback-time'>Call me back at</Label>
        <TimeField min='09:00' max='18:00' value={callback} onChangeValue={setCallback}>
          <TimeFieldInput id='callback-time' placeholder='hh:mm' aria-describedby='callback-time-hint' />
        </TimeField>
        <HelperText id='callback-time-hint'>Support works 09:00–18:00. Try 07:15 or 19:40.</HelperText>
      </div>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        Value: {callback ? <code>{callback}</code> : 'no complete time'}
      </Text>
    </div>
  );
}

Пограничный случай: шаг в четверть часа

step задаёт, на сколько ArrowUp и ArrowDown сдвигают минуты под кареткой, переходя от 45 к 00 без смены часа. Часы всегда двигаются на единицу, а введённые минуты остаются как есть.

quarter-hour-step.tsx · 13 строк
import { HelperText, Label, TimeField, TimeFieldInput } from '@vega-ui/react';

export default function QuarterHourStep() {
  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-3)', width: '100%', maxWidth: 260 }}>
      <Label htmlFor='class-start'>Class starts</Label>
      <TimeField step={15} defaultValue='18:45'>
        <TimeFieldInput id='class-start' aria-describedby='class-start-hint' />
      </TimeField>
      <HelperText id='class-start-hint'>Put the caret in the minutes and press ↑ or ↓.</HelperText>
    </div>
  );
}

Пограничный случай: точность до секунд

format='HH:mm:ss' добавляет сегмент секунд; значение тогда содержит секунды, и min и max тоже записываются с секундами.

seconds-precision-rule.tsx · 20 строк
import { useState } from 'react';
import { Label, Text, TimeField, TimeFieldInput } from '@vega-ui/react';

export default function SecondsPrecisionRule() {
  const [runAt, setRunAt] = useState('00:00:30');

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-8)', width: '100%', maxWidth: 260 }}>
      <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
        <Label htmlFor='cron-time'>Run the export at</Label>
        <TimeField format='HH:mm:ss' min='00:00:30' max='23:59:30' value={runAt} onChangeValue={setRunAt}>
          <TimeFieldInput id='cron-time' placeholder='hh:mm:ss' />
        </TimeField>
      </div>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        Value: {runAt ? <code>{runAt}</code> : 'no complete time'}
      </Text>
    </div>
  );
}

Анатомия

Обзор

TimeField — небольшая составная обёртка вокруг TextField. Корень предоставляет контекст формата и границ, а TimeFieldInput применяет поведение маски.

Обязательные части

TimeField

Обязателен. Отвечает за контекст format, min, max и step.

TimeFieldInput

Обязателен. Рендерит само поле ввода с маской.

Необязательные части

Кроме поля ввода, дополнительных публичных частей нет.

Порядок композиции

  1. TimeField
  2. TimeFieldInput

Корректные варианты композиции

minimal-composition.tsx · 9 строк
import { TimeField, TimeFieldInput } from '@vega-ui/react';

export default function MinimalComposition() {
  return (
    <TimeField>
      <TimeFieldInput aria-label='Reminder time' placeholder='hh:mm' />
    </TimeField>
  );
}

Некорректные варианты композиции

Границы, не соответствующие формату

Пример: format='HH:mm' с max, содержащим секунды.

Обёртка без TimeFieldInput

Корень рендерится, но контрола с маской нет.