Time Range
Start/end time range (HH:mm) integrated with TanStack Form via field context. Selecting the start sets the end one hour later and enforces start < end.
npx shadcn@latest add https://shuip.plvo.dev/r/tsf-time-range.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/tsf-time-range.json
bun x shadcn@latest add https://shuip.plvo.dev/r/tsf-time-range.json
'use client';import { Field, FieldDescription, FieldError, FieldLabel } from '@/components/ui/field';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';import { useFieldContext } from '@/components/ui/shuip/tanstack-form/form-context';import {addOneHour,HOUR_OPTIONS,isHourDisabled,isMinuteDisabled,joinTime,MINUTE_OPTIONS,nextSlot,splitTime,type TimeRangeValue,} from '@/lib/time';interface TimePickerProps {id?: string;value: string;onChange: (value: string) => void;min?: string;disabled?: boolean;invalid?: boolean;}function TimePicker({ id, value, onChange, min, disabled, invalid }: TimePickerProps) {const { hour, minute } = splitTime(value);const bounds = { min };return (<div className='flex items-center gap-2'><Select value={hour} onValueChange={(h) => onChange(joinTime(h, minute))} disabled={disabled}><SelectTrigger id={id} aria-invalid={invalid} className='w-full'><SelectValue placeholder='HH' /></SelectTrigger><SelectContent>{HOUR_OPTIONS.map((h) => (<SelectItem key={h} value={h} disabled={isHourDisabled(h, bounds)}>{h}</SelectItem>))}</SelectContent></Select><span className='text-muted-foreground'>:</span><Select value={minute} onValueChange={(m) => onChange(joinTime(hour, m))} disabled={disabled}><SelectTrigger aria-invalid={invalid} className='w-full'><SelectValue placeholder='mm' /></SelectTrigger><SelectContent>{MINUTE_OPTIONS.map((m) => (<SelectItem key={m} value={m} disabled={isMinuteDisabled(m, hour, bounds)}>{m}</SelectItem>))}</SelectContent></Select></div>);}export interface TimeRangeFieldProps {label?: string;description?: string;startLabel?: string;endLabel?: string;disabled?: boolean;fieldProps?: React.ComponentProps<typeof Field>;}export function TimeRangeField({label,description,startLabel = 'Start',endLabel = 'End',disabled,fieldProps,}: TimeRangeFieldProps) {const field = useFieldContext<TimeRangeValue>();const { isValid, errors } = field.state.meta;const value: TimeRangeValue = field.state.value ?? { start: '', end: '' };return (<Field className='gap-2' data-invalid={!isValid} {...fieldProps}>{label && <FieldLabel>{label}</FieldLabel>}<div className='flex flex-col gap-3 sm:flex-row sm:items-end'><div className='flex flex-1 flex-col gap-1'><FieldLabel htmlFor={`${field.name}-start`} className='text-xs'>{startLabel}</FieldLabel><TimePickerid={`${field.name}-start`}value={value.start}onChange={(start) => field.handleChange({ start, end: addOneHour(start) })}disabled={disabled}invalid={!isValid}/></div><div className='flex flex-1 flex-col gap-1'><FieldLabel htmlFor={`${field.name}-end`} className='text-xs'>{endLabel}</FieldLabel><TimePickerid={`${field.name}-end`}value={value.end}onChange={(end) => field.handleChange({ ...value, end })}min={value.start ? nextSlot(value.start) : undefined}disabled={disabled}invalid={!isValid}/></div></div>{!isValid && (<FieldErrorclassName='text-xs text-left'errors={errors.map((error) => ({ message: typeof error === 'string' ? error : error?.message }))}/>)}{description && <FieldDescription className='text-xs'>{description}</FieldDescription>}</Field>);}
Loading...
TimeRangeField is two coupled time pickers — a start and an end — reading a single { start, end } object from TanStack Form's field context. Both values are HH:mm strings.
Built-in features
- Coupled pickers: choosing a start time sets the end to one hour later automatically
start < endenforced: end options at or before the start are disabled in the dropdowns- Single object binding: bind the field to a
{ start, end }value - Validation: surfaces TanStack Form validator errors below the pickers
Setup
import { createFormHook } from '@tanstack/react-form';
import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';
import { TimeRangeField } from '@/components/ui/shuip/tanstack-form/time-range';
const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { TimeRangeField },
formComponents: {},
});
// defaultValues: { slot: { start: '', end: '' } }
<form.AppField
name='slot'
children={(field) => <field.TimeRangeField label='Meeting slot' />}
/>The value is { start: string; end: string }, each a HH:mm string (empty when unset). Changing the start always resets the end to one hour after the new start.
Examples
Default
Loading...
'use client';import { createFormHook } from '@tanstack/react-form';import { fieldContext, formContext } from '@/components/ui/shuip/tanstack-form/form-context';import { SubmitButton } from '@/components/ui/shuip/tanstack-form/submit-button';import { TimeRangeField } from '@/components/ui/shuip/tanstack-form/time-range';const { useAppForm } = createFormHook({fieldContext,formContext,fieldComponents: { TimeRangeField },formComponents: { SubmitButton },});export default function TsfTimeRangeExample() {const form = useAppForm({defaultValues: {slot: { start: '', end: '' },},onSubmit: async ({ value }) => {await new Promise((resolve) => setTimeout(resolve, 500));alert(JSON.stringify(value, null, 2));},});return (<formonSubmit={(e) => {e.preventDefault();form.handleSubmit();}}className='space-y-4'><form.AppFieldname='slot'validators={{onChange: ({ value }) =>value.start && value.end && value.start < value.end? undefined: 'Select a start and end time (end after start)',}}children={(field) => <field.TimeRangeField label='Meeting slot' description='Pick a start and end time' />}/><form.AppForm><form.SubmitButton>Save</form.SubmitButton></form.AppForm></form>);}
Props
Prop
Type