Date and time

Time field

A labelled time entry typed by hour, minute and optional second and AM or PM segments.

Built on
<input>:has()
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.8 kB
Dependencies
React
Code
import { TimeField } from "@/components/equip/time-field";

export default function Demo() {
  return <TimeField label="Standup reminder" defaultValue="09:45" step={5} />;
}

Examples

12-hour with seconds

Type a or p in the last segment for AM or PM. The value stays 24-hour, so 2:30:15 PM is "14:30:15".

Code
import { TimeField } from "@/components/equip/time-field";

export default function Example() {
  return <TimeField label="Race start" hourCycle={12} withSeconds defaultValue="14:30:15" name="race_start" />;
}

Step and limits

Arrow keys move minutes in steps of 15 and snap to the grid; a time outside min and max marks the field invalid.

Courier arrives from 10:00

Code
"use client";

import { useState } from "react";
import { TimeField } from "@/components/equip/time-field";

export default function Example() {
  const [time, setTime] = useState<string | null>("10:00");
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: 24, alignItems: "start" }}>
      <TimeField
        label="Delivery slot"
        value={time}
        onValueChange={setTime}
        step={15}
        min="08:00"
        max="18:00"
        hint={time ? `Courier arrives from ${time}` : "Between 08:00 and 18:00"}
      />
      <TimeField label="Cut-off" defaultValue="17:30" disabled />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneVisible label for the group.
valuestring | nullnone24-hour time, "14:30", or "14:30:15" with seconds.
defaultValuestring | nullnoneStarting time when uncontrolled.
onValueChange(value: string | null) => voidnoneCalled with the 24-hour time once every segment is set, and with null when one is cleared.
stepnumber1Minutes the arrow keys move by, snapping to multiples of it.
hourCycle12 | 242412 adds an AM or PM segment. The value stays 24-hour.
withSecondsbooleanfalseAdds a seconds segment.
minstringnoneEarliest valid time. A time before it marks the field invalid.
maxstringnoneLatest valid time. A time after it marks the field invalid.
namestringnoneAdds a hidden input carrying the 24-hour value, empty while incomplete.
hintReactNodenoneHelp text under the control, linked with aria-describedby.
disabledbooleannoneDisables every segment.
...restComponentProps<"div">noneEvery native div attribute on the wrapper, including ref.

Anatomy

root.eq-time-field
label.eq-time-field-label
control.eq-time-field-controlThe bordered group holding the segments.
segment.eq-time-field-segmentA spinbutton; data-kind is hour, minute, second or period.
separator.eq-time-field-separatorHidden from assistive tech.
hint.eq-time-field-hint

Keyboard

0-9Types into the focused segment and moves on once it is complete.
APIn the AM or PM segment, sets AM or PM.
ArrowUpArrowDownSteps the segment and wraps; minutes move by step. An empty segment starts from now.
ArrowLeftArrowRightMoves to the previous or next segment.
:.Moves to the next segment, as when typing a time in one go.
BackspaceDeleteClears the segment. Backspace in an empty segment moves back.

Notes

  • Each segment is a native input with role="spinbutton" and inputmode="numeric", so touch keyboards show digits.
  • The value is always 24-hour text, whatever hourCycle shows, so it compares and stores without parsing.
  • Crossing 12 with the arrow keys does not flip AM and PM; each segment wraps on its own.
  • A time outside min or max sets aria-invalid and the danger border; min and max do not wrap past midnight.
  • The label points at the hour segment, so clicking it starts typing.
  • The AM and PM text and the segment names are fixed English.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/time-field.tsx289 lines
"use client";

import {
  Fragment,
  useId,
  useRef,
  useState,
  type ChangeEvent,
  type ComponentProps,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import "./time-field.css";

type Kind = "hour" | "minute" | "second" | "period";
type Parts = Record<Kind, number | null>;

export type TimeFieldProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
  label: string;
  /** 24-hour time, "14:30", or "14:30:15" with seconds. null while empty or incomplete. */
  value?: string | null;
  defaultValue?: string | null;
  /** Called with the time once every segment is set, and with null when one is cleared. */
  onValueChange?: (value: string | null) => void;
  /** Minutes the arrow keys move by. */
  step?: number;
  hourCycle?: 12 | 24;
  withSeconds?: boolean;
  /** Earliest valid time, 24-hour. */
  min?: string;
  /** Latest valid time, 24-hour. */
  max?: string;
  /** Name of a hidden input that carries the 24-hour value in a form. */
  name?: string;
  hint?: ReactNode;
  disabled?: boolean;
};

const PERIODS = ["AM", "PM"];
const LABELS: Record<Kind, string> = { hour: "Hours", minute: "Minutes", second: "Seconds", period: "AM or PM" };
const pad = (n: number) => String(n).padStart(2, "0");
const empty: Parts = { hour: null, minute: null, second: null, period: null };

function toParts(value: string | null | undefined, cycle: 12 | 24): Parts {
  const match = value?.match(/^(\d{2}):(\d{2})(?::(\d{2}))?$/);
  if (!match) return empty;
  const h = Number(match[1]);
  return {
    hour: cycle === 12 ? h % 12 || 12 : h,
    minute: Number(match[2]),
    second: match[3] ? Number(match[3]) : 0,
    period: h >= 12 ? 1 : 0,
  };
}

function toValue(p: Parts, cycle: 12 | 24, withSeconds: boolean) {
  if (p.hour === null || p.minute === null) return null;
  if (withSeconds && p.second === null) return null;
  if (cycle === 12 && p.period === null) return null;
  const h = cycle === 12 ? (p.hour % 12) + (p.period ?? 0) * 12 : p.hour;
  return `${pad(h)}:${pad(p.minute)}${withSeconds ? `:${pad(p.second ?? 0)}` : ""}`;
}

export function TimeField({
  label,
  value,
  defaultValue,
  onValueChange,
  step = 1,
  hourCycle = 24,
  withSeconds = false,
  min,
  max,
  name,
  hint,
  disabled,
  className,
  ...rest
}: TimeFieldProps) {
  const id = useId();
  const [parts, setParts] = useState<Parts>(() => toParts(value ?? defaultValue, hourCycle));
  const [synced, setSynced] = useState(value);
  const current = toValue(parts, hourCycle, withSeconds);
  if (value !== undefined && value !== synced) {
    setSynced(value);
    if (value !== current) setParts(toParts(value, hourCycle));
  }
  const [typing, setTyping] = useState<{ kind: Kind; text: string } | null>(null);
  const inputs = useRef<Partial<Record<Kind, HTMLInputElement | null>>>({});

  const order: Kind[] = ["hour", "minute"];
  if (withSeconds) order.push("second");
  if (hourCycle === 12) order.push("period");

  // Compare at the precision shown, so "09:00" as min admits "09:00:30".
  const compared = current?.slice(0, withSeconds ? 8 : 5);
  const invalid =
    compared !== undefined &&
    ((min !== undefined && compared < min.slice(0, compared.length)) ||
      (max !== undefined && compared > max.slice(0, compared.length)));
  const hintId = hint ? `${id}-hint` : undefined;

  function limit(kind: Kind) {
    if (kind === "hour") return hourCycle === 12 ? { min: 1, max: 12 } : { min: 0, max: 23 };
    if (kind === "period") return { min: 0, max: 1 };
    return { min: 0, max: 59 };
  }

  function commit(next: Parts) {
    setParts(next);
    const nextValue = toValue(next, hourCycle, withSeconds);
    if (nextValue !== current) onValueChange?.(nextValue);
  }

  function focusAt(index: number) {
    const kind = order[index];
    if (kind) inputs.current[kind]?.focus();
  }

  function typeDigit(kind: Kind, digit: string) {
    const { min: low, max: high } = limit(kind);
    let text = (typing?.kind === kind ? typing.text : "") + digit;
    if (text.length > 2 || Number(text) > high) text = digit;
    const n = Number(text);
    setTyping({ kind, text });
    if (n >= low) commit({ ...parts, [kind]: n });
    if (text.length >= 2 || n * 10 > high) focusAt(order.indexOf(kind) + 1);
  }

  function setPeriod(period: number) {
    setTyping(null);
    commit({ ...parts, period });
  }

  function spin(kind: Kind, direction: 1 | -1) {
    const { min: low, max: high } = limit(kind);
    const now = parts[kind];
    let n: number;
    if (now === null) {
      // An empty segment starts from the time on the device, read on the key press, not in render.
      const clock = new Date();
      const h = clock.getHours();
      n = {
        hour: hourCycle === 12 ? h % 12 || 12 : h,
        minute: Math.floor(clock.getMinutes() / step) * step,
        second: clock.getSeconds(),
        period: h >= 12 ? 1 : 0,
      }[kind];
    } else if (kind === "minute" && step > 1) {
      n = direction > 0 ? Math.floor(now / step) * step + step : Math.ceil(now / step) * step - step;
    } else {
      n = now + direction;
    }
    const span = high - low + 1;
    n = ((((n - low) % span) + span) % span) + low;
    setTyping(null);
    commit({ ...parts, [kind]: n });
  }

  function clear(kind: Kind) {
    setTyping(null);
    commit({ ...parts, [kind]: null });
  }

  function handleKeyDown(kind: Kind, event: KeyboardEvent<HTMLInputElement>) {
    const index = order.indexOf(kind);
    if (event.metaKey || event.ctrlKey || event.altKey) return;
    if (kind === "period" && /^[ap]$/i.test(event.key)) {
      event.preventDefault();
      event.stopPropagation();
      setPeriod(event.key.toLowerCase() === "a" ? 0 : 1);
      return;
    }
    if (kind !== "period" && /^\d$/.test(event.key)) {
      event.preventDefault();
      event.stopPropagation();
      typeDigit(kind, event.key);
      return;
    }
    switch (event.key) {
      case "ArrowUp":
        spin(kind, 1);
        break;
      case "ArrowDown":
        spin(kind, -1);
        break;
      case "ArrowLeft":
        focusAt(index - 1);
        break;
      case "ArrowRight":
        focusAt(index + 1);
        break;
      case "Backspace":
      case "Delete":
        if (parts[kind] === null && !typing && event.key === "Backspace") focusAt(index - 1);
        else clear(kind);
        break;
      default:
        if (event.key.length !== 1) return;
        event.stopPropagation();
        if (/[\s:.]/.test(event.key)) focusAt(index + 1);
    }
    event.preventDefault();
  }

  /** Touch keyboards send input events rather than key presses; read the character that arrived. */
  function handleChange(kind: Kind, event: ChangeEvent<HTMLInputElement>) {
    const text = event.target.value;
    if (text.length < display(kind).length) {
      clear(kind);
      return;
    }
    const typed = text.charAt((event.target.selectionStart ?? text.length) - 1);
    if (kind === "period" && /^[ap]$/i.test(typed)) setPeriod(typed.toLowerCase() === "a" ? 0 : 1);
    else if (kind !== "period" && /^\d$/.test(typed)) typeDigit(kind, typed);
  }

  function display(kind: Kind) {
    if (typing?.kind === kind) return typing.text;
    const n = parts[kind];
    if (n === null) return "";
    return kind === "period" ? PERIODS[n] : pad(n);
  }

  return (
    <div
      {...rest}
      className={["eq-time-field", className].filter(Boolean).join(" ")}
      data-disabled={disabled || undefined}
    >
      <label className="eq-time-field-label" id={`${id}-label`} htmlFor={`${id}-hour`}>
        {label}
      </label>
      <div
        className="eq-time-field-control"
        role="group"
        aria-labelledby={`${id}-label`}
        aria-describedby={hintId}
        data-invalid={invalid || undefined}
      >
        {order.map((kind, index) => {
          const { min: low, max: high } = limit(kind);
          const shown = display(kind);
          return (
            <Fragment key={kind}>
              {index > 0 && (
                <span className="eq-time-field-separator" aria-hidden="true">
                  {kind === "period" ? " " : ":"}
                </span>
              )}
              <input
                ref={(node) => {
                  inputs.current[kind] = node;
                }}
                id={`${id}-${kind}`}
                className="eq-time-field-segment"
                data-kind={kind}
                role="spinbutton"
                inputMode={kind === "period" ? "text" : "numeric"}
                autoComplete="off"
                spellCheck={false}
                aria-label={LABELS[kind]}
                aria-valuemin={low}
                aria-valuemax={high}
                aria-valuenow={parts[kind] ?? undefined}
                aria-valuetext={shown || "Empty"}
                aria-invalid={invalid || undefined}
                aria-describedby={hintId}
                placeholder="––"
                value={shown}
                disabled={disabled}
                onFocus={() => setTyping(null)}
                onBlur={() => setTyping(null)}
                onKeyDown={(event) => handleKeyDown(kind, event)}
                onChange={(event) => handleChange(kind, event)}
              />
            </Fragment>
          );
        })}
      </div>
      {hint && (
        <p className="eq-time-field-hint" id={hintId}>
          {hint}
        </p>
      )}
      {name && <input type="hidden" name={name} value={current ?? ""} />}
    </div>
  );
}
components/equip/time-field.css99 lines
@layer equip {
  .eq-time-field {
    display: inline-grid;
    gap: var(--eq-space-1);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-m) / 1.4 var(--eq-font);
  }

  .eq-time-field-label {
    width: fit-content;
    font-size: var(--eq-text-s);
    font-weight: var(--eq-weight-strong);
    color: var(--eq-ink);
  }

  .eq-time-field-control {
    display: flex;
    align-items: center;
    width: fit-content;
    height: var(--eq-h-m);
    padding-inline: var(--eq-space-3);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    font-size: var(--eq-text-m);
    font-variant-numeric: tabular-nums;
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-time-field-control:has(.eq-time-field-segment:focus-visible) {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-time-field-control[data-invalid] {
    border-color: var(--eq-danger);
  }

  .eq-time-field-segment {
    box-sizing: content-box;
    width: 2.4ch;
    height: 1.5em;
    margin: 0;
    padding: 0 1px;
    border: 0;
    border-radius: var(--eq-radius-s);
    background: transparent;
    color: inherit;
    font: inherit;
    text-align: center;
    caret-color: transparent;
    outline: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease);
  }

  .eq-time-field-segment[data-kind="period"] {
    width: 3ch;
  }

  .eq-time-field-segment::placeholder {
    color: var(--eq-ink-3);
  }

  .eq-time-field-segment::selection {
    background: transparent;
  }

  /* The segment being typed into is the current one: solid, like a selection. */
  .eq-time-field-segment:focus {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-time-field-segment:focus::placeholder {
    color: color-mix(in srgb, var(--eq-solid-ink) 65%, transparent);
  }

  .eq-time-field-separator {
    color: var(--eq-ink-3);
    padding-inline: 1px;
  }

  .eq-time-field-hint {
    margin: 0;
    font-size: var(--eq-text-s);
    color: var(--eq-ink-2);
  }

  .eq-time-field[data-disabled] .eq-time-field-control {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-time-field[data-disabled] .eq-time-field-segment {
    cursor: not-allowed;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index