File size: 5,018 Bytes
f1dd159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import { format } from "date-fns"
import { enUS, zhCN } from "date-fns/locale"
import { CalendarIcon } from "lucide-react"
import { useRef } from "react"
import { useTranslation } from "react-i18next"
import { enUS as enUSDayPicker, zhCN as zhCNDayPicker } from "react-day-picker/locale"

import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/shared/lib/cn"
import { toDateTimeLocal } from "@/shared/lib/format"

type DateTimePickerProps = {
  value: string
  onChange: (value: string) => void
  placeholder?: string
  disabled?: boolean
}

export function DateTimePicker({ value, onChange, placeholder, disabled = false }: DateTimePickerProps) {
  const { t, i18n } = useTranslation()
  const selected = parseLocalDateTime(value)
  const isChinese = i18n.language.toLowerCase().startsWith("zh")
  const timeParts = getTimeParts(selected)
  const timeInputRefs = useRef<Array<HTMLInputElement | null>>([])

  function enteredTimeParts(): TimeParts {
    return timeParts.map((part, index) => timeInputRefs.current[index]?.value ?? part) as TimeParts
  }

  function selectDate(date: Date | undefined): void {
    if (!date) {
      onChange("")
      return
    }
    const [hours, minutes, seconds] = normalizedTimeParts(enteredTimeParts())
    date.setHours(hours, minutes, seconds, 0)
    onChange(toDateTimeLocal(date.toISOString()))
  }

  function commitTime(): void {
    const normalized = normalizedTimeParts(enteredTimeParts())
    const date = selected ? new Date(selected) : new Date()
    date.setHours(normalized[0], normalized[1], normalized[2], 0)
    onChange(toDateTimeLocal(date.toISOString()))
  }

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button type="button" variant="secondary" disabled={disabled} className={cn("h-8 w-full min-w-0 justify-start rounded-md bg-secondary/55 px-3 text-left font-normal", !selected && "text-muted-foreground")}>
          <CalendarIcon />
          <span className="truncate">
            {selected ? format(selected, isChinese ? "yyyy年M月d日 HH:mm:ss" : "MMM d, yyyy HH:mm:ss", { locale: isChinese ? zhCN : enUS }) : (placeholder ?? t("keys.neverExpires"))}
          </span>
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-auto overflow-hidden p-0" align="start">
        <Calendar
          mode="single"
          selected={selected}
          defaultMonth={selected}
          onSelect={selectDate}
          locale={isChinese ? zhCNDayPicker : enUSDayPicker}
        />
        <div className="flex items-center gap-2 border-t px-3 py-2">
          <span className="text-xs text-muted-foreground">{t("keys.expiryTime")}</span>
          <div className="ml-auto flex items-center gap-1 text-xs">
            {timeParts.map((part, index) => (
              <div className="contents" key={index}>
                {index > 0 ? <span className="text-muted-foreground">:</span> : null}
                <Input
                  aria-label={`${t("keys.expiryTime")} ${index === 0 ? "HH" : index === 1 ? "MM" : "SS"}`}
                  className="h-7 w-9 bg-background px-1 text-center text-xs tabular-nums"
                  inputMode="numeric"
                  maxLength={2}
                  pattern="[0-9]*"
                  defaultValue={part}
                  key={`${value}-${index}`}
                  ref={(element) => {
                    timeInputRefs.current[index] = element
                  }}
                  onChange={(event) => {
                    event.currentTarget.value = event.currentTarget.value.replace(/\D/g, "").slice(0, 2)
                  }}
                  onBlur={commitTime}
                  onFocus={(event) => event.currentTarget.select()}
                  onKeyDown={(event) => {
                    if (event.key === "Enter") {
                      commitTime()
                      event.currentTarget.blur()
                    }
                  }}
                />
              </div>
            ))}
          </div>
        </div>
      </PopoverContent>
    </Popover>
  )
}

type TimeParts = [string, string, string]

function getTimeParts(date: Date | undefined): TimeParts {
  return date ? [format(date, "HH"), format(date, "mm"), format(date, "ss")] : ["23", "59", "59"]
}

function normalizedTimeParts(parts: TimeParts): [number, number, number] {
  return [
    clampTimePart(parts[0], 23),
    clampTimePart(parts[1], 59),
    clampTimePart(parts[2], 59),
  ]
}

function clampTimePart(value: string, maximum: number): number {
  const parsed = Number.parseInt(value, 10)
  return Number.isNaN(parsed) ? 0 : Math.min(Math.max(parsed, 0), maximum)
}

function parseLocalDateTime(value: string): Date | undefined {
  if (!value) {
    return undefined
  }
  const date = new Date(value)
  return Number.isNaN(date.getTime()) ? undefined : date
}