{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-dial",
  "title": "Time Dial",
  "description": "The Material clock dial, and the dialog that pairs it with text entry.",
  "registryDependencies": [
    "https://guillermo-rebolledo.github.io/materialcn/r/button.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/dialog.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/field.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/materialcn-theme.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/time-picker.json",
    "https://guillermo-rebolledo.github.io/materialcn/r/utils.json"
  ],
  "files": [
    {
      "path": "src/components/ui/time-dial.tsx",
      "content": "import { useState, type KeyboardEvent } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"./button\"\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n  DialogTrigger,\n} from \"./dialog\"\nimport { FieldError } from \"./field\"\nimport { type TimeMode, type TimeValue } from \"./time-picker\"\nimport { formatTime, isValidTime } from \"./time-picker-utils\"\n\ntype TimeDialProps = {\n  disabled?: boolean\n  mode?: TimeMode\n  onValueChange: (value: TimeValue) => void\n  phase?: \"hour\" | \"minute\"\n  value: TimeValue\n}\n\nfunction TimeDial({\n  disabled = false,\n  mode = \"12-hour\",\n  onValueChange,\n  phase: phaseProp,\n  value,\n}: TimeDialProps) {\n  const [internalPhase, setInternalPhase] = useState<\"hour\" | \"minute\">(\"hour\")\n  const phase = phaseProp ?? internalPhase\n  const hourValues = mode === \"24-hour\"\n    ? Array.from({ length: 24 }, (_, index) => index)\n    : Array.from({ length: 12 }, (_, index) => index + 1)\n  const values = phase === \"hour\"\n    ? hourValues\n    : Array.from({ length: 12 }, (_, index) => index * 5)\n  const selected = phase === \"hour\"\n    ? mode === \"12-hour\" ? value.hour % 12 || 12 : value.hour\n    : Math.round(value.minute / 5) * 5 % 60\n\n  const choose = (next: number) => {\n    if (disabled) return\n    if (phase === \"hour\") {\n      const hour = mode === \"12-hour\"\n        ? (next % 12) + (value.hour >= 12 ? 12 : 0)\n        : next\n      onValueChange({ ...value, hour })\n      if (!phaseProp) setInternalPhase(\"minute\")\n    } else {\n      onValueChange({ ...value, minute: next })\n    }\n  }\n\n  const move = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    if (![\"ArrowRight\", \"ArrowDown\", \"ArrowLeft\", \"ArrowUp\"].includes(event.key)) return\n    event.preventDefault()\n    const delta = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : -1\n    const buttons = event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[data-slot=\"time-dial-value\"]')\n    buttons?.[(index + delta + values.length) % values.length]?.focus()\n  }\n\n  return (\n    <div data-slot=\"time-dial\" data-phase={phase} className=\"flex flex-col items-center gap-3\">\n      {/* Kit dial inputs: 96 × 80, 8dp corners, display-large; the active\n          segment is Primary Container. The separator is a fixed 24dp column. */}\n      <div className=\"flex items-center gap-0\" aria-label=\"Time segments\">\n        <Button\n          disabled={disabled}\n          aria-pressed={phase === \"hour\"}\n          variant={phase === \"hour\" ? \"tonal\" : \"outline\"}\n          className={cn(\n            \"h-20 w-24 rounded-m3-sm px-0 text-m3-display-lg font-m3-regular active:not-disabled:rounded-m3-sm\",\n            phase === \"hour\" ? \"bg-m3-primary-container text-m3-on-primary-container\" : \"border-transparent bg-m3-surface-container-highest text-m3-on-surface\",\n          )}\n          onClick={() => !phaseProp && setInternalPhase(\"hour\")}\n        >\n          {String(mode === \"12-hour\" ? value.hour % 12 || 12 : value.hour).padStart(2, \"0\")}\n        </Button>\n        <span aria-hidden=\"true\" className=\"flex w-6 justify-center text-m3-display-lg\">:</span>\n        <Button\n          disabled={disabled}\n          aria-pressed={phase === \"minute\"}\n          variant={phase === \"minute\" ? \"tonal\" : \"outline\"}\n          className={cn(\n            \"h-20 w-24 rounded-m3-sm px-0 text-m3-display-lg font-m3-regular active:not-disabled:rounded-m3-sm\",\n            phase === \"minute\" ? \"bg-m3-primary-container text-m3-on-primary-container\" : \"border-transparent bg-m3-surface-container-highest text-m3-on-surface\",\n          )}\n          onClick={() => !phaseProp && setInternalPhase(\"minute\")}\n        >\n          {String(value.minute).padStart(2, \"0\")}\n        </Button>\n        {mode === \"12-hour\" && (\n          <div className=\"ml-3 flex w-13 flex-col overflow-hidden rounded-m3-sm border border-m3-outline\" aria-label=\"Period\">\n            {([\"AM\", \"PM\"] as const).map((period) => {\n              const pressed = period === (value.hour >= 12 ? \"PM\" : \"AM\")\n              return (\n                <button\n                  key={period}\n                  type=\"button\"\n                  aria-pressed={pressed}\n                  disabled={disabled}\n                  // Kit: two 40dp halves, title-medium, Tertiary Container when selected.\n                  className=\"h-10 text-m3-title-md text-m3-on-surface-variant outline-none transition-colors duration-(--m3-spring-effects-fast-duration) ease-(--m3-spring-effects-fast) first:border-b first:border-m3-outline hover:bg-m3-on-surface/8 focus-visible:ring-3 focus-visible:ring-inset focus-visible:ring-m3-secondary aria-pressed:bg-m3-tertiary-container aria-pressed:text-m3-on-tertiary-container disabled:text-m3-on-surface/38\"\n                  onClick={() => onValueChange({ ...value, hour: (value.hour % 12) + (period === \"PM\" ? 12 : 0) })}\n                >\n                  {period}\n                </button>\n              )\n            })}\n          </div>\n        )}\n      </div>\n      <div\n        role=\"group\"\n        aria-label={phase === \"hour\" ? \"Choose hour\" : \"Choose minute\"}\n        className=\"relative size-64 touch-none rounded-full bg-m3-surface-container-highest\"\n      >\n        <div aria-hidden=\"true\" className=\"absolute top-1/2 left-1/2 size-2 -translate-1/2 rounded-full bg-m3-primary\" />\n        {/* Clock hand: a 2dp Primary line from the centre to the selected numeral. */}\n        {(() => {\n          const index = values.indexOf(selected)\n          if (index < 0) return null\n          const clockPosition = phase === \"minute\" ? index : selected % 12\n          const inner = phase === \"hour\" && mode === \"24-hour\" && (selected === 0 || selected > 12)\n          const length = inner ? 72 : 104\n          return (\n            <div\n              aria-hidden=\"true\"\n              data-slot=\"time-dial-hand\"\n              className=\"absolute top-1/2 left-1/2 h-0.5 origin-left bg-m3-primary transition-[transform,width] duration-(--m3-spring-effects-fast-duration) ease-(--m3-spring-effects-fast) motion-reduce:transition-none\"\n              style={{ width: length, transform: `translateY(-50%) rotate(${clockPosition * 30 - 90}deg)` }}\n            />\n          )\n        })()}\n        {values.map((number, index) => {\n          const clockPosition = phase === \"minute\" ? index : number % 12\n          const angle = (clockPosition * 30 - 90) * Math.PI / 180\n          const radius = phase === \"hour\" && mode === \"24-hour\" && (number === 0 || number > 12) ? 72 : 104\n          const x = Math.cos(angle) * radius\n          const y = Math.sin(angle) * radius\n          const active = number === selected\n          return (\n            <button\n              key={number}\n              type=\"button\"\n              data-slot=\"time-dial-value\"\n              aria-label={`${number} ${phase === \"hour\" ? \"hours\" : \"minutes\"}`}\n              aria-pressed={active}\n              disabled={disabled}\n              tabIndex={active ? 0 : -1}\n              className={cn(\n                // Kit numerals: 48dp targets, body-large.\n                \"absolute flex size-12 -translate-1/2 items-center justify-center rounded-full text-m3-body-lg outline-none\",\n                \"transition-colors duration-(--m3-spring-effects-fast-duration) ease-(--m3-spring-effects-fast) motion-reduce:transition-none\",\n                \"hover:bg-m3-on-surface/8 focus-visible:ring-3 focus-visible:ring-m3-secondary\",\n                active && \"bg-m3-primary text-m3-on-primary\",\n              )}\n              style={{ left: `calc(50% + ${x}px)`, top: `calc(50% + ${y}px)` }}\n              onClick={() => choose(number)}\n              onKeyDown={(event) => move(event, index)}\n            >\n              {number.toString().padStart(phase === \"minute\" ? 2 : 1, \"0\")}\n            </button>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n\ntype DialTimePickerProps = {\n  defaultOpen?: boolean\n  disabled?: boolean\n  error?: string\n  invalid?: boolean\n  label: string\n  max?: TimeValue\n  min?: TimeValue\n  mode?: TimeMode\n  onValueChange: (value: TimeValue) => void\n  value: TimeValue\n}\n\nfunction DialTimePicker({\n  defaultOpen,\n  disabled,\n  error,\n  invalid,\n  label,\n  max,\n  min,\n  mode = \"12-hour\",\n  onValueChange,\n  value,\n}: DialTimePickerProps) {\n  const [draft, setDraft] = useState(value)\n  const valueInvalid = invalid || !isValidTime(draft, min, max)\n  return (\n    <Dialog\n      defaultOpen={defaultOpen}\n      onOpenChange={(open) => {\n        if (open) setDraft(value)\n      }}\n    >\n      <DialogTrigger aria-label={`Choose ${label}`} disabled={disabled} render={<Button variant=\"outline\" />}>\n        {value && isValidTime(value) ? formatTime(value, mode) : `Choose ${label}`}\n      </DialogTrigger>\n      <DialogContent showCloseButton={false} className=\"w-[328px] max-w-[calc(100%-2rem)] gap-4\">\n        <DialogHeader>\n          <DialogTitle>{label}</DialogTitle>\n          <DialogDescription>Choose the hour and minute on the clock dial.</DialogDescription>\n        </DialogHeader>\n        <TimeDial value={draft} onValueChange={setDraft} mode={mode} disabled={disabled} />\n        {valueInvalid && <FieldError>{error ?? \"Choose a time within the allowed range\"}</FieldError>}\n        <DialogFooter>\n          <DialogClose render={<Button variant=\"ghost\">Cancel</Button>} />\n          <DialogClose\n            render={<Button />}\n            aria-label=\"Confirm time\"\n            disabled={valueInvalid}\n            onClick={() => onValueChange(draft)}\n          >\n            Confirm\n          </DialogClose>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nexport { DialTimePicker, TimeDial, type DialTimePickerProps, type TimeDialProps }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}