【山竹记账前端-react】19.自制日期选择器(下)


大纲链接 §

[toc]


1. 一列变三列 ⇧

提取单个日期栏组件到 DateTimeColumn.tsx

  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
interface ColumnProps {
  className?: string
  start?: Date
  end?: Date
  value?: Date
  dateItemHeight?: number
}

const DateTimeColumn: React.FC<ColumnProps> = ({
  className,
  start,
  end,
  value,
  dateItemHeight = 36,
}) => {
  const startTime = start ? createDateTime(start) : createDateTime().add(-3, 'year')
  const endTime = end ? createDateTime(end) : createDateTime().add(3, 'year')
  if (startTime.getTimestamp() >= endTime.getTimestamp()) {
    throw new Error('startTime must be greater than endTime')
  }

  const valueTime = value ? createDateTime(value) : createDateTime()
  const yearList = Array.from({ length: endTime.year - startTime.year + 1 }).map(
    (_, i) => startTime.year + i
  )
  const halfDateItemHeight = dateItemHeight / 2
  const itemHeightVarStyle = {
    '--date-item-height': `${dateItemHeight}px`,
    '--half-date-item-height': `${halfDateItemHeight}px`,
    '--total-height': `${yearList.length * dateItemHeight}px`,
  }
  const currentYearIndex = yearList.indexOf(valueTime.year)

  const [isTouching, setIsTouching] = useState(false)
  const [lastY, setLastY] = useState(-1)
  const [translateY, setTranslateYState] = useState(currentYearIndex * -dateItemHeight)

  const springStyle = useSpring({
    transform: `translate3d(0, ${translateY}px, 0)`,
    immediate: isTouching,
    config: {
      tension: 300,
      friction: 30,
    },
  })

  const setTranslateY = (y: number) => {
    y = Math.min(0, y)
    y = Math.max(y, (yearList.length - 1) * -dateItemHeight)
    setTranslateYState(y)
  }

  const onTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {
    setIsTouching(true)
    setLastY(e.touches[0].clientY)
  }

  const onTouchMove: TouchEventHandler<HTMLDivElement> = (e) => {
    if (!isTouching) {
      return
    }
    const currentY = e.touches[0].clientY
    const deltaY = currentY - lastY
    setTranslateY(translateY + deltaY)
    setLastY(currentY)
  }

  const onTouchEnd: TouchEventHandler<HTMLDivElement> = (/* e */) => {
    const remainder = translateY % dateItemHeight
    let resultY = translateY - remainder
    if (Math.abs(remainder) > halfDateItemHeight) {
      resultY += dateItemHeight * (remainder > 0 ? 1 : -1)
    }
    setTranslateY(resultY)
    setIsTouching(false)
  }
  return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      style={itemHeightVarStyle}
      className={cn(
        'date-picker-wrapper h-[var(--total-height)] overflow-clip relative',
        className
      )}>
      {/*
      <div
        className={cn(
          'highlight-select-box h-[var(--date-item-height)] w-full',
          'absolute top-[calc(50%-var(--half-date-item-height))] left-0',
          'b-t-1.5 b-b-1.5 b-solid b-[#ccc]/50'
        )}></div>
         */}
      <div className="picker-positioner w-full absolute top-[calc(50%-var(--half-date-item-height))]">
        <animated.ol style={springStyle} className="year-picker text-center">
          {yearList.map((year) => (
            <li
              key={year}
              className="h-[var(--date-item-height)] leading-[var(--date-item-height)]">
              {year}
            </li>
          ))}
        </animated.ol>
      </div>
    </div>
  )
}

@/components/form/DatePickerPopup.tsx

 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
export const DatePickerPopup: React.FC<Props> = ({
  className,
  start,
  end,
  value,
  dateItemHeight = 36,
}) => {
  return (
    <div className={cn(className, 'flex relative')}>
      <DateTimeColumn
        className="grow-1"
        start={start}
        end={end}
        value={value}
        dateItemHeight={dateItemHeight}
      />
      <DateTimeColumn
        className="grow-1"
        start={start}
        end={end}
        value={value}
        dateItemHeight={dateItemHeight}
      />
      <DateTimeColumn
        className="grow-1"
        start={start}
        end={end}
        value={value}
        dateItemHeight={dateItemHeight}
      />
      <div
        className={cn(
          'picker-mask z-1 pointer-events-none w-full h-full',
          'absolute top-0 left-1em right-1em transform-gpu',
          'bg-[image:var(--picker-mask-color)] bg-no-repeat',
          'bg-[position:center_top,center_bottom] bg-[size:100%_110px]'
        )}></div>
      <div
        className={cn(
          'pricker-frame z-2 h-[36px] pointer-events-none',
          'absolute top-[50%] left-[1em] right-[1em] translate-y-[-50%]',
          'animation-rainbow after:(b-color-[var(--vp-c-brand-next)])',
          'after:(content-[""] b-y-2.5)',
          'after:(absolute top-[-50%] bottom-[-50%] left-[-50%] right-[-50%] scale-[.5])'
        )}></div>
    </div>
  )
}

2. useRef 配合 forceUpdate ⇧

添加月日列重,构组件 ⇧

重构 @/components/form/DateTimeColumn.tsx 解耦年份数据,只需要 dateTimeValue 和 dateTimeList

 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
import { animated, useSpring } from '@react-spring/web'
import { cn } from 'cn'
import { TouchEventHandler, useState } from 'react'

interface ColumnProps {
  className?: string
  dateItemHeight?: number
  dateTimeValue: string
  dateTimeList: string[]
}

export const DateTimeColumn: React.FC<ColumnProps> = ({
  className,
  dateItemHeight = 36,
  dateTimeValue,
  dateTimeList,
}) => {
  const halfDateItemHeight = dateItemHeight / 2
  const itemHeightVarStyle = {
    '--date-item-height': `${dateItemHeight}px`,
    '--half-date-item-height': `${halfDateItemHeight}px`,
  }

  const currentDateIndex = dateTimeList.indexOf(dateTimeValue)
  if (currentDateIndex === -1) {
    throw new Error('dateTimeValue is not in dateTimeList')
  }

  const [isTouching, setIsTouching] = useState(false)
  const [lastY, setLastY] = useState(-1)
  const [translateY, setTranslateYState] = useState(currentDateIndex * -dateItemHeight)

  const springStyle = useSpring({
    transform: `translate3d(0, ${translateY}px, 0)`,
    immediate: isTouching,
    config: {
      tension: 300,
      friction: 30,
    },
  })

  const setTranslateY = (y: number) => {
    y = Math.min(0, y)
    y = Math.max(y, (dateTimeList.length - 1) * -dateItemHeight)
    setTranslateYState(y)
  }

  const onTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {
    setIsTouching(true)
    setLastY(e.touches[0].clientY)
  }

  const onTouchMove: TouchEventHandler<HTMLDivElement> = (e) => {
    if (!isTouching) {
      return
    }
    const currentY = e.touches[0].clientY
    const deltaY = currentY - lastY
    setTranslateY(translateY + deltaY)
    setLastY(currentY)
  }

  const onTouchEnd: TouchEventHandler<HTMLDivElement> = (/* e */) => {
    const remainder = translateY % dateItemHeight
    let resultY = translateY - remainder
    if (Math.abs(remainder) > halfDateItemHeight) {
      resultY += dateItemHeight * (remainder > 0 ? 1 : -1)
    }
    setTranslateY(resultY)
    setIsTouching(false)
  }

  return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      style={itemHeightVarStyle}
      className={cn(
        'date-picker-column h-[var(--popup-height)] overflow-clip relative',
        className
      )}>
      <div className="picker-positioner w-full absolute top-[calc(50%-var(--half-date-item-height))]">
        <animated.ol style={springStyle} className="date-time-picker text-center">
          {dateTimeList.map((dateTimeStr) => (
            <li
              key={dateTimeStr}
              className="h-[var(--date-item-height)] leading-[var(--date-item-height)]">
              {dateTimeStr}
            </li>
          ))}
        </animated.ol>
      </div>
    </div>
  )
}
  • 容器总高度需要写死 h-[var(--popup-height)]

重构 @/components/form/DatePickerPopup.tsx 添加月份、日列表数据

 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
import { cn } from 'cn'
import { DateTimeColumn } from '@/components/form/DateTimeColumn'
import { createDateTime } from '@/lib/helpers/DateTime'

interface Props {
  className?: string
  start?: Date
  end?: Date
  value?: Date
  dateItemHeight?: number
}

export const DatePickerPopup: React.FC<Props> = ({
  className,
  start,
  end,
  value,
  dateItemHeight = 36,
}) => {
  const startTime = start ? createDateTime(start) : createDateTime().add(-3, 'year')
  const endTime = end ? createDateTime(end) : createDateTime().add(3, 'year')
  if (startTime.getTimestamp() >= endTime.getTimestamp()) {
    throw new Error('startTime must be greater than endTime')
  }

  const valueTime = value ? createDateTime(value) : createDateTime()
  const yearList = Array.from({ length: endTime.year - startTime.year + 1 })
    .map((_, i) => startTime.year + i)
    .map((item) => item.toString())
  const monthList = Array.from({ length: 12 })
    .map((_, i) => i + 1)
    .map((item) => item.toString())
  const dayList = Array.from({ length: valueTime.lastDayOfMonth })
    .map((_, i) => i + 1)
    .map((item) => item.toString())

  return (
    <div className={cn(className, 'flex relative')}>
      <DateTimeColumn
        className="grow-1"
        dateTimeValue={valueTime.year.toString()}
        dateTimeList={yearList}
        dateItemHeight={dateItemHeight}
      />
      <DateTimeColumn
        className="grow-1"
        dateTimeValue={valueTime.month.toString()}
        dateTimeList={monthList}
        dateItemHeight={dateItemHeight}
      />
      <DateTimeColumn
        className="grow-1"
        dateTimeValue={valueTime.day.toString()}
        dateTimeList={dayList}
        dateItemHeight={dateItemHeight}
      />
      <div
        className={cn(
          'picker-mask z-1 pointer-events-none w-full h-full',
          'absolute top-0 left-1em right-1em transform-gpu',
          'bg-[image:var(--picker-mask-color)] bg-no-repeat',
          'bg-[position:center_top,center_bottom] bg-[size:100%_110px]'
        )}></div>
      <div
        className={cn(
          'pricker-frame z-2 h-[36px] pointer-events-none',
          'absolute top-[50%] left-[1em] right-[1em] translate-y-[-50%]',
          'animation-rainbow after:(b-color-[var(--vp-c-brand-next)])',
          'after:(content-[""] b-y-2.5)',
          'after:(absolute top-[-50%] bottom-[-50%] left-[-50%] right-[-50%] scale-[.5])'
        )}></div>
    </div>
  )
}

日期栏联动,onChange 触发日期栏切换 ⇧

@/components/form/DateTimeColumn.tsx

 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
// import ...

interface ColumnProps {
  className?: string
  dateItemHeight?: number
  dateTimeValue: string
  dateTimeList: string[]
  onChange: (value: string) => void
}

export const DateTimeColumn: React.FC<ColumnProps> = ({
  className,
  dateItemHeight = 36,
  dateTimeValue,
  dateTimeList,
  onChange,
}) => {
  const halfDateItemHeight = dateItemHeight / 2
  const itemHeightVarStyle = {/* ... */}
  const currentDateIndex = dateTimeList.indexOf(dateTimeValue)
  if (currentDateIndex === -1) {/* ... */}
  const [isTouching, setIsTouching] = useState(false)
  const [lastY, setLastY] = useState(-1)
  const [translateY, setTranslateYState] = useState(currentDateIndex * -dateItemHeight)
  const springStyle = useSpring({/* ... */})
  const setTranslateY = (y: number) => {/* ... */}
  const onTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {/* ... */}
  const onTouchMove: TouchEventHandler<HTMLDivElement> = (e) => {/* ... */}
  const onTouchEnd: TouchEventHandler<HTMLDivElement> = (/* e */) => {
    const remainder = translateY % dateItemHeight
    let resultY = translateY - remainder
    if (Math.abs(remainder) > halfDateItemHeight) {
      resultY += dateItemHeight * (remainder > 0 ? 1 : -1)
    }
    setTranslateY(resultY)
    setIsTouching(false)
    onChange(dateTimeList[Math.abs(resultY / dateItemHeight)])
  }

  return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      style={itemHeightVarStyle}
      className={cn(/* ... */)}>
      <div className="picker-positioner w-full absolute top-[calc(50%-var(--half-date-item-height))]">
        <animated.ol style={springStyle} className="date-time-picker text-center">
          {dateTimeList.map((dateTimeStr) => (
            <li
              key={dateTimeStr}
              className="h-[var(--date-item-height)] leading-[var(--date-item-height)]">
              {dateTimeStr}
            </li>
          ))}
        </animated.ol>
      </div>
    </div>
  )
}
  • 组件接受父组件传参方法onChange
  • 在 onTouchEnd 时触发 onChange
  • onChange 时将当前时间选项值,通过计算 dateTimeList 的索引,传递给父组件
    • dateTimeList[Math.abs(resultY / dateItemHeight)]

使用 useRef + forceUpdate 改造 @/components/form/DatePickerPopup.tsx

 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
// import ...

interface Props {
  className?: string
  start?: Date
  end?: Date
  value?: Date
  dateItemHeight?: number
  onChange?: (value: Date) => void
}

export const DatePickerPopup: React.FC<Props> = ({
  className,
  start,
  end,
  value,
  dateItemHeight = 36,
  onChange,
}) => {
  const startTime = start ? createDateTime(start) : createDateTime().add(-3, 'year')
  const endTime = end ? createDateTime(end) : createDateTime().add(3, 'year')
  if (startTime.getTimestamp() >= endTime.getTimestamp()) {
    throw new Error('startTime must be greater than endTime')
  }

  const valueTime = useRef(value ? createDateTime(value) : createDateTime())
  const [_, forceUpdate] = useState({})

  const yearList = Array.from({ length: endTime.year - startTime.year + 1 })
    .map((_, i) => startTime.year + i)
    .map((item) => item.toString())
  const monthList = Array.from({ length: 12 })
    .map((_, i) => i + 1)
    .map((item) => item.toString())
  const dayList = Array.from({ length: valueTime.current.lastDayOfMonth })
    .map((_, i) => i + 1)
    .map((item) => item.toString())

  const onChangeYear = (value: string) => {
    valueTime.current.year = parseInt(value)
    forceUpdate({})
    onChange?.(valueTime.current.rawDate)
  }
  const onChangeMonth = (value: string) => {
    valueTime.current.month = parseInt(value)
    forceUpdate({})
    onChange?.(valueTime.current.rawDate)
  }
  const onChangeDay = (value: string) => {
    valueTime.current.day = parseInt(value)
    forceUpdate({})
    onChange?.(valueTime.current.rawDate)
  }

  return (
    <div className={cn(className, 'flex relative')}>
      {valueTime.current.format()}
      <DateTimeColumn
        className="grow-1"
        dateTimeValue={valueTime.current.year.toString()}
        dateTimeList={yearList}
        dateItemHeight={dateItemHeight}
        onChange={onChangeYear}
      />
      <DateTimeColumn
        className="grow-1"
        dateTimeValue={valueTime.current.month.toString()}
        dateTimeList={monthList}
        dateItemHeight={dateItemHeight}
        onChange={onChangeMonth}
      />
      <DateTimeColumn
        className="grow-1"
        dateTimeValue={valueTime.current.day.toString()}
        dateTimeList={dayList}
        dateItemHeight={dateItemHeight}
        onChange={onChangeDay}
      />
      <div className={cn( 'picker-mask', /* ... */ )}></div>
      <div className={cn('pricker-frame', /* ... */ )}></div>
    </div>
  )
}
  • 需要强行设置一个空对象的状态 const [_, forceUpdate] = useState({})
  • 每次更新组件只声明一次变量使用 useRef
    • const valueTime = useRef(value ? createDateTime(value) : createDateTime())
  • 当切换日期栏,每次都需要重新计算 dayList
  • 使用 useRef + forceUpdate
    • 值放到一个固定的对象上 const valueTime = useRef(value ? createDateTime(value) : createDateTime())
    • 声明强行更行更新方法 const [_, forceUpdate] = useState({})
    • 状态变更时添加 forceUpdate({})
    • const onChangeYear = (value) => {valueTime.current.year = ...; forceUpdate({})}

父组件 @/modules/items/input/DateInput.tsx 通过 onChange 拿到新的日期值,再设置到日期对象中 valueTime

 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
// import ...

export const DateInput: React.FC<Props> = ({ className }) => {
  const [currentDate, setCurrentDate] = useState<string>(genDateStr())
  const onChange = (value: Date) => {
    const dateStr = genDateStr(value)
    setCurrentDate(dateStr)
    console.log('DateInput onChange_______________________')
    console.log('value: ', value)
    console.log('dateStr: ', dateStr)
    console.log('_______________________DateInput onChange')
  }
  const { portalPopup, togglePopup } =
    usePopup(<DatePickerPopup onChange={onChange} />, true)

  return (
    <>
      {portalPopup}
      <span
        onClick={togglePopup}
        className={cn('flex justify-start items-center gap-x-[.5em] pr-1em', className)}>
        <SvgIcon name="salary-calendar" className="..." />
        <time
          dateTime={currentDate}
          className="grow-1 shrink-1 animation-rainbow text-[14px] font-mono color-[var(--vp-c-brand-next)]">
          {currentDate}
        </time>
      </span>
    </>
  )
}

目前还有 bug

  • 从闰年的 02-29 切换到平年时,实际日期到了 03-01
  • dateTimeValue 变化时,没有去更新 UI

date-picker-bug-1 date-picker-bug-2


3. value变化时更新偏移量y ⇧

date-picker-bug-3

Debug 调试精髓 @/components/form/DateTimeColumn.tsx

  • 未更新 UI 中的 currentDateIndex

     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
    
    import { animated, useSpring } from '@react-spring/web'
    import { cn } from 'cn'
    import { TouchEventHandler, useEffect, useState } from 'react'
    
    interface ColumnProps {
    className?: string
    dateItemHeight?: number
    dateTimeValue: string
    dateTimeList: string[]
    onChange: (value: string) => void
    }
    
    export const DateTimeColumn: React.FC<ColumnProps> = ({
    className,
    dateItemHeight = 36,
    dateTimeValue,
    dateTimeList,
    onChange,
    }) => {
    useEffect(() => {
    console.log('DateTimeColumn_______________________')
    console.log('%c⧭', 'color: #00ff88', dateTimeValue)
    console.log('_______________________DateTimeColumn')
    }, [dateTimeValue])
    
    const halfDateItemHeight = dateItemHeight / 2
    const itemHeightVarStyle = {/* ... */}
    
    const currentDateIndex = dateTimeList.indexOf(dateTimeValue)
    if (currentDateIndex === -1) {/* ... */}
    
    const [isTouching, setIsTouching] = useState(false)
    const [lastY, setLastY] = useState(-1)
    const [translateY, setTranslateYState] = useState(currentDateIndex * -dateItemHeight)
    const springStyle = useSpring({/* ... */})
    const setTranslateY = (y: number) => {/* ... */}
    const onTouchStart: TouchEventHandler<HTMLDivElement> = (e) => {/* ... */}
    const onTouchMove: TouchEventHandler<HTMLDivElement> = (e) => {/* ... */}
    const onTouchEnd: TouchEventHandler<HTMLDivElement> = (/* e */) => {/* ... */}
    
    return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      style={itemHeightVarStyle}
      className={cn(
        'date-picker-column h-[var(--popup-height)] overflow-clip relative',
        className
      )}>
      <div className="picker-positioner w-full absolute top-[calc(50%-var(--half-date-item-height))]">
        <animated.ol style={springStyle} className="date-time-picker text-center">
          {dateTimeList.map((dateTimeStr) => (
            <li
              key={dateTimeStr}
              className="h-[var(--date-item-height)] leading-[var(--date-item-height)]">
              {dateTimeStr}
            </li>
          ))}
        </animated.ol>
      </div>
    </div>
    )
    }
    
  • 需要使用 useEffect 监听 dateTimeValue 变化,重新计算 currentDateIndex,然后重新 setTranslateY

  • 同步 dateTimeValue 变化时的滚动偏移量位置

     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
    
    export const DateTimeColumn: React.FC<ColumnProps> = ({
    className,
    dateItemHeight = 36,
    dateTimeValue,
    dateTimeList,
    onChange,
    }) => {
    const halfDateItemHeight = dateItemHeight / 2
    const itemHeightVarStyle = {/* ... */}
    const currentDateIndex = dateTimeList.indexOf(dateTimeValue)
    if (currentDateIndex === -1) {/* ... */}
    const [isTouching, setIsTouching] = useState(false)
    const [lastY, setLastY] = useState(-1)
    const [translateY, setTranslateYState] = useState(currentDateIndex * -dateItemHeight)
    const springStyle = useSpring({/* ... */})
    const setTranslateY = (y: number) => {
    y = Math.min(0, y)
    y = Math.max(y, (dateTimeList.length - 1) * -dateItemHeight)
    setTranslateYState(y)
    }
    
    useEffect(() => {
    console.log('DateTimeColumn_______________________')
    console.log('%c⧭', 'color: #00ff88', dateTimeValue)
    console.log('_______________________DateTimeColumn')
    
    const currentDateIndex = dateTimeList.indexOf(dateTimeValue)
    setTranslateY(currentDateIndex * -dateItemHeight)
    }, [dateTimeValue])
    
    // ...
    
    return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      style={itemHeightVarStyle}
      className={cn(/* ... */)}>
      {/* ... */}
    </div>
    )
    }
    

4. 确定和取消按钮 ⇧

@/components/form/DatePickerPopup.tsx

  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
import { cn } from 'cn'
import { DateTimeColumn } from '@/components/form/DateTimeColumn'
import { createDateTime } from '@/lib/helpers/DateTime'
import { useRef, useState } from 'react'

interface Props {
  className?: string
  start?: Date
  end?: Date
  value?: Date
  dateItemHeight?: number
  onChange?: (value: Date) => void
  onCancel?: () => void
  onConfirm?: (dateTimeStr: string) => void
}

export const DatePickerPopup: React.FC<Props> = ({
  className,
  start,
  end,
  value,
  dateItemHeight = 36,
  onCancel,
  onConfirm,
}) => {
  const startTime = start ? createDateTime(start) : createDateTime().add(-3, 'year')
  const endTime = end ? createDateTime(end) : createDateTime().add(3, 'year')
  if (startTime.getTimestamp() >= endTime.getTimestamp()) {
    throw new Error('startTime must be greater than endTime')
  }

  const valueTime = useRef(value ? createDateTime(value) : createDateTime())
  const [_, forceUpdate] = useState({})

  const yearList = Array.from({ length: endTime.year - startTime.year + 1 })
    .map((_, i) => startTime.year + i)
    .map((item) => item.toString())
  const monthList = Array.from({ length: 12 })
    .map((_, i) => i + 1)
    .map((item) => item.toString())
  const dayList = Array.from({ length: valueTime.current.lastDayOfMonth })
    .map((_, i) => i + 1)
    .map((item) => item.toString())

  const onChangeYear = (value: string) => {
    valueTime.current.year = parseInt(value)
    forceUpdate({})
  }
  const onChangeMonth = (value: string) => {
    valueTime.current.month = parseInt(value)
    forceUpdate({})
  }
  const onChangeDay = (value: string) => {
    valueTime.current.day = parseInt(value)
    forceUpdate({})
  }

  return (
    <section className={className}>
      {/* 顶部按钮 */}
      <div
        className={cn(
          'flex justify-between p-[.5em]',
          'bg-white b-b-1 b-b-[#f3f3f3]',
          'children:(p-[.5em])'
        )}>
        <span onClick={onCancel}>取消</span>
        <span>日期选择</span>
        <span onClick={() => onConfirm?.(valueTime.current.format('YYYY-MM-DD'))}>确认</span>
      </div>

      {/* 日期选择 */}
      <div className={cn('flex relative')}>
        <DateTimeColumn
          className="grow-1"
          dateTimeValue={valueTime.current.year.toString()}
          dateTimeList={yearList}
          dateItemHeight={dateItemHeight}
          onChange={onChangeYear}
        />
        <DateTimeColumn
          className="grow-1"
          dateTimeValue={valueTime.current.month.toString()}
          dateTimeList={monthList}
          dateItemHeight={dateItemHeight}
          onChange={onChangeMonth}
        />
        <DateTimeColumn
          className="grow-1"
          dateTimeValue={valueTime.current.day.toString()}
          dateTimeList={dayList}
          dateItemHeight={dateItemHeight}
          onChange={onChangeDay}
        />
        <div
          className={cn(
            'picker-mask z-1 pointer-events-none w-full h-full',
            'absolute top-0 left-1em right-1em transform-gpu',
            'bg-[image:var(--picker-mask-color)] bg-no-repeat',
            'bg-[position:center_top,center_bottom] bg-[size:100%_110px]'
          )}></div>
        <div
          className={cn(
            'pricker-frame z-2 h-[36px] pointer-events-none',
            'absolute top-[50%] left-[1em] right-[1em] translate-y-[-50%]',
            'animation-rainbow after:(b-color-[var(--vp-c-brand-next)])',
            'after:(content-[""] b-y-2.5)',
            'after:(absolute top-[-50%] bottom-[-50%] left-[-50%] right-[-50%] scale-[.5])'
          )}></div>
      </div>
    </section>
  )
}
  • 将 onChange 改为确认/取消交互

@/modules/items/input/DateInput.tsx

 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
// import ...'

export const DateInput: React.FC<Props> = ({ className }) => {
  const [currentDate, setCurrentDate] = useState<string>(genDateStr())
  const onConfirm = (value: string) => {
    setCurrentDate(value)
    hidePopup?.()
  }

  const { portalPopup, togglePopup, hidePopup } = usePopup(
    <DatePickerPopup onConfirm={onConfirm} onCancel={() => hidePopup()} />,
    false
  )

  return (
    <>
      {portalPopup}
      <span
        onClick={togglePopup}
        className={cn('flex justify-start items-center gap-x-[.5em] pr-1em', className)}>
        <SvgIcon
          name="salary-calendar"
          className="grow-0 shrink-0 size-[2.5em] mx-1 pl-.5em" />
        <time
          dateTime={currentDate}
          className="grow-1 shrink-1 animation-rainbow text-[14px] font-mono color-[var(--vp-c-brand-next)]">
          {currentDate}
        </time>
      </span>
    </>
  )
}

5. 修复数据共享引起的bug ⇧

DateTime.ts 日期库返回日期拷贝 ⇧

@/lib/helpers/DateTime.ts

  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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
export type DateTimeParts = {
  year: number
  month: number
  day: number
  hour: number
  minute: number
  second: number
  ms: number
}
export type DateTimeUnit = 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'ms'
type DateTimeParams = number | string | Date

export class DateTime {
  #date: Date
  constructor(p?: DateTimeParams) {
    this.#date = p ? new Date(p) : new Date()
  }
  getTimestamp() {
    return this.#date.getTime()
  }
  // DateTime {day, month, hour,... }
  get originDate() {
    return new DateTime(this.#date)
  }
  // Date: Sat Nov 07 2026 23:03:43 GMT-0800 (Pacific Standard Time)
  get rawDate() {
    return new Date(this.#date)
  }
  get date() {
    return new Date(this.#date)
  }
  // '2026-11-07T23:03:43.000Z' '2026-11-08T07:07:13.295Z'
  get isoString() {
    return this.#date.toISOString()
  }
  // '2026-11-07 23:03:43' '11/7/2026, 11:07:13 PM'
  get localString() {
    return this.#date.toLocaleString()
  }
  // '2026-11-07' '11/7/2026'
  get localDateString() {
    return this.#date.toLocaleDateString()
  }
  // t = new DateTime(); t.parts.year;
  get parts(): DateTimeParts {
    return {
      year: this.#date.getFullYear(),
      month: this.#date.getMonth() + 1,
      day: this.#date.getDate(),
      hour: this.#date.getHours(),
      minute: this.#date.getMinutes(),
      second: this.#date.getSeconds(),
      ms: this.#date.getMilliseconds(),
    }
  }

  // p = {year: 2000, month: 10}; t.parts = p;
  set parts(p: Partial<DateTime['parts']>) {
    const table = {
      year: 'setFullYear',
      month: 'setMonth',
      day: 'setDate',
      hour: 'setHours',
      minute: 'setMinutes',
      second: 'setSeconds',
      ms: 'setMilliseconds',
    } as const
    Object.entries(p).forEach(([key, value]) => {
      const methodsName = table[key as keyof typeof table]
      value = key === 'month' ? value - 1 : value
      this.#date[methodsName](value)
    })
  }

  get year(): number {
    return this.parts.year
  }
  set year(value: number) {
    this.parts = { year: value }
  }
  get month(): number {
    return this.parts.month
  }
  set month(value: number) {
    this.parts = { month: value }
  }
  get day(): number {
    return this.parts.day
  }
  set day(value: number) {
    this.parts = { day: value }
  }
  get hour(): number {
    return this.parts.hour
  }
  set hour(value: number) {
    this.parts = { hour: value }
  }
  get minute(): number {
    return this.parts.minute
  }
  set minute(value: number) {
    this.parts = { minute: value }
  }
  get second(): number {
    return this.parts.second
  }
  set second(value: number) {
    this.parts = { second: value }
  }
  get ms(): number {
    return this.parts.ms
  }
  set ms(value: number) {
    this.parts = { ms: value }
  }

  get lastDayOfMonth() {
    return new DateTime(new Date(this.year, this.month - 1 + 1, 0)).day
  }
  // t.add(-10, 'year').year
  add(n: number, unit: DateTimeUnit) {
    const table = {
      year: 'year',
      month: 'month',
      day: 'day',
      hour: 'hour',
      minute: 'minute',
      second: 'second',
      ms: 'ms',
    } as const
    this[table[unit]] += n
    return this
  }
  /**
   * 格式化输出
   * https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-date-time-string-format
   * @pattern 目前只支持 YYYY-MM-DDTHH:mm:ss.sssZ
   * @memberof DateTime
   * @example (new DateTime()).format('YYYY-MM-DDTHH:mm:ss.sssZ') DateTime
   * @example (new DateTime()).format('YYYY年MM月DD日') DateTime
   */
  format(pattern = 'YYYY-MM-DD') {
    return pattern
      .replace(/YYYY/g, this.year.toString())
      .replace(/MM/g, this.month.toString().padStart(2, '0'))
      .replace(/DD/g, this.day.toString().padStart(2, '0'))
      .replace(/HH/g, this.hour.toString().padStart(2, '0'))
      .replace(/mm/g, this.minute.toString().padStart(2, '0'))
      .replace(/ss/g, this.second.toString().padStart(2, '0'))
      .replace(/sss/g, this.ms.toString().padStart(3, '0'))
  }
}

export const createDateTime = (p?: DateTimeParams) => new DateTime(p)
  • get date() { return new Date(this.#date) } 使得外部不可通过读取 .date 直接修改实例对象内部数据
  • 即返回 Date 副本,避免引用被外部篡改

DatePickerPopup 新增 useEffect 同步外部 value 变化,防止组件内外日期不一致 ⇧

  • 目前日期选择组件只要修改过其中的年月日日期,就算点击取消,再次打开日期选择框,会出现内外日期不一致
  • 这是由于组件内没有监听外部传参变化,实时改变组建自己的状态 ``

@/components/form/DatePickerPopup.tsx

  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
// import ...

interface Props {
  className?: string
  visible?: boolean
  start?: Date
  end?: Date
  value?: Date
  dateItemHeight?: number
  onCancel?: () => void
  onConfirm?: (dateTimeStr: string) => void
}

export const DatePickerPopup: React.FC<Props> = ({
   className,
   visible,
   start,
   end,
   value,
   dateItemHeight = 36,
   onCancel,
   onConfirm,
 }) => {
  const startTime = start ? createDateTime(start) : createDateTime().add(-3, 'year')
  const endTime = end ? createDateTime(end) : createDateTime().add(3, 'year')
  if (startTime.getTimestamp() >= endTime.getTimestamp()) {
    throw new Error('startTime must be greater than endTime')
  }

  const valueTime = useRef(value ? createDateTime(value) : createDateTime())
  const [_, forceUpdate] = useState({})
  // 同步更新外部日期,防止组件内外的日期不一致
  useEffect(() => {
    if (!value) {
      return
    }
    valueTime.current = createDateTime(value)
    forceUpdate({})
  }, [value])

  const yearList = Array.from({ length: endTime.year - startTime.year + 1 })
    .map((_, i) => startTime.year + i)
    .map((item) => item.toString())
  const monthList = Array.from({ length: 12 })
    .map((_, i) => i + 1)
    .map((item) => item.toString())
  const dayList = Array.from({ length: valueTime.current.lastDayOfMonth })
    .map((_, i) => i + 1)
    .map((item) => item.toString())

  const onChangeYear = (value: string) => {
    valueTime.current.year = parseInt(value)
    forceUpdate({})
  }
  const onChangeMonth = (value: string) => {
    valueTime.current.month = parseInt(value)
    forceUpdate({})
  }
  const onChangeDay = (value: string) => {
    valueTime.current.day = parseInt(value)
    forceUpdate({})
  }

  return (
    <section className={className}>
      {/* 顶部按钮 */}
      <div
        className={cn(
          'flex justify-between p-[.5em]',
          'bg-white b-b-1 b-b-[#f3f3f3]',
          'children:(p-[.5em])'
        )}>
        <span onClick={onCancel}>取消</span>
        <span>日期选择</span>
        <span onClick={() => onConfirm?.(valueTime.current.format('YYYY-MM-DD'))}>确认</span>
      </div>

      {/* 日期选择 */}
      <div className={cn('flex relative')}>
        <DateTimeColumn
          className="grow-1"
          dateTimeValue={valueTime.current.year.toString()}
          dateTimeList={yearList}
          dateItemHeight={dateItemHeight}
          onChange={onChangeYear}
        />
        <DateTimeColumn
          className="grow-1"
          dateTimeValue={valueTime.current.month.toString()}
          dateTimeList={monthList}
          dateItemHeight={dateItemHeight}
          onChange={onChangeMonth}
        />
        <DateTimeColumn
          className="grow-1"
          dateTimeValue={valueTime.current.day.toString()}
          dateTimeList={dayList}
          dateItemHeight={dateItemHeight}
          onChange={onChangeDay}
        />
        <div
          className={cn(
            'picker-mask z-1 pointer-events-none w-full h-full',
            'absolute top-0 left-1em right-1em transform-gpu',
            'bg-[image:var(--picker-mask-color)] bg-no-repeat',
            'bg-[position:center_top,center_bottom] bg-[size:100%_110px]'
          )}></div>
        <div
          className={cn(
            'pricker-frame z-2 h-[36px] pointer-events-none',
            'absolute top-[50%] left-[1em] right-[1em] translate-y-[-50%]',
            'animation-rainbow after:(b-color-[var(--vp-c-brand-next)])',
            'after:(content-[""] b-y-2.5)',
            'after:(absolute top-[-50%] bottom-[-50%] left-[-50%] right-[-50%] scale-[.5])'
          )}></div>
      </div>
    </section>
  )
}

@/modules/items/input/DateInput.tsx 向 DatePickerPopup 传递当前 value

 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
import { DatePickerPopup } from '@/components/form/DatePickerPopup'
import { SvgIcon } from '@/components/SvgIcon'
import { usePopup } from '@/hooks/usePopup'
import { genDateStr } from '@/lib/helpers/genDateStr'
import { cn } from 'cn'
import { useState } from 'react'

interface Props {
  className?: string
}

export const DateInput: React.FC<Props> = ({ className }) => {
  const [currentDate, setCurrentDate] = useState<string>(genDateStr())
  const onConfirm = (value: string) => {
    setCurrentDate(value)
    hidePopup?.()
  }

  const { portalPopup, togglePopup, hidePopup } = usePopup(
    <DatePickerPopup
      value={new Date(currentDate)}
      onConfirm={onConfirm}
      onCancel={() => hidePopup()}
    />,
    false
  )

  return (
    <>
      {portalPopup}
      <span
        onClick={togglePopup}
        className={cn('flex justify-start items-center gap-x-[.5em] pr-1em', className)}>
        <SvgIcon name="salary-calendar" className="grow-0 shrink-0 size-[2.5em] mx-1 pl-.5em" />
        <time
          dateTime={currentDate}
          className={cn(
            'grow-1 shrink-1 text-[14px] font-mono',
            'animation-rainbow color-[var(--vp-c-brand-next)]'
          )}>
          {currentDate}
        </time>
      </span>
    </>
  )
}

·未完待续·

参考文章

  • 无

相关文章

  • 无

  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名