TimezoneDropdown.tsx 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. import { CheckIcon } from 'lucide-react'
  2. import { useMemo, useState } from 'react'
  3. import {
  4. cn,
  5. Command,
  6. CommandEmpty,
  7. CommandGroup,
  8. CommandInput,
  9. CommandItem,
  10. CommandList,
  11. DropdownMenuPortal,
  12. DropdownMenuSub,
  13. DropdownMenuSubContent,
  14. DropdownMenuSubTrigger,
  15. ScrollArea,
  16. } from 'ui'
  17. import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
  18. import { useTimezone } from '@/lib/datetime'
  19. import { guessLocalTimezone } from '@/lib/dayjs'
  20. import { useTrack } from '@/lib/telemetry/track'
  21. const AUTO_OPTION_VALUE = '__auto__'
  22. export const TimezoneDropdown = () => {
  23. const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
  24. const track = useTrack()
  25. const [open, setOpen] = useState(false)
  26. // The "Auto detect" row always advertises the browser's own timezone, even
  27. // when the user is currently overriding it with a manual pick.
  28. const browserTimezone = useMemo(() => guessLocalTimezone(), [])
  29. const triggerLabel = useMemo(() => {
  30. return findTimezoneByIana(timezone)?.text ?? timezone
  31. }, [timezone])
  32. const handleSelect = (nextStored: string) => {
  33. setTimezone(nextStored)
  34. const resolvedNext = nextStored || guessLocalTimezone()
  35. track('timezone_picker_clicked', {
  36. previousTimezone: timezone,
  37. nextTimezone: resolvedNext,
  38. isAutoDetected: nextStored === '',
  39. source: 'user_dropdown',
  40. })
  41. setOpen(false)
  42. }
  43. return (
  44. <DropdownMenuSub open={open} onOpenChange={setOpen}>
  45. <DropdownMenuSubTrigger className="flex gap-2 cursor-pointer">
  46. <div className="flex flex-col min-w-0">
  47. <span>Timezone</span>
  48. <span className="text-xs text-foreground-lighter truncate" title={triggerLabel}>
  49. {isAutoDetected ? `Auto (${timezone})` : triggerLabel}
  50. </span>
  51. </div>
  52. </DropdownMenuSubTrigger>
  53. <DropdownMenuPortal>
  54. <DropdownMenuSubContent className="p-0 w-[320px]" sideOffset={4}>
  55. <Command>
  56. <CommandInput placeholder="Search timezone..." className="h-9" />
  57. <CommandList>
  58. <CommandEmpty>No timezones found</CommandEmpty>
  59. <CommandGroup>
  60. <ScrollArea className="h-72">
  61. <CommandItem
  62. key={AUTO_OPTION_VALUE}
  63. value={`Auto detect ${browserTimezone}`}
  64. onSelect={() => handleSelect('')}
  65. >
  66. <div className="flex flex-col">
  67. <span>Auto detect</span>
  68. <span className="text-xs text-foreground-lighter">{browserTimezone}</span>
  69. </div>
  70. <CheckIcon
  71. className={cn(
  72. 'ml-auto h-4 w-4',
  73. isAutoDetected ? 'opacity-100' : 'opacity-0'
  74. )}
  75. />
  76. </CommandItem>
  77. {TIMEZONES_BY_IANA.map((entry) => {
  78. const ianaName = entry.utc[0]
  79. const isSelected = !isAutoDetected && storedTimezone === ianaName
  80. return (
  81. <CommandItem
  82. key={ianaName}
  83. // CommandItem matches against the `value` prop for the input filter — include
  84. // both the human label and the IANA name so search works for either.
  85. value={`${entry.text} ${ianaName}`}
  86. onSelect={() => handleSelect(ianaName)}
  87. >
  88. {entry.text}
  89. <CheckIcon
  90. className={cn(
  91. 'ml-auto h-4 w-4',
  92. isSelected ? 'opacity-100' : 'opacity-0'
  93. )}
  94. />
  95. </CommandItem>
  96. )
  97. })}
  98. </ScrollArea>
  99. </CommandGroup>
  100. </CommandList>
  101. </Command>
  102. </DropdownMenuSubContent>
  103. </DropdownMenuPortal>
  104. </DropdownMenuSub>
  105. )
  106. }