{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "weekly-hours",
  "title": "Weekly Hours",
  "author": "Matthew Blode",
  "description": "An editor for opening hours across the days of the week.",
  "dependencies": ["blode-icons-react"],
  "registryDependencies": [
    "button",
    "dropdown-menu",
    "field",
    "switch",
    "tooltip",
    "@blode/time-picker"
  ],
  "files": [
    {
      "path": "ui/weekly-hours.tsx",
      "content": "\"use client\";\n\nimport { PlusIcon, SquareBehindSquare1Icon, XIcon } from \"blode-icons-react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuCheckboxItem,\n  DropdownMenuContent,\n  DropdownMenuGroup,\n  DropdownMenuItem,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Field, FieldError } from \"@/components/ui/field\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { TimePicker } from \"@/components/ui/time-picker\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/components/ui/tooltip\";\n\n/** A single opening interval, as 24-hour `\"HH:mm\"` strings. */\ninterface TimeRange {\n  /** Exclusive end of the interval. Must be later in the day than `start`. */\n  end: string;\n  /** Inclusive start of the interval. */\n  start: string;\n}\n\n/** Days of the week, keyed the way `Date.prototype.getDay()` numbers them. */\ntype Weekday = \"friday\" | \"monday\" | \"saturday\" | \"sunday\" | \"thursday\" | \"tuesday\" | \"wednesday\";\n\n/** Opening hours for a whole week. An empty array means the day is closed. */\ntype WeeklyHoursValue = Record<Weekday, TimeRange[]>;\n\n/** Every user-visible and assistive-technology string the component renders. */\ninterface WeeklyHoursLabels {\n  /** Accessible name of the add-range button. */\n  addRange: (day: string) => string;\n  /** Tooltip on the add-range button. Must be a substring of `addRange`. */\n  addRangeShort: string;\n  /** Action that copies the day's ranges onto the checked days. */\n  copyApply: string;\n  /** Accessible name of the copy-to-other-days trigger. Must contain `copyToHeading`. */\n  copyTo: (day: string) => string;\n  /**\n   * Heading inside the copy-to-other-days menu, and the tooltip on its trigger.\n   * Must be a substring of `copyTo`, so the visible text is contained in the\n   * accessible name.\n   */\n  copyToHeading: string;\n  /** Display name for each weekday. */\n  days: Record<Weekday, string>;\n  /** Error shown when a range ends at or before it starts. */\n  endBeforeStart: string;\n  /** Accessible name of the end-time picker. */\n  endTime: (day: string) => string;\n  /** Placeholder shown on a day with no ranges. */\n  noHours: string;\n  /** Error shown when two ranges on the same day overlap. */\n  overlap: string;\n  /** Accessible name of the remove-range button. */\n  removeRange: (day: string, start: string, end: string) => string;\n  /** Tooltip on the remove-range button. Must be a substring of `removeRange`. */\n  removeRangeShort: string;\n  /** Toggles every target day in the copy menu at once. */\n  selectAll: string;\n  /** Accessible name of the start-time picker. */\n  startTime: (day: string) => string;\n}\n\ninterface WeeklyHoursProps extends Omit<React.ComponentProps<\"div\">, \"onChange\"> {\n  /** Disables every control. */\n  disabled?: boolean;\n  /**\n   * Force a 12- or 24-hour display on the time pickers. Defaults to whatever\n   * the locale itself prefers, so an `en-GB` user is not shown AM/PM.\n   */\n  hour12?: boolean;\n  /** Overrides for the built-in English strings. */\n  labels?: Partial<WeeklyHoursLabels>;\n  /**\n   * BCP 47 locale the time pickers display in. Defaults to `navigator.language`.\n   * Pass it explicitly when the component is server-rendered, so the server and\n   * the client format the same string.\n   */\n  locale?: string;\n  /** Caps how many ranges a single day may hold. Defaults to 4. */\n  maxRangesPerDay?: number;\n  /** Called with the next week whenever the user edits it. */\n  onValueChange: (value: WeeklyHoursValue) => void;\n  /** Granularity of the time pickers, in minutes. Defaults to 15. */\n  step?: number;\n  /** The controlled week. */\n  value: WeeklyHoursValue;\n  /** First column of the week, numbered like `getDay()`. Defaults to 1 (Monday). */\n  weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;\n}\n\nconst MINUTES_IN_DAY = 24 * 60;\nconst LAST_MINUTE_OF_DAY = MINUTES_IN_DAY - 1;\nconst DEFAULT_RANGE: TimeRange = { end: \"17:00\", start: \"09:00\" };\nconst DEFAULT_RANGE_LENGTH = 60;\n\n/** Indexed to match `Date.prototype.getDay()`. */\nconst WEEKDAYS: Weekday[] = [\n  \"sunday\",\n  \"monday\",\n  \"tuesday\",\n  \"wednesday\",\n  \"thursday\",\n  \"friday\",\n  \"saturday\",\n];\n\n/**\n * Returns null rather than NaN for anything unparseable. NaN would make every\n * comparison below false, so a malformed range would silently read as valid.\n */\nconst toMinutes = (time: string): number | null => {\n  const match = /^(?<hours>\\d{1,2}):(?<minutes>\\d{2})$/u.exec(time.trim());\n  if (!match?.groups) {\n    return null;\n  }\n  const hours = Number(match.groups.hours);\n  const minutes = Number(match.groups.minutes);\n  if (hours > 23 || minutes > 59) {\n    return null;\n  }\n  return hours * 60 + minutes;\n};\n\nconst toTime = (minutes: number) => {\n  const clamped = Math.max(0, Math.min(LAST_MINUTE_OF_DAY, Math.round(minutes)));\n  const hours = Math.floor(clamped / 60);\n  return `${String(hours).padStart(2, \"0\")}:${String(clamped % 60).padStart(2, \"0\")}`;\n};\n\n/** Locale-independent so the server and client render the same aria-label. */\nconst formatTime = (time: string) => {\n  const total = toMinutes(time);\n  if (total === null) {\n    return time;\n  }\n  const hours = Math.floor(total / 60);\n  const hour12 = hours % 12 === 0 ? 12 : hours % 12;\n  const minutes = String(total % 60).padStart(2, \"0\");\n  return `${hour12}:${minutes} ${hours < 12 ? \"AM\" : \"PM\"}`;\n};\n\nconst DEFAULT_LABELS: WeeklyHoursLabels = {\n  addRange: (day) => `Add hours for ${day}`,\n  addRangeShort: \"Add hours\",\n  copyApply: \"Copy hours\",\n  copyTo: (day) => `Copy times to other days from ${day}`,\n  copyToHeading: \"Copy times to\",\n  days: {\n    friday: \"Friday\",\n    monday: \"Monday\",\n    saturday: \"Saturday\",\n    sunday: \"Sunday\",\n    thursday: \"Thursday\",\n    tuesday: \"Tuesday\",\n    wednesday: \"Wednesday\",\n  },\n  endBeforeStart: \"End time must be later in the day than the start time.\",\n  endTime: (day) => `${day} end time`,\n  noHours: \"Closed\",\n  overlap: \"These hours overlap another range on the same day.\",\n  removeRange: (day, start, end) => `Remove ${formatTime(start)} to ${formatTime(end)} on ${day}`,\n  removeRangeShort: \"Remove\",\n  selectAll: \"Select all\",\n  startTime: (day) => `${day} start time`,\n};\n\n/**\n * Presentational validation only — never mutates the caller's value. Ranges are\n * treated as same-day intervals, so anything that would cross midnight reads as\n * an end-before-start error rather than wrapping around.\n */\nconst getRangeErrors = (ranges: TimeRange[], labels: WeeklyHoursLabels) => {\n  const errors: (string | undefined)[] = Array.from({ length: ranges.length });\n\n  for (const [index, range] of ranges.entries()) {\n    const start = toMinutes(range.start);\n    const end = toMinutes(range.end);\n    if (start === null || end === null || end <= start) {\n      errors[index] = labels.endBeforeStart;\n    }\n  }\n\n  for (let i = 0; i < ranges.length; i += 1) {\n    for (let j = i + 1; j < ranges.length; j += 1) {\n      if (errors[i] === labels.endBeforeStart || errors[j] === labels.endBeforeStart) {\n        continue;\n      }\n      const aStart = toMinutes(ranges[i].start);\n      const aEnd = toMinutes(ranges[i].end);\n      const bStart = toMinutes(ranges[j].start);\n      const bEnd = toMinutes(ranges[j].end);\n      if (aStart === null || aEnd === null || bStart === null || bEnd === null) {\n        continue;\n      }\n      if (aStart < bEnd && bStart < aEnd) {\n        // Flag the range that starts later, so the earlier one still reads as settled.\n        errors[aStart <= bStart ? j : i] = labels.overlap;\n      }\n    }\n  }\n\n  return errors;\n};\n\n/**\n * Earliest time the end picker may offer, so a range that ends at or before it\n * starts is unrepresentable through the UI. Returns undefined near midnight,\n * where there is no later slot to constrain to. `getRangeErrors` still runs:\n * a caller's value can be anything, and this only narrows the picker.\n *\n * Snaps up to the next slot on the step grid rather than adding `step` to the\n * start. A start that sits off the grid — a time saved before `step` changed —\n * would otherwise shift every option off it too, leaving nothing clean to pick.\n */\nconst minEnd = (start: string, step: number): string | undefined => {\n  const startMinutes = toMinutes(start);\n  if (startMinutes === null) {\n    return undefined;\n  }\n  const interval = Math.max(1, Math.round(step));\n  const next = Math.floor(startMinutes / interval) * interval + interval;\n  if (next > LAST_MINUTE_OF_DAY) {\n    return undefined;\n  }\n  return toTime(next);\n};\n\n/** Next sensible range: an hour after the last one ends, clamped to the day. */\nconst nextRange = (ranges: TimeRange[]): TimeRange | null => {\n  const last = ranges.at(-1);\n  if (!last) {\n    return { ...DEFAULT_RANGE };\n  }\n  const start = toMinutes(last.end);\n  if (start === null || start >= LAST_MINUTE_OF_DAY) {\n    return null;\n  }\n  return { end: toTime(start + DEFAULT_RANGE_LENGTH), start: toTime(start) };\n};\n\ninterface WeeklyHoursDayProps {\n  day: Weekday;\n  disabled?: boolean;\n  hour12?: boolean;\n  labels: WeeklyHoursLabels;\n  locale?: string;\n  maxRangesPerDay: number;\n  onCopy: (targets: Weekday[]) => void;\n  onRangesChange: (ranges: TimeRange[]) => void;\n  orderedDays: Weekday[];\n  ranges: TimeRange[];\n  step: number;\n}\n\nconst WeeklyHoursDay = ({\n  day,\n  disabled,\n  hour12,\n  labels,\n  locale,\n  maxRangesPerDay,\n  onCopy,\n  onRangesChange,\n  orderedDays,\n  ranges,\n  step,\n}: WeeklyHoursDayProps) => {\n  const groupId = React.useId();\n  const fieldId = React.useId();\n  const addRef = React.useRef<HTMLButtonElement>(null);\n  const switchRef = React.useRef<HTMLButtonElement>(null);\n  const [copyOpen, setCopyOpen] = React.useState(false);\n  const [copyTargets, setCopyTargets] = React.useState<Weekday[]>([]);\n\n  const dayLabel = labels.days[day];\n  const isOpen = ranges.length > 0;\n  const errors = getRangeErrors(ranges, labels);\n  const candidate = nextRange(ranges);\n  const canAdd = !disabled && ranges.length < maxRangesPerDay && candidate !== null;\n  const targetDays = orderedDays.filter((other) => other !== day);\n\n  const handleToggle = (checked: boolean) => {\n    onRangesChange(checked ? [{ ...DEFAULT_RANGE }] : []);\n  };\n\n  const handleRangeChange = (index: number, patch: Partial<TimeRange>) => {\n    onRangesChange(ranges.map((range, i) => (i === index ? { ...range, ...patch } : range)));\n  };\n\n  const handleAdd = () => {\n    if (!candidate) {\n      return;\n    }\n    onRangesChange([...ranges, candidate]);\n  };\n\n  const handleRemove = (index: number) => {\n    const next = ranges.filter((_, i) => i !== index);\n    onRangesChange(next);\n    // Removing the row that held focus would otherwise drop it on <body>.\n    requestAnimationFrame(() => {\n      if (next.length > 0) {\n        addRef.current?.focus();\n      } else {\n        switchRef.current?.focus();\n      }\n    });\n  };\n\n  const handleApplyCopy = () => {\n    if (copyTargets.length > 0) {\n      onCopy(copyTargets);\n    }\n    setCopyOpen(false);\n  };\n\n  return (\n    <fieldset\n      className=\"flex min-w-0 flex-col gap-3 border-border border-b py-4 last:border-b-0 @min-[34rem]:flex-row @min-[34rem]:flex-wrap\"\n      data-slot=\"weekly-hours-day\"\n    >\n      <legend className=\"sr-only\">{dayLabel}</legend>\n      <div className=\"flex shrink-0 items-center gap-3 @min-[34rem]:h-[var(--field-height-sm)] @min-[34rem]:w-32\">\n        <Switch\n          aria-labelledby={groupId}\n          checked={isOpen}\n          disabled={disabled}\n          onCheckedChange={handleToggle}\n          ref={switchRef}\n        />\n        <span className=\"font-medium text-sm\" id={groupId}>\n          {dayLabel}\n        </span>\n      </div>\n\n      {isOpen ? (\n        <div className=\"flex min-w-0 flex-col gap-2 @min-[34rem]:min-w-fit @min-[34rem]:flex-1\">\n          {ranges.map((range, index) => (\n            <Field\n              aria-invalid={Boolean(errors[index])}\n              className=\"min-w-0 gap-1.5\"\n              data-invalid={!!errors[index]}\n              data-slot=\"weekly-hours-range\"\n              // Ranges are positional and two rows can hold identical times,\n              // so the index is the only stable identity available here.\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional rows\n              key={index}\n            >\n              <div className=\"flex min-w-0 items-center gap-1.5 tabular-figures\">\n                <label className=\"sr-only\" htmlFor={`${fieldId}-${index}-start`}>\n                  {labels.startTime(dayLabel)}\n                </label>\n                <TimePicker\n                  className=\"flex-1 whitespace-nowrap pl-3\"\n                  size=\"sm\"\n                  disabled={disabled}\n                  hour12={hour12}\n                  id={`${fieldId}-${index}-start`}\n                  locale={locale}\n                  onValueChange={(start) => handleRangeChange(index, { start })}\n                  step={step}\n                  value={range.start}\n                />\n                <span\n                  aria-hidden=\"true\"\n                  className=\"h-0.5 w-1.5 shrink-0 rounded-full bg-muted-foreground\"\n                />\n                <label className=\"sr-only\" htmlFor={`${fieldId}-${index}-end`}>\n                  {labels.endTime(dayLabel)}\n                </label>\n                <TimePicker\n                  className=\"flex-1 whitespace-nowrap pl-3\"\n                  size=\"sm\"\n                  disabled={disabled}\n                  hour12={hour12}\n                  id={`${fieldId}-${index}-end`}\n                  locale={locale}\n                  min={minEnd(range.start, step)}\n                  onValueChange={(end) => handleRangeChange(index, { end })}\n                  step={step}\n                  value={range.end}\n                />\n                <Tooltip>\n                  <TooltipTrigger\n                    render={\n                      <Button\n                        aria-label={labels.removeRange(dayLabel, range.start, range.end)}\n                        className=\"shrink-0\"\n                        disabled={disabled}\n                        onClick={() => handleRemove(index)}\n                        size=\"icon\"\n                        type=\"button\"\n                        variant=\"ghost\"\n                      />\n                    }\n                  >\n                    <XIcon />\n                  </TooltipTrigger>\n                  <TooltipContent>{labels.removeRangeShort}</TooltipContent>\n                </Tooltip>\n              </div>\n              {errors[index] && <FieldError>{errors[index]}</FieldError>}\n            </Field>\n          ))}\n        </div>\n      ) : (\n        <div className=\"flex min-w-0 items-center @min-[34rem]:h-[var(--field-height-sm)]\">\n          <span className=\"text-muted-foreground text-sm\">{labels.noHours}</span>\n        </div>\n      )}\n\n      <div className=\"flex shrink-0 items-start gap-2 @min-[34rem]:ms-auto @min-[34rem]:h-[var(--field-height-sm)] @min-[34rem]:items-center\">\n        <Tooltip>\n          <TooltipTrigger\n            render={\n              <Button\n                aria-label={labels.addRange(dayLabel)}\n                disabled={!canAdd}\n                onClick={handleAdd}\n                ref={addRef}\n                size=\"icon\"\n                type=\"button\"\n                variant=\"outline\"\n              />\n            }\n          >\n            <PlusIcon />\n          </TooltipTrigger>\n          <TooltipContent>{labels.addRangeShort}</TooltipContent>\n        </Tooltip>\n\n        <DropdownMenu\n          onOpenChange={(open) => {\n            setCopyOpen(open);\n            if (!open) {\n              setCopyTargets([]);\n            }\n          }}\n          open={copyOpen}\n        >\n          <Tooltip>\n            <DropdownMenuTrigger\n              render={\n                <TooltipTrigger\n                  render={\n                    <Button\n                      aria-label={labels.copyTo(dayLabel)}\n                      disabled={disabled || !isOpen}\n                      size=\"icon\"\n                      type=\"button\"\n                      variant=\"outline\"\n                    />\n                  }\n                />\n              }\n            >\n              <SquareBehindSquare1Icon />\n            </DropdownMenuTrigger>\n            <TooltipContent>{labels.copyToHeading}</TooltipContent>\n          </Tooltip>\n          <DropdownMenuContent align=\"end\" className=\"w-auto min-w-56\">\n            {/* Base UI throws if a GroupLabel has no Group ancestor. */}\n            <DropdownMenuGroup>\n              <DropdownMenuLabel>{labels.copyToHeading}</DropdownMenuLabel>\n              <DropdownMenuCheckboxItem\n                checked={copyTargets.length === targetDays.length}\n                closeOnClick={false}\n                onCheckedChange={(checked) => setCopyTargets(checked ? targetDays : [])}\n              >\n                {labels.selectAll}\n              </DropdownMenuCheckboxItem>\n              <DropdownMenuSeparator />\n              {targetDays.map((target) => (\n                <DropdownMenuCheckboxItem\n                  checked={copyTargets.includes(target)}\n                  closeOnClick={false}\n                  key={target}\n                  onCheckedChange={(checked) =>\n                    setCopyTargets((current) =>\n                      checked\n                        ? [...current, target]\n                        : current.filter((selected) => selected !== target),\n                    )\n                  }\n                >\n                  {labels.days[target]}\n                </DropdownMenuCheckboxItem>\n              ))}\n            </DropdownMenuGroup>\n            <DropdownMenuSeparator />\n            <DropdownMenuItem disabled={copyTargets.length === 0} onClick={handleApplyCopy}>\n              {labels.copyApply}\n            </DropdownMenuItem>\n          </DropdownMenuContent>\n        </DropdownMenu>\n      </div>\n    </fieldset>\n  );\n};\n\nconst WeeklyHours = ({\n  className,\n  disabled,\n  hour12,\n  labels: labelOverrides,\n  locale,\n  maxRangesPerDay = 4,\n  onValueChange,\n  step = 15,\n  value,\n  weekStartsOn = 1,\n  ...props\n}: WeeklyHoursProps) => {\n  const labels = React.useMemo<WeeklyHoursLabels>(\n    () => ({\n      ...DEFAULT_LABELS,\n      ...labelOverrides,\n      days: { ...DEFAULT_LABELS.days, ...labelOverrides?.days },\n    }),\n    [labelOverrides],\n  );\n\n  const orderedDays = React.useMemo(\n    () => Array.from({ length: 7 }, (_, i) => WEEKDAYS[(i + weekStartsOn) % 7]),\n    [weekStartsOn],\n  );\n\n  return (\n    <div className={cn(\"@container flex flex-col\", className)} data-slot=\"weekly-hours\" {...props}>\n      {orderedDays.map((day) => (\n        <WeeklyHoursDay\n          day={day}\n          disabled={disabled}\n          hour12={hour12}\n          key={day}\n          labels={labels}\n          locale={locale}\n          maxRangesPerDay={maxRangesPerDay}\n          onCopy={(targets) => {\n            const next = { ...value };\n            for (const target of targets) {\n              next[target] = value[day].map((range) => ({ ...range }));\n            }\n            onValueChange(next);\n          }}\n          onRangesChange={(ranges) => onValueChange({ ...value, [day]: ranges })}\n          orderedDays={orderedDays}\n          ranges={value[day]}\n          step={step}\n        />\n      ))}\n    </div>\n  );\n};\n\nexport { WeeklyHours };\nexport type { TimeRange, Weekday, WeeklyHoursLabels, WeeklyHoursProps, WeeklyHoursValue };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
