{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "timezone-picker",
  "title": "Timezone Picker",
  "author": "Matthew Blode",
  "description": "A searchable list of IANA time zones with their current offset and time.",
  "registryDependencies": ["@blode/combobox"],
  "files": [
    {
      "path": "ui/timezone-picker.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  Combobox,\n  ComboboxCollection,\n  ComboboxContent,\n  ComboboxEmpty,\n  ComboboxGroup,\n  ComboboxInput,\n  ComboboxItem,\n  ComboboxLabel,\n  ComboboxList,\n} from \"@/components/ui/combobox\";\n\nconst MINUTE = 60_000;\n\n/** One IANA zone, ready to render. */\nexport interface TimeZoneOption {\n  /** Humanised city, e.g. `Sao Paulo`. Also what the input displays. */\n  label: string;\n  /** Short UTC offset, e.g. `GMT+11`. */\n  offset: string;\n  /** Readable identifier, e.g. `Pacific/Port Moresby`. */\n  path: string;\n  /** First path segment, e.g. `America`. */\n  region: string;\n  /** The IANA identifier, e.g. `America/Sao_Paulo`. */\n  value: string;\n}\n\ninterface TimeZoneGroup {\n  items: TimeZoneOption[];\n  value: string;\n}\n\n/** Fallback for engines without `Intl.supportedValuesOf`. */\nconst FALLBACK_TIME_ZONES = [\n  \"Africa/Cairo\",\n  \"Africa/Johannesburg\",\n  \"Africa/Lagos\",\n  \"America/Chicago\",\n  \"America/Denver\",\n  \"America/Los_Angeles\",\n  \"America/New_York\",\n  \"America/Sao_Paulo\",\n  \"Asia/Dubai\",\n  \"Asia/Kolkata\",\n  \"Asia/Shanghai\",\n  \"Asia/Singapore\",\n  \"Asia/Tokyo\",\n  \"Australia/Melbourne\",\n  \"Australia/Sydney\",\n  \"Europe/Berlin\",\n  \"Europe/London\",\n  \"Europe/Madrid\",\n  \"Europe/Paris\",\n  \"Pacific/Auckland\",\n  \"UTC\",\n];\n\nconst supportedTimeZones = (): string[] => {\n  const { supportedValuesOf } = Intl as unknown as {\n    supportedValuesOf?: (key: string) => string[];\n  };\n\n  if (typeof supportedValuesOf !== \"function\") {\n    return FALLBACK_TIME_ZONES;\n  }\n\n  try {\n    return supportedValuesOf(\"timeZone\");\n  } catch {\n    return FALLBACK_TIME_ZONES;\n  }\n};\n\n/**\n * An omitted locale resolves against the browser's own UI locale, which tracks\n * the OS region rather than the languages the viewer asked to read in: Chrome\n * hands a reader on `en-US` the `en-GB` formats, and the column comes out\n * 24-hour for someone who has never chosen that. `navigator.languages` is the\n * list the page is actually being read in, so it is what decides between\n * `5:57 pm` and `17:57`.\n */\nconst preferredLocales = (): string[] | undefined => {\n  if (typeof navigator === \"undefined\" || navigator.languages.length === 0) {\n    return;\n  }\n\n  return [...navigator.languages];\n};\n\n/**\n * `Intl.DateTimeFormat` is expensive to construct and the list runs to roughly\n * four hundred rows, so one formatter per zone is built once and reused on\n * every open and every tick. `null` marks a zone this engine rejects.\n *\n * `timeStyle` rather than explicit `hour`/`minute`: it gives each locale its own\n * short time — `7:23 pm` where the locale is twelve-hour, `19:23` where it is\n * not. The two run to different widths, which is why the column is right\n * aligned rather than padded.\n */\nconst timeFormatters = new Map<string, Intl.DateTimeFormat | null>();\n\nconst timeFormatterFor = (timeZone: string): Intl.DateTimeFormat | null => {\n  const cached = timeFormatters.get(timeZone);\n\n  if (cached !== undefined) {\n    return cached;\n  }\n\n  let formatter: Intl.DateTimeFormat | null = null;\n\n  try {\n    formatter = new Intl.DateTimeFormat(preferredLocales(), {\n      timeStyle: \"short\",\n      timeZone,\n    });\n  } catch {\n    formatter = null;\n  }\n\n  timeFormatters.set(timeZone, formatter);\n\n  return formatter;\n};\n\nconst offsetOf = (timeZone: string, at: Date): string => {\n  try {\n    const parts = new Intl.DateTimeFormat(preferredLocales(), {\n      timeZone,\n      timeZoneName: \"shortOffset\",\n    }).formatToParts(at);\n\n    return parts.find((part) => part.type === \"timeZoneName\")?.value ?? \"\";\n  } catch {\n    return \"\";\n  }\n};\n\nconst localTimeIn = (timeZone: string, at: Date): string =>\n  timeFormatterFor(timeZone)?.format(at) ?? \"\";\n\nconst toOption = (timeZone: string, at: Date): TimeZoneOption => {\n  const segments = timeZone.split(\"/\");\n\n  return {\n    label: (segments.at(-1) ?? timeZone).replaceAll(\"_\", \" \"),\n    offset: offsetOf(timeZone, at),\n    path: timeZone.replaceAll(\"_\", \" \"),\n    region: segments.length > 1 ? segments[0] : \"Other\",\n    value: timeZone,\n  };\n};\n\nconst noop = () => {\n  // Nothing to unsubscribe from: the viewer's zone does not change at runtime,\n  // and a closed picker has no clock to keep.\n};\n\nconst noopSubscribe = () => noop;\n\n/**\n * The tick is aligned to the next wall-clock minute rather than a naive 60s\n * interval, which would otherwise drift up to a minute behind the times on\n * screen. A clock is information rather than decoration, so it is not gated on\n * `prefers-reduced-motion`.\n */\nconst subscribeToMinute = (onChange: () => void) => {\n  let interval: ReturnType<typeof setInterval> | undefined;\n\n  const timeout = setTimeout(\n    () => {\n      onChange();\n      interval = setInterval(onChange, MINUTE);\n    },\n    MINUTE - (Date.now() % MINUTE),\n  );\n\n  return () => {\n    clearTimeout(timeout);\n\n    if (interval) {\n      clearInterval(interval);\n    }\n  };\n};\n\n/**\n * Minute-bucketed so the snapshot is stable between renders within a minute.\n * Only subscribes while the popup is open: the times are unmounted otherwise,\n * so a background timer would rerender the whole picker for nothing.\n */\nconst useMinuteClock = (active: boolean): Date | null => {\n  const minute = React.useSyncExternalStore<number | null>(\n    active ? subscribeToMinute : noopSubscribe,\n    () => Math.floor(Date.now() / MINUTE),\n    () => null,\n  );\n\n  return React.useMemo(() => (minute === null ? null : new Date(minute * MINUTE)), [minute]);\n};\n\n/**\n * Resolved through `useSyncExternalStore` rather than an effect: the server\n * renders `null`, because the host's zone is not the viewer's.\n */\nconst useLocalTimeZone = (): string | null =>\n  React.useSyncExternalStore<string | null>(\n    noopSubscribe,\n    () => Intl.DateTimeFormat().resolvedOptions().timeZone,\n    () => null,\n  );\n\nconst optionToLabel = (option: TimeZoneOption) => option.label;\nconst optionToValue = (option: TimeZoneOption) => option.value;\n\nexport interface TimezonePickerProps {\n  /** Accessible name, when no visible `FieldLabel` points at the input. */\n  \"aria-label\"?: string;\n  /** Id of the element labelling the input. */\n  \"aria-labelledby\"?: string;\n  /** Class names merged onto the input. */\n  className?: string;\n  /**\n   * Initial IANA zone. Omit it and the picker resolves the viewer's own zone\n   * after mount, which keeps the server and client markup identical.\n   */\n  defaultValue?: string;\n  /** Disables the control. */\n  disabled?: boolean;\n  /** Id applied to the input, for a `FieldLabel htmlFor`. */\n  id?: string;\n  /** Field name, for native form submission of the IANA identifier. */\n  name?: string;\n  /**\n   * Called with the selected IANA identifier. Also called once with the\n   * viewer's own zone when the picker resolves it for an uncontrolled field.\n   */\n  onValueChange?: (value: string) => void;\n  /** Shown while nothing is selected. */\n  placeholder?: string;\n  /** Explicit zone list. Defaults to `Intl.supportedValuesOf(\"timeZone\")`. */\n  timeZones?: string[];\n  /** Selected IANA identifier. Use for a controlled picker. */\n  value?: string;\n}\n\nconst TimezonePicker = ({\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  className,\n  defaultValue,\n  disabled = false,\n  id,\n  name,\n  onValueChange,\n  placeholder = \"Search time zones\",\n  timeZones,\n  value,\n}: TimezonePickerProps) => {\n  const [open, setOpen] = React.useState(false);\n  const now = useMinuteClock(open);\n  const localTimeZone = useLocalTimeZone();\n  const [uncontrolledValue, setUncontrolledValue] = React.useState<string | null>(\n    defaultValue ?? null,\n  );\n\n  const isControlled = value !== undefined;\n  const selectedValue = value ?? uncontrolledValue ?? localTimeZone;\n  const announcedRef = React.useRef(false);\n\n  /**\n   * The viewer's zone cannot be known until after hydration, so the consumer is\n   * told about it once it is. Without this the field shows a zone the parent's\n   * own state has never heard of, and an untouched form submits nothing.\n   */\n  React.useEffect(() => {\n    if (announcedRef.current || isControlled || uncontrolledValue !== null) {\n      return;\n    }\n\n    if (localTimeZone === null) {\n      return;\n    }\n\n    announcedRef.current = true;\n    onValueChange?.(localTimeZone);\n  }, [isControlled, localTimeZone, onValueChange, uncontrolledValue]);\n\n  const groups = React.useMemo<TimeZoneGroup[]>(() => {\n    // Built once per zone set. Offsets shift only at DST boundaries, which no\n    // session outlives, so this does not rerun on every clock tick.\n    const reference = new Date();\n    const byRegion = new Map<string, TimeZoneOption[]>();\n\n    for (const timeZone of timeZones ?? supportedTimeZones()) {\n      const option = toOption(timeZone, reference);\n      const bucket = byRegion.get(option.region);\n\n      if (bucket) {\n        bucket.push(option);\n      } else {\n        byRegion.set(option.region, [option]);\n      }\n    }\n\n    return [...byRegion.entries()]\n      .map(([region, items]) => ({\n        items: items.toSorted((a, b) => a.label.localeCompare(b.label)),\n        value: region,\n      }))\n      .toSorted((a, b) => a.value.localeCompare(b.value));\n  }, [timeZones]);\n\n  const selectedOption = React.useMemo(() => {\n    if (!selectedValue) {\n      return null;\n    }\n\n    for (const group of groups) {\n      const match = group.items.find((item) => item.value === selectedValue);\n\n      if (match) {\n        return match;\n      }\n    }\n\n    return toOption(selectedValue, new Date());\n  }, [groups, selectedValue]);\n\n  const filter = React.useCallback(\n    (item: TimeZoneOption, query: string, itemToString?: (item: TimeZoneOption) => string) => {\n      const needle = query.trim().toLowerCase();\n\n      if (needle === \"\") {\n        return true;\n      }\n\n      return [\n        item.label,\n        item.path,\n        item.value,\n        item.region,\n        item.offset,\n        itemToString?.(item) ?? \"\",\n      ].some((haystack) => haystack.toLowerCase().includes(needle));\n    },\n    [],\n  );\n\n  const handleValueChange = React.useCallback(\n    (next: TimeZoneOption | null) => {\n      if (!next) {\n        return;\n      }\n\n      setUncontrolledValue(next.value);\n      onValueChange?.(next.value);\n    },\n    [onValueChange],\n  );\n\n  /**\n   * Base UI seeds the input's text from the value it mounts with, so a zone\n   * adopted after hydration reaches the field only on a fresh mount. Scoped to\n   * that adoption: two states, so it flips once on a closed and untouched\n   * picker, never on selection, and never for a controlled or `defaultValue`\n   * picker, both of which mount with their value already in hand.\n   */\n  const isSelfResolving = !(isControlled || defaultValue !== undefined);\n  const seedKey = isSelfResolving && selectedValue === null ? \"unresolved\" : \"resolved\";\n\n  return (\n    <Combobox<TimeZoneOption>\n      disabled={disabled}\n      key={seedKey}\n      filter={filter}\n      isItemEqualToValue={(a, b) => a.value === b.value}\n      itemToStringLabel={optionToLabel}\n      itemToStringValue={optionToValue}\n      items={groups}\n      name={name}\n      onOpenChange={setOpen}\n      onValueChange={handleValueChange}\n      value={selectedOption}\n    >\n      <ComboboxInput\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledBy}\n        className={cn(className)}\n        data-slot=\"timezone-picker-input\"\n        disabled={disabled}\n        id={id}\n        placeholder={placeholder}\n      />\n      <ComboboxContent data-slot=\"timezone-picker-content\">\n        <ComboboxEmpty>No time zones found.</ComboboxEmpty>\n        <ComboboxList>\n          {(group: TimeZoneGroup) => (\n            <ComboboxGroup items={group.items} key={group.value}>\n              {/* Sticky: ten regions across four hundred rows, so the heading\n                  has to survive the scroll that takes you away from it. */}\n              <ComboboxLabel className=\"sticky -top-1 z-10 bg-popover pt-2.5 pl-1.5\">\n                {group.value}\n              </ComboboxLabel>\n              <ComboboxCollection>\n                {/* The shared item reserves a 32px gutter for the check, which\n                    strands the time column well short of the popup edge. Give the\n                    gutter back to every row and let the one selected row pay for\n                    its own check. `data-[selected]` rather than `data-selected:`:\n                    shadcn's stylesheet redefines that variant as\n                    `[data-selected=\"true\"]`, and Base UI writes the attribute\n                    empty, so the shorthand compiles and never matches. */}\n                {(option: TimeZoneOption) => (\n                  <ComboboxItem\n                    className=\"gap-3 py-1.5 pr-2 data-[selected]:pr-8\"\n                    key={option.value}\n                    value={option}\n                  >\n                    <span className=\"flex min-w-0 flex-1 flex-col\">\n                      <span className=\"truncate\">{option.label}</span>\n                      <span className=\"flex min-w-0 items-center gap-1.5 text-muted-foreground text-xs\">\n                        <span className=\"truncate\">{option.path}</span>\n                        <span className=\"tabular-figures shrink-0\">{option.offset}</span>\n                      </span>\n                    </span>\n                    <span className=\"tabular-figures shrink-0 text-right text-muted-foreground text-xs\">\n                      {now ? localTimeIn(option.value, now) : option.offset}\n                    </span>\n                  </ComboboxItem>\n                )}\n              </ComboboxCollection>\n            </ComboboxGroup>\n          )}\n        </ComboboxList>\n      </ComboboxContent>\n    </Combobox>\n  );\n};\n\nexport { TimezonePicker };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
