Toggling a closed day open, or pressing its add button, seeds it with a single 09:00–17:00 range. Toggling it closed clears its ranges.
Time format
The pickers display in whatever format the locale prefers, so an en-GB or de-DE user gets a 24-hour clock and an en-US user gets AM/PM. The stored value never changes: it is a 24-hour "HH:mm" string either way.
Without locale, the format follows navigator.language and is resolved after hydration, so the server never renders a string the client disagrees with. Pass locale when you know it up front, and hour12 to override the locale's own preference in either direction.
Layout
The day row lays out horizontally once the component itself is about 34rem wide, and stacks below that. The breakpoint reads the component's own width, not the viewport's, so dropping it into a narrow card or a sidebar stacks it rather than squeezing the pickers until the times clip.
Validation
Validation is presentational. The component marks the row invalid but never rewrites the value you passed in, so a schema on your form still decides whether it can be saved.
Two conditions are reported inline, on the offending range:
End at or before start. A range must end later in the same day than it starts. The end picker only offers times after the range's start, so this error is unreachable through the UI. It is here for values that arrive from props or a form library.
Overlap. Two ranges on the same day may not cover the same minute. The later-starting range is the one flagged.
Ranges that cross midnight are not supported. 22:00–02:00 reads as an end-before-start error instead of wrapping. Model an overnight shift as two ranges on two days.
Accessibility
Each day is a role="group" labelled by its name, so a screen reader announces “Monday, group” before its controls. The day's switch borrows the same label. Add, remove, and copy buttons name their day, and the remove button names the range it removes: “Remove 9:00 AM to 5:00 PM on Monday”. Removing a range moves focus to the day's add button, or to the day's switch when the last range goes, never onto the document.
The add, remove, and copy buttons are icon-only, so each carries a tooltip. The tooltip text is a substring of the button's accessible name, “Add hours” inside “Add hours for Monday”, so speech-input users can say what they see.
Times use the tabular-figures utility so the columns hold still as values change.
With react-hook-form
WeeklyHours takes no dependency on a form library. Wire it up with a Controller, as you would any other controlled input.
Loading...
"use client";import { zodResolver } from "@hookform/resolvers/zod";import type * as React from "react";import { Controller, useForm } from "react-hook-form";import { toast } from "sonner";import { z } from "zod";import { Button } from "@/components/ui/button";import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "@/components/ui/card";import { Field, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSet,} from "@/components/ui/field";import { WeeklyHours } from "@/components/ui/weekly-hours";const timeRangeSchema = z .object({ end: z.string(), start: z.string(), }) .refine((range) => range.end > range.start, { message: "End time must be later in the day than the start time.", });const formSchema = z.object({ hours: z.object({ friday: z.array(timeRangeSchema), monday: z.array(timeRangeSchema), saturday: z.array(timeRangeSchema), sunday: z.array(timeRangeSchema), thursday: z.array(timeRangeSchema), tuesday: z.array(timeRangeSchema), wednesday: z.array(timeRangeSchema), }),});const onSubmit = (data: z.infer<typeof formSchema>) => { toast("You submitted the following values:", { classNames: { content: "flex flex-col gap-2", }, description: ( <pre className="mt-2 w-80 overflow-x-auto rounded-md bg-code p-4 text-code-foreground"> <code>{JSON.stringify(data, null, 2)}</code> </pre> ), position: "bottom-right", style: { "--border-radius": "calc(var(--radius) + 4px)", } as React.CSSProperties, });};export function WeeklyHoursRhf() { const form = useForm<z.infer<typeof formSchema>>({ defaultValues: { hours: { friday: [{ end: "17:00", start: "09:00" }], monday: [{ end: "17:00", start: "09:00" }], saturday: [], sunday: [], thursday: [{ end: "17:00", start: "09:00" }], tuesday: [{ end: "17:00", start: "09:00" }], wednesday: [{ end: "17:00", start: "09:00" }], }, }, resolver: zodResolver(formSchema), }); return ( <Card className="w-full max-w-2xl"> <CardHeader className="border-b"> <CardTitle>Opening Hours</CardTitle> <CardDescription>When customers can book an appointment with you.</CardDescription> </CardHeader> <CardContent> <form id="weekly-hours-rhf" onSubmit={form.handleSubmit(onSubmit)}> <FieldSet className="gap-4"> <FieldLegend variant="label">Weekly Schedule</FieldLegend> <FieldDescription> Turn a day off to mark it closed. Add a second range to cover a lunch break. </FieldDescription> <FieldGroup className="gap-4"> <Controller control={form.control} name="hours" render={({ field, fieldState }) => ( <Field data-invalid={fieldState.invalid}> <WeeklyHours onValueChange={(next) => field.onChange(next)} value={field.value} /> {fieldState.invalid && ( <FieldError>Fix the highlighted hours before saving.</FieldError> )} </Field> )} /> </FieldGroup> </FieldSet> </form> </CardContent> <CardFooter className="border-t"> <Field orientation="horizontal"> <Button onClick={() => form.reset()} type="button" variant="outline"> Reset </Button> <Button form="weekly-hours-rhf" type="submit"> Save </Button> </Field> </CardFooter> </Card> );}