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>
<TimePicker
id={`${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>
<TimePicker
id={`${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 && (
<FieldError
className='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 < end enforced: 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 (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className='space-y-4'
>
<form.AppField
name='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

On this page