{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timezone-picker",
  "title": "Timezone Picker",
  "description": "Searchable IANA time zone select showing each zone's UTC offset and current local time.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "popover",
    "@drift-ui/use-controllable-state",
    "@drift-ui/shape"
  ],
  "files": [
    {
      "path": "registry/default/ui/timezone-picker.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, ChevronsUpDownIcon } from \"lucide-react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  filterTimezones,\n  formatTimezoneLabel,\n  formatTimezoneTime,\n  getTimezoneOffset,\n  isValidTimeZone,\n  listTimezones,\n  toCurrentTimezoneName,\n} from \"@/registry/default/lib/timezone-picker.ts\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/registry/default/ui/popover\"\n\nfunction scrollOptionIntoList(option: HTMLElement | null) {\n  option?.scrollIntoView({ block: \"nearest\" })\n}\n\nfunction TimezonePicker({\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  timeZones,\n  placeholder = \"Select a time zone\",\n  disabled,\n  className,\n  ...props\n}: Omit<\n  React.ComponentProps<\"button\">,\n  \"value\" | \"defaultValue\" | \"children\"\n> & {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  timeZones?: string[]\n  placeholder?: string\n}) {\n  const listId = React.useId()\n  const { value, setValue } = useControllableState({\n    value: valueProp,\n    defaultValue,\n    onChange: onValueChange,\n  })\n  const renamed = toCurrentTimezoneName(value)\n  // An unknown name shows the placeholder instead of crashing the render.\n  const current = renamed && isValidTimeZone(renamed) ? renamed : \"\"\n  const [isOpen, setIsOpen] = React.useState(false)\n  const [query, setQuery] = React.useState(\"\")\n  // null means \"the selected zone\", resolved against the current list.\n  const [highlighted, setHighlighted] = React.useState<number | null>(null)\n  // Read once per open, so offsets and times match the moment it opened.\n  const [openedAt, setOpenedAt] = React.useState<Date | null>(null)\n\n  const options = React.useMemo(\n    () => (openedAt ? listTimezones(openedAt, timeZones) : []),\n    [openedAt, timeZones]\n  )\n  const matches = filterTimezones(options, query)\n  const highlightedIndex = Math.min(\n    highlighted ??\n      Math.max(\n        0,\n        matches.findIndex((option) => option.value === current)\n      ),\n    Math.max(0, matches.length - 1)\n  )\n\n  function handleOpenChange(nextOpen: boolean) {\n    setIsOpen(nextOpen)\n    if (!nextOpen) return\n    setOpenedAt(new Date())\n    setQuery(\"\")\n    setHighlighted(null)\n  }\n\n  function select(next: string) {\n    setValue(next)\n    setIsOpen(false)\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n    // Leave keys that confirm an IME composition to the IME.\n    if (event.nativeEvent.isComposing || event.keyCode === 229) return\n    switch (event.key) {\n      case \"ArrowDown\":\n      case \"ArrowUp\": {\n        event.preventDefault()\n        if (matches.length === 0) return\n        const delta = event.key === \"ArrowDown\" ? 1 : -1\n        setHighlighted(\n          (highlightedIndex + delta + matches.length) % matches.length\n        )\n        return\n      }\n      case \"Enter\": {\n        event.preventDefault()\n        const match = matches[highlightedIndex]\n        if (match) select(match.value)\n        return\n      }\n    }\n  }\n\n  return (\n    <Popover open={isOpen} onOpenChange={handleOpenChange}>\n      <PopoverTrigger\n        type=\"button\"\n        role=\"combobox\"\n        aria-expanded={isOpen}\n        aria-haspopup=\"listbox\"\n        aria-controls={isOpen ? listId : undefined}\n        disabled={disabled}\n        data-slot=\"timezone-picker\"\n        className={cn(\n          \"flex h-control w-full min-w-0 items-center gap-2 rounded-control border border-input bg-transparent px-3 text-left text-sm shadow-control outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30\",\n          className\n        )}\n        {...props}\n      >\n        {current ? (\n          <>\n            <span className=\"min-w-0 flex-1 truncate\">\n              {formatTimezoneLabel(current)}\n            </span>\n            <span className=\"text-muted-foreground tabular-nums\">\n              {getTimezoneOffset(current, new Date())}\n            </span>\n          </>\n        ) : (\n          <span className=\"flex-1 text-muted-foreground\">{placeholder}</span>\n        )}\n        <ChevronsUpDownIcon className=\"size-4 shrink-0 opacity-50\" />\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        aria-label=\"Choose a time zone\"\n        className=\"flex w-80 flex-col gap-1 p-1\"\n      >\n        <input\n          type=\"text\"\n          role=\"combobox\"\n          data-slot=\"timezone-picker-search\"\n          aria-label=\"Search time zones\"\n          aria-autocomplete=\"list\"\n          aria-expanded=\"true\"\n          aria-controls={listId}\n          aria-activedescendant={\n            matches.length > 0 ? `${listId}-${highlightedIndex}` : undefined\n          }\n          value={query}\n          placeholder=\"Search time zones\"\n          onChange={(event) => {\n            setQuery(event.target.value)\n            setHighlighted(0)\n          }}\n          onKeyDown={handleKeyDown}\n          className=\"h-8 w-full rounded-sm bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground\"\n        />\n        <div\n          id={listId}\n          role=\"listbox\"\n          aria-label=\"Time zones\"\n          data-slot=\"timezone-picker-list\"\n          className=\"relative max-h-64 overflow-y-auto\"\n        >\n          {openedAt &&\n            matches.map((option, index) => (\n              <div\n                key={option.value}\n                ref={\n                  index === highlightedIndex ? scrollOptionIntoList : undefined\n                }\n                id={`${listId}-${index}`}\n                role=\"option\"\n                aria-selected={option.value === current}\n                data-highlighted={index === highlightedIndex ? \"\" : undefined}\n                onPointerMove={() => setHighlighted(index)}\n                onClick={() => select(option.value)}\n                className=\"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground\"\n              >\n                <span className=\"min-w-0 flex-1 truncate\">{option.label}</span>\n                <span className=\"text-xs text-muted-foreground tabular-nums\">\n                  {option.offset} · {formatTimezoneTime(option.value, openedAt)}\n                </span>\n                {option.value === current && (\n                  <CheckIcon className=\"size-4 shrink-0\" />\n                )}\n              </div>\n            ))}\n        </div>\n        {matches.length === 0 && (\n          <p\n            role=\"status\"\n            className=\"px-2 py-1.5 text-sm text-muted-foreground\"\n          >\n            No time zone found\n          </p>\n        )}\n      </PopoverContent>\n    </Popover>\n  )\n}\n\nexport { TimezonePicker }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/timezone-picker.ts",
      "content": "export interface TimezoneOption {\n  value: string\n  label: string\n  offset: string\n  offsetMinutes: number\n}\n\n// V8 lists these under old names that nobody searches for. Every engine accepts the new ones.\nconst RENAMED_ZONES: Record<string, string> = {\n  \"America/Buenos_Aires\": \"America/Argentina/Buenos_Aires\",\n  \"America/Godthab\": \"America/Nuuk\",\n  \"Asia/Calcutta\": \"Asia/Kolkata\",\n  \"Asia/Katmandu\": \"Asia/Kathmandu\",\n  \"Asia/Rangoon\": \"Asia/Yangon\",\n  \"Asia/Saigon\": \"Asia/Ho_Chi_Minh\",\n  \"Atlantic/Faeroe\": \"Atlantic/Faroe\",\n  \"Europe/Kiev\": \"Europe/Kyiv\",\n  \"Pacific/Enderbury\": \"Pacific/Kanton\",\n  \"Pacific/Ponape\": \"Pacific/Pohnpei\",\n  \"Pacific/Truk\": \"Pacific/Chuuk\",\n}\n\nconst validity = new Map<string, boolean>()\n\n// Intl throws a RangeError for unknown names, so check before formatting.\nexport function isValidTimeZone(timeZone: string) {\n  let valid = validity.get(timeZone)\n  if (valid === undefined) {\n    try {\n      new Intl.DateTimeFormat(\"en-US\", { timeZone })\n      valid = true\n    } catch {\n      valid = false\n    }\n    validity.set(timeZone, valid)\n  }\n  return valid\n}\n\nexport function toCurrentTimezoneName(timeZone: string) {\n  return RENAMED_ZONES[timeZone] ?? timeZone\n}\n\nexport function getTimezoneIds(): string[] {\n  const ids = [\n    ...new Set(Intl.supportedValuesOf(\"timeZone\").map(toCurrentTimezoneName)),\n  ]\n  // Some engines leave UTC out of the list.\n  return ids.includes(\"UTC\") ? ids : [\"UTC\", ...ids]\n}\n\nexport function formatTimezoneLabel(timeZone: string) {\n  return timeZone.replaceAll(\"_\", \" \").replaceAll(\"/\", \" / \")\n}\n\n// Building a DateTimeFormat is the slow part, so keep one per zone.\nconst offsetFormatters = new Map<string, Intl.DateTimeFormat>()\nconst timeFormatters = new Map<string, Intl.DateTimeFormat>()\n\nfunction cached(\n  cache: Map<string, Intl.DateTimeFormat>,\n  key: string,\n  create: () => Intl.DateTimeFormat\n) {\n  let formatter = cache.get(key)\n  if (!formatter) {\n    formatter = create()\n    cache.set(key, formatter)\n  }\n  return formatter\n}\n\n// Offsets move with daylight saving, so callers pass the date to read them at.\nexport function getTimezoneOffset(timeZone: string, date: Date) {\n  const offset = cached(\n    offsetFormatters,\n    timeZone,\n    () =>\n      new Intl.DateTimeFormat(\"en-US\", {\n        timeZone,\n        timeZoneName: \"shortOffset\",\n      })\n  )\n    .formatToParts(date)\n    .find((part) => part.type === \"timeZoneName\")?.value\n\n  return offset === \"GMT\" || !offset ? \"GMT+0\" : offset\n}\n\nexport function parseOffsetMinutes(offset: string) {\n  const match = /^GMT([+-])(\\d{1,2})(?::(\\d{2}))?$/.exec(offset)\n  if (!match) return 0\n  const minutes = Number(match[2]) * 60 + Number(match[3] ?? 0)\n  return match[1] === \"-\" ? -minutes : minutes\n}\n\nexport function formatTimezoneTime(\n  timeZone: string,\n  date: Date,\n  locale?: string\n) {\n  return cached(\n    timeFormatters,\n    `${locale ?? \"\"}|${timeZone}`,\n    () =>\n      new Intl.DateTimeFormat(locale, {\n        timeZone,\n        hour: \"numeric\",\n        minute: \"2-digit\",\n      })\n  ).format(date)\n}\n\nexport function listTimezones(\n  date: Date,\n  ids: string[] = getTimezoneIds()\n): TimezoneOption[] {\n  return [...new Set(ids.map(toCurrentTimezoneName))]\n    .filter(isValidTimeZone)\n    .map((value) => {\n      const offset = getTimezoneOffset(value, date)\n      return {\n        value,\n        label: formatTimezoneLabel(value),\n        offset,\n        offsetMinutes: parseOffsetMinutes(offset),\n      }\n    })\n    .sort(\n      (a, b) =>\n        a.offsetMinutes - b.offsetMinutes || a.value.localeCompare(b.value)\n    )\n}\n\nfunction normalize(text: string) {\n  return text.toLowerCase().replaceAll(\"_\", \" \").trim()\n}\n\nexport function filterTimezones(options: TimezoneOption[], query: string) {\n  const needle = normalize(query)\n  if (!needle) return options\n\n  return options.filter(\n    (option) =>\n      normalize(option.value).includes(needle) ||\n      normalize(option.label).includes(needle) ||\n      option.offset.toLowerCase().includes(needle)\n  )\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}