【山竹记账前端-react】18.自制日期选择器


大纲链接 §

[toc]


1. 搞定输入面板的样式 ⇧

数字面板的样式 ⇧

vscode多行文字内容包裹html标签技巧

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
1
2
3
clear
4
5
6
backspace
7
8
9
submit
0
dot
  • 选中需要包裹标签的所有行
  • 按快捷键 ctrl+shift+p 搜索框输入 Emmet W
  • 选择 Emmet Wrap With Abbreviation
  • 输入 button 的话只能添加一个标签包裹所有内容;输入 button* 可以每行都包裹标签,按回车确认
  • webstorm 中类似快捷键为 ctrl+alt+t,但必须多行选中

根据数字键盘UI,先创建数字按键列表文件 @src/modules/items/useNumpadButtons.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
// import type { SvgIconName } from '@svgoStore'
// import { useNumpadAmountStore } from '@/stores/useNumpadAmountStore.ts'
import { cn } from 'cn'

type ButtonItem<T extends boolean = boolean> = {
  id: string
  text: string
  onClick: () => void
  extraClass?: string
} & (T extends true
  ? {
    hasIcon: T
    iconName: string //SvgIconName
  }
  : {
    hasIcon?: false
    iconName?: never
  })

/**
 * @Description: 数字按键列表
 * @Description: 注意使用 cn 函数拼接类名,必须在 TSX 中使用
 */
export const useNumpadButtons = () => {
  // const { appendText, clearText, backspaceText } = useNumpadAmountStore()
  const appendText = (str: string) => void str
  const backspaceText = () => void 0
  const clearText = () => void 0
  const numpadButtons: ButtonItem[] = [
    { id: '1', text: '1', onClick: () => appendText('1') },
    { id: '2', text: '2', onClick: () => appendText('2') },
    { id: '3', text: '3', onClick: () => appendText('3') },
    { id: 'clear', text: Math.random() > 0.5 ? '归零' : '清空', onClick: () => clearText() },
    { id: '4', text: '4', onClick: () => appendText('4') },
    { id: '5', text: '5', onClick: () => appendText('5') },
    { id: '6', text: '6', onClick: () => appendText('6') },
    {
      id: 'backspace',
      text: '退格',
      onClick: () => backspaceText(),
      hasIcon: true,
      iconName: 'backspace',
      extraClass: cn('children:(size-1.5em)'),
    },
    { id: '7', text: '7', onClick: () => appendText('7') },
    { id: '8', text: '8', onClick: () => appendText('8') },
    { id: '9', text: '9', onClick: () => appendText('9') },
    {
      id: 'submit',
      text: '提交',
      onClick: () => {
        clearText()
      },
      extraClass: cn('bg-[var(--pad-submit-button-bg-color)] text-[var(--button-text-color)]'),
    },
    { id: '0', text: '0', onClick: () => appendText('0') },
    {
      id: 'dot',
      text: 'dot',
      onClick: () => appendText('.'),
      hasIcon: true,
      iconName: 'decimal_point',
      extraClass: cn('children:(size-2em translate-y-[-9px] font-extrabold)'),
    },
  ]

  return { numpadButtons }
}
  • extraClass 可用于当前按钮额外unocss样式拼接
  • 注意使用 cn 函数拼接类名,必须在 .tsx 文件中使用,在.ts的使用话无法识别
  • 按钮操作功能未实现,useNumpadAmountStore 待实现按钮输入,金额显示逻辑
  • iconName 为自定义图标组件名称属性 <SvgIcon name={iconName} /> 待实现更具体的图标名称类型提示
  • 可使用图标/文字作为按钮内容

实现基本样式结构 @/modules/items/input/Numpad.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
import { SvgIcon } from "@/components/SvgIcon";
import { useNumpadButtons } from "@/modules/items/input/useNumpadButtons.tsx";
import { cn } from "cn";
import s from './Numpad.module.scss'

interface Props {
  className?: string
}

export const Numpad: React.FC<Props> = ({ /* className, */ }) => {
  const numberSizeClass = (text: string) =>
  (/[0-9|dot]/g.test(text)
    ? cn('text-18px')
    : '')
  const { numpadButtons } = useNumpadButtons()

  return (
    <div
      className={cn(
        'grid grid-cols-[repeat(4,1fr)] grid-rows-[repeat(4,56px)]',
        'where:(bg-[var(--pad-border-color)]) gap-1px p-t-1px',
      )}
      style={{
        'gridTemplateAreas': `
          'btn_1 btn_2 btn_3 btn_clear'
          'btn_4 btn_5 btn_6 btn_backspace'
          'btn_7 btn_8 btn_9 btn_submit'
          'btn_0 btn_0 btn_dot btn_submit'
          `,
      }}>
      {numpadButtons.map((buttonItem) => (
        <button
          type="button"
          key={buttonItem.text}
          data-btn-text={`btn_${buttonItem.id}`}
          onClick={buttonItem.onClick}
          style={{
            'gridArea': `btn_${buttonItem.id}`,
          }}
          className={cn(
            'flex justify-center items-center font-bold',
            'bg-[var(--pad-button-bg-color)] b-unset',
            buttonItem?.extraClass,
            numberSizeClass(buttonItem.text),
            s['button-item'],
          )}>
          {buttonItem.hasIcon ? <SvgIcon name={buttonItem.iconName} /> : buttonItem.text}
        </button>
      ))}
    </div>
  )
}
  • 注意 React.JSX,正确使用属性名称,否则会报错 'grid-area'”不在类型“Properties<string | number, string & {}>
    • 使用 className,而不是 class
    • 使用 gridTemplateAreas,而不是 grid-template-areas
    • 使用 gridArea,而不是 grid-area
  • 使用网格布局 grid grid-cols-[repeat(4,1fr)] grid-rows-[repeat(4,56px)]
    • 使用具体的高度数值撑开按钮高度 grid-rows-[repeat(4,56px)];宽度平均分配即可
    • 在容器中使用 gridTemplateAreas 指定具名按钮位置
    • 在每一个网格,即按钮上,使用 gridArea 指定按钮名称;
    • 遍历 numpadButtons 指定 gridArea 为 btn_${buttonItem.id}
    • 和 gridTemplateAreas 的名称位置一一对应
    • 比起一个个按钮元素上指定 row-start-* col-start-* row-end-* col-end-* 更直观
    • 缺点是无法使用 CSS Grid Generator Tool 直接获取
  • 键盘整体颜色设置为 #ddd,并且设置间隔 gap-1px,各个按钮背景色为白色,从而透出类似边框的样式,这样不用考虑边框重合问题
  • 使用按钮数据 const { numpadButtons } = useNumpadButtons()
  • 可使用图标/文字作为按钮内容 {buttonItem.hasIcon ? <SvgIcon name={buttonItem.iconName} /> : buttonItem.text}

日期输入框和金额显示框的样式 ⇧

@/modules/items/input/InputPad.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// import ...

export const InputPad: React.FC<Props> = ({ /* className, */ }) => {

  return (
    <>
      {/* 日期输入、金额显示 */}
      <div className={cn(
        'flex justify-between items-center',
        'min-h-69px p-t-15px p-b-1em b-t-[var(--pad-border-color)] b-t-1',
      )}>
        <DateInput />
        <AmountNumberOutput className="mr-4" />
      </div>
      {/* 数字键盘 */}
      <Numpad />
    </>
  )
}

@/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
import { SvgIcon } from "@/components/SvgIcon";
import { genDateStr } from "@/lib/helpers/genDateStr";
import { useState } from "react";

interface Props {
  className?: string
}

export const DateInput: React.FC<Props> = ({ /* className, */ }) => {
  const [currentDate,] = useState<string>(genDateStr())
  const [datePickerVisible, setDatePickerVisible] = useState<boolean>(false)
  const toggleDatePicker = () => {
    setDatePickerVisible(!datePickerVisible)
  }
  return (
    <span className="flex justify-start items-center gap-x-[.5em]">
      <SvgIcon onClick={toggleDatePicker} name="salary-calendar" className="grow-0 shrink-0 size-[2.5em] mx-1 pl-.5em" />
      <span className="grow-1 shrink-1 animation-rainbow text-[14px] font-mono color-[var(--vp-c-brand-next)]">
        <span onClick={toggleDatePicker}>{currentDate}</span>
      </span>
    </span>
  )
}

@/modules/items/input/AmountNumberOutput.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { cn } from "cn";

interface Props {
  className?: string
}

export const AmountNumberOutput: React.FC<Props> = ({ className, }) => {
  return (
    <span className={cn(
      'text-[24px] font-mono color-[var(--amount-text-color)]',
      className,
    )}>
      123456789.01
    </span>
  )
}

2. 创建 Popup 组件 ⇧

  • 有两种形式可以控制组件的显示/隐藏
    • { visible ? <PopUp /> : null } 在组件外部控制,但不方便设置组件自身的动画
    • <PopUp visible={visible} /> 将 visible 作为属性参数传入组件,方便控制组件自身动画

@/components/ui/PopUp.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
import { OverlayMask } from "@/components/menu/OverlayMask"
import { animated, useSpring } from "@react-spring/web"
import { cn } from "cn"

interface Props {
  className?: string
  visible: boolean
  onClickMask?: () => void
}

export const PopUp: React.FC<Props> = ({ className, visible, onClickMask, }) => {
  const menuStyle = useSpring({
    transform: visible ? 'translateY(0%)' : 'translateY(100%)',
    opacity: visible ? 1 : 0,
    config: {
      tension: 200,
      friction: 20,
      duration: 180,
    },
  })

  return (
    <div className={cn(
      'z-[var(--z-popup)]',
      className,
    )}>
      {/* 遮罩层 */}
      <OverlayMask
        visible={visible}
        onClickMask={onClickMask}
        className="bg-black:50" />

      {/* 内容 */}
      <animated.div
        style={menuStyle}
        className={cn(
          'fixed bottom-0 left-0 w-full min-h-300px bg-red',
          'z-[calc(var(--z-popup)+1)]',
        )}>
        visible {`${visible}`}
      </animated.div>
    </div>
  )
}
  • 可复用 OverlayMask 组件
  • useSpring 动画配置从下往上显示 transform: visible ? 'translateY(0%)' : 'translateY(100%)',
  • 调整 z-index 层级 z-[var(--z-popup)]

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

interface Props {
  className?: string
}

export const DateInput: React.FC<Props> = ({ className, }) => {
  const [currentDate,] = useState<string>(genDateStr())
  const [datePickerVisible, setDatePickerVisible] = useState<boolean>(false)
  const toggleDatePicker = () => {
    setDatePickerVisible(!datePickerVisible)
  }

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

      <PopUp
        visible={datePickerVisible}
        onClickMask={toggleDatePicker}/>
    </>
  )
}

3. 封装 usePopup 钩子 ⇧

  • 目前使用 PopUp 时必须现在父组件声明变量 visible,和点击隐藏的函数
  • 可以封装一个钩子简化使用

初步封装 ⇧

@/hooks/usePopup.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { PopUp } from '@/components/ui/PopUp';
import { useState, } from 'react'

export const usePopup = () => {
  const [visible, setVisible] = useState(false)

  const showPopup = () => { setVisible(true) }
  const hidePopup = () => { setVisible(false) }
  const togglePopup = () => { setVisible(!visible) }
  const popup = <PopUp visible={visible} onClickMask={() => hidePopup()} />

  return {
    popup,
    showPopup,
    hidePopup,
    togglePopup,
  }
}

@/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
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,] = useState<string>(genDateStr())
  const { popup, togglePopup, } = usePopup()
  return (
    <>
      <span
        onClick={togglePopup}
        className={cn(
          'flex justify-start items-center gap-x-[.5em]',
          className,
        )}>
        <SvgIcon name="salary-calendar" className="grow-0 shrink-0 size-[2.5em] mx-1 pl-.5em" />
        <span className="grow-1 shrink-1 animation-rainbow text-[14px] font-mono color-[var(--vp-c-brand-next)]">
          {currentDate}
        </span>
      </span>
      { popup }
    </>
  )
}

使用传送门 createPortal ⇧

@/hooks/usePopup.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { PopUp } from '@/components/ui/PopUp';
import { useState, } from 'react'
import { createPortal } from 'react-dom';

export const usePopup = () => {
  const [visible, setVisible] = useState(false)

  const showPopup = () => { setVisible(true) }
  const hidePopup = () => { setVisible(false) }
  const togglePopup = () => { setVisible(!visible) }
  const popup = <PopUp visible={visible} onClickMask={() => hidePopup()} />
  const portalPopup = createPortal(popup, document.body)
  return {
    popup,
    portalPopup,
    showPopup,
    hidePopup,
    togglePopup,
  }
}

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

export const DateInput: React.FC<Props> = ({ className, }) => {
  const [currentDate,] = useState<string>(genDateStr())
  const { portalPopup, togglePopup, } = usePopup()
  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" />
        <span className="grow-1 shrink-1 animation-rainbow text-[14px] font-mono color-[var(--vp-c-brand-next)]">
          {currentDate}
        </span>
      </span>
    </>
  )
}
  • portalPopup 元素被传送到 body 中,在 root 元素后
  • portalPopup 元素的绝对定位位置不会被任何元素的 transform 阻挡而导致定位以 transform的元素 为父元素相对定位,而不是以根元素相对定位
  • 也可以将 portalPopup 传送到 root 元素中
    • 需要在 main.ts 中导出 export const rootElement = document.getElementById('root') as HTMLElement
    • 在组件中引入 const portalPopup = createPortal(popup, rootElement)
  • const portalPopup = createPortal(popup, rootElement) 自定义钩子逻辑中存在bug:避免每次调用钩子都会产生一个新的 portalPopup

重复创建传送节点

  • 处理方式-在静态页面中,在 div#root 的平级下方,手动添加一个专门的挂载容器 div#portal-root
  • 在 Hook 中直接通过原生 DOM API 获取

@/hooks/usePopup.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
// import ...

// 提前在模块顶层获取引用(只会执行一次)
const portalElement = document.getElementById('portal-root')

export const usePopup = () => {
  if (!portalElement) {
    // 容错处理:如果项目有 SSR(服务端渲染)或者由于特殊原因节点未加载
    throw new Error('未找到 portal-root 节点')
  }
  const [visible, setVisible] = useState(false)
  const showPopup = () => { setVisible(true) }
  const hidePopup = () => { setVisible(false) }
  const togglePopup = () => { setVisible(!visible) }
  const popup = <PopUp visible={visible} onClickMask={() => hidePopup()} />
  const portalPopup = createPortal(popup, portalElement, useId())
  return {
    popup,
    portalPopup,
    showPopup,
    hidePopup,
    togglePopup,
  }
}

参考


@/hooks/usePopup.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
import { PopUp } from '@/components/ui/PopUp';
import { useId, useState, } from 'react'
import { createPortal } from 'react-dom';

// 提前在模块顶层获取引用(只会执行一次)
const portalElement = document.getElementById('portal-root')

export const usePopup = (children?: React.ReactNode) => {
  if (!portalElement) {
    // 容错处理:如果项目有 SSR(服务端渲染)或者由于特殊原因节点未加载
    throw new Error('未找到 portal-root 节点')
  }
  const [visible, setVisible] = useState(false)
  const showPopup = () => { setVisible(true) }
  const hidePopup = () => { setVisible(false) }
  const togglePopup = () => { setVisible(!visible) }
  const popup = (
  <PopUp visible={visible} onClickMask={() => hidePopup()} >
    {children}
  </PopUp>
)
  const portalPopup = createPortal(popup, portalElement, useId())

  return {
    popup,
    portalPopup,
    showPopup,
    hidePopup,
    togglePopup,
  }
}
  • usePopup 接受传参 children

@/components/ui/PopUp.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
// import ...

interface Props {
  className?: string
  visible: boolean
  onClickMask?: () => void
  children?: React.ReactNode
}

export const PopUp: React.FC<Props> = ({ className, visible, onClickMask, children, }) => {
  const menuStyle = useSpring({/* ... */})
  return (
    <div className={cn(
      'z-[var(--z-popup)]',
      className,
    )}>
      {/* 遮罩层 */}
      <OverlayMask
        visible={visible}
        onClickMask={onClickMask}
        className="bg-black:50" />

      {/* 内容 */}
      <animated.div
        style={menuStyle}
        className={cn(
          'popup-content',
          s.popupContent,
          'fixed bottom-0 left-0 w-full min-h-300px bg-red',
          'z-[calc(var(--z-popup)+1)]',
        )}>
        {children}
      </animated.div>
    </div>
  )
}

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

export const DateInput: React.FC<Props> = ({ className, }) => {
  const [currentDate,] = useState<string>(genDateStr())
  const { portalPopup, togglePopup, } = usePopup(<div>TODO: 日期组件</div>)
  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" />
        <span className="grow-1 shrink-1 animation-rainbow text-[14px] font-mono color-[var(--vp-c-brand-next)]">
          {currentDate}
        </span>
      </span>
    </>
  )
}

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
import { cn } from 'cn'
import { TouchEventHandler, useState } from 'react'

interface Props {
  className?: string
}

export const DatePickerPopup: React.FC<Props> = ({/* className, */}) => {
  const [isTouching, setIsTouching] = useState(false)
  const [lastY, setLastY] = useState(-1)
  const [translateY, setTranslateY] = useState(0)
  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)
    console.log('onTouchMove_______________________')
    console.log('%c⧭ deltaY', 'color: #bfffc8', deltaY)
    console.log('_______________________onTouchMove')
    setLastY(currentY)
  }
  const onTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
    setIsTouching(false)
  }

  return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      className="date-picker-wrapper h-[50vh] overflow-clip relative">
      <div
        className={cn(
          'highlight-select-box h-[36px] w-full',
          'absolute top-[calc(50%-18px)] left-0',
          'border b-solid b-red'
        )}></div>
      <div className="picker-positioner h-[36px] w-full absolute top-[calc(50%-18px)]">
        <ol
          style={{ transform: `translateY(${translateY}px)` }}
          className="year-picker children:(h-[36px] leading-[36px]) text-center">
          <li>2021</li>
          <li>2022</li>
          <li>2023</li>
          <li>2024</li>
          <li>2025</li>
          <li>2026</li>
          <li>2027</li>
          <li>2028</li>
          <li>2029</li>
          <li>2030</li>
          <li>2031</li>
          <li>2021</li>
          <li>2022</li>
          <li>2023</li>
          <li>2024</li>
          <li>2025</li>
          <li>2026</li>
          <li>2027</li>
          <li>2028</li>
          <li>2029</li>
          <li>2030</li>
          <li>2031</li>
        </ol>
      </div>
    </div>
  )
}
  • 样式
    • 外部容器高度写死 date-picker-wrapper,否则内部绝对定位元素会塌陷
    • 高亮选择框 highlight-select-box 绝对定位到容器一半的高度再向上自身一半高度,居中位置
    • 选择框定位器 picker-positioner 使其定位初始位置和高亮选择框一致
    • 年份选择框 year-picker 可移动样式 style={{ transform:translateY(${translateY}px)}}
    • 选择框内部元素统一给高度、行高 h-[36px] leading-[36px] 样式居中
  • 逻辑
    • 开始滑动时
    • 设置滑动状态 setIsTouching(true)
    • 记录滑动初始 Y 位置 setLastY(e.touches[0].clientY)
    • 滑动中
    • 计算 Y 位置滑动距离
    • 更新设置移动样式 setTranslateY(translateY + deltaY),年份选择框移动样式更新 style={{ transform:translateY(${translateY}px)}}
    • 记录结束滑动 Y 位置 setLastY(currentY)
    • 结束滑动 setIsTouching(false)

目前选择年份的位置还不能精确确定,总是有偏移,下一章节处理

  • 需要将偏移的 Y 改为 36px 的倍数即可

5. 自制日期选择器(中) ⇧

移动距离会在用户松开手指时变成36的整数倍 ⇧

  • 将偏移的 Y 改为 36px 的倍数,找最接近的倍数值,取余数,和半值比较
    • 小于半值的,减掉
    • 大于半值的,加上不足的
    • 还需要分情况:余数是否大于0
  • 不可在 touchMove 的时候设置整数值,需要在结束松开手指时设置,否则会滑不动

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

export const DatePickerPopup: React.FC<Props> = ({/* className, */}) => {
  const [isTouching, setIsTouching] = useState(false)
  const [lastY, setLastY] = useState(-1)
  const [translateY, setTranslateY] = useState(0)
  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 % 36
    if (remainder > 0) {
      remainder < 18
        ? setTranslateY(translateY - remainder)
        : setTranslateY(translateY + 36 - remainder)
    } else {
      remainder < -18
        ? setTranslateY(translateY - 36 - remainder)
        : setTranslateY(translateY - remainder)
    }

    setIsTouching(false)
  }

  return (
    <div
      onTouchStart={onTouchStart}
      onTouchMove={onTouchMove}
      onTouchEnd={onTouchEnd}
      className="date-picker-wrapper h-[50vh] overflow-clip relative">
      <div
        className={cn(
          'highlight-select-box h-[36px] w-full',
          'absolute top-[calc(50%-18px)] left-0',
          'border b-solid b-red'
        )}></div>
      <div className="picker-positioner w-full absolute top-[calc(50%-18px-180px)]">
        <ol
          style={{ transform: `translateY(${translateY}px)` }}
          className="year-picker children:(h-[36px] leading-[36px]) text-center">
          <li>2021</li>
          <li>2022</li>
          <li>2023</li>
          <li>2024</li>
          <li>2025</li>
          <li>2026</li>
          <li>2027</li>
          <li>2028</li>
          <li>2029</li>
          <li>2030</li>
          <li>2031</li>
          <li>2021</li>
          <li>2022</li>
          <li>2023</li>
          <li>2024</li>
          <li>2025</li>
          <li>2026</li>
          <li>2027</li>
          <li>2028</li>
          <li>2029</li>
          <li>2030</li>
          <li>2031</li>
        </ol>
      </div>
    </div>
  )
}

重构余数逻辑逻辑,将移动距离吸附为36px整数倍的完整实现 ⇧

 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
const onTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
  const remainder = translateY % 36
  if (remainder > 0) {
    remainder < 18
      ? setTranslateY(translateY - remainder)
      : setTranslateY(translateY + 36 - remainder)
  } else {
    remainder < -18
      ? setTranslateY(translateY - 36 - remainder)
      : setTranslateY(translateY - remainder)
  }
  //...
}

// 小于-18的情况交互正负逻辑
const onTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
  const remainder = translateY % 36
  if (remainder > 0) {
    remainder < 18
      ? setTranslateY(translateY - remainder)
      : setTranslateY(translateY + 36 - remainder)
  } else {
    remainder > -18
      ? setTranslateY(translateY - remainder)
      : setTranslateY(translateY - 36 - remainder)
  }
  //...
}

// ±36 的符号和余数的符号相同,可以改为加上 36 * (remainder > 0 ? 1 : -1)
const onTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
  const remainder = translateY % 36
  if (remainder > 0) {
    remainder < 18
      ? setTranslateY(translateY - remainder)
      : setTranslateY(translateY + 36 * (remainder > 0 ? 1 : -1) - remainder)
  } else {
    remainder > -18
      ? setTranslateY(translateY - remainder)
      : setTranslateY(translateY + 36 * (remainder > 0 ? 1 : -1) - remainder)
  }
  //...
}

// remainder > -18 和 remainder < 18 的情况统一归纳为 Math.abs(remainder) < 18
const onTouchEnd: TouchEventHandler<HTMLDivElement> = (e) => {
  const remainder = translateY % 36
  if (Math.abs(remainder) < 18) {
    setTranslateY(translateY - remainder)
  } else {
    setTranslateY(translateY + 36 * (remainder > 0 ? 1 : -1) - remainder)
  }
  //...
}

// 整数倍值 translateY - remainder
if (Math.abs(remainder) < 18) {
  setTranslateY(translateY - remainder)
} else {
  setTranslateY(translateY - remainder + 36 * (remainder > 0 ? 1 : -1))
}

// 最终改为:损有余 补不足
const remainder = translateY % 36
let resultY = translateY - remainder
if (Math.abs(remainder) > 18) {
  resultY += 36 * (remainder > 0 ? 1 : -1)
}
setTranslateY(resultY)

6. 插曲:封装一个时间库 ⇧

动态生成日期 ⇧

@/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
import { createJSONStorage } from 'zustand/middleware'

type Parts = {
  year: number
  month: number
  day: number
  hour: number
  minute: number
  second: number
  ms: number
}
type Unit = '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()
  }
  // t = new DateTime(); t.parts.year;
  get parts(): Parts {
    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 }
  }

  // t.add(-10, 'year').year
  add(n: number, unit: Unit) {
    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)
  • createDateTime([2026, 12, 8].join('-'))
  • 支持获取年月日 (new DateTime()).year
  • 支持设置年月日 (new DateTime()).year = {year: 2026}
  • 格式化输出目前只支持 YYYY-MM-DDTHH:mm:ss.sssZ
  • 获取日期 t.add(-10, 'year').year

参考


7. 自制日期选择器(下) ⇧

动态渲染年份列 ⇧

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

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 halfDateItemHeight = dateItemHeight / 2
  const style = {
    '--date-item-height': `${dateItemHeight}px`,
    '--half-date-item-height': `${halfDateItemHeight}px`,
  } as React.CSSProperties
  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 currentYearIndex = yearList.indexOf(valueTime.year)

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

  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}
      className={cn('date-picker-wrapper h-[50vh] overflow-clip relative', className)}
      style={style}>
      <div
        className={cn(
          'highlight-select-box h-[var(--date-item-height)] w-full',
          'absolute top-[calc(50%-var(--half-date-item-height))] left-0',
          'border b-solid b-red'
        )}></div>
      <div className="picker-positioner w-full absolute top-[calc(50%-var(--half-date-item-height))]">
        <ol
          style={{ transform: `translateY(${translateY}px)` }}
          className="year-picker text-center">
          {yearList.map((year) => (
            <li
              key={year}
              className="h-[var(--date-item-height)] leading-[var(--date-item-height)]">
              {year}
            </li>
          ))}
        </ol>
      </div>
    </div>
  )
}
  • 通过 dateItemHeight 外部参数指定每项年份的高度
    • 取余值的运算都给为参数 const remainder = translateY % dateItemHeight
  • 在组件根节点添加 style 属性声明 CSS 变量
    • --date-item-height 和 --half-date-item-height 供组件内样式使用
    • h-[var(--date-item-height)]
    • 定位半高值 top-[calc(50%-var(--half-date-item-height))]
    • 行高值 leading-[var(--date-item-height)]
  • 根据 start/end 动态生成年份列表,默认范围前后3年
    • const startTime = start ? createDateTime(start) : createDateTime().add(-3, 'year')
    • const endTime = end ? createDateTime(end) : createDateTime().add(3, 'year')
    • const yearList = Array.from({ length: endTime.year - startTime.year + 1 }).map( (_, i) => startTime.year + i )
  • 根据传入的 value 计算初始索引,使选择器定位到当前值
    • const valueTime = value ? createDateTime(value) : createDateTime()
    • const currentYearIndex = yearList.indexOf(valueTime.year)
    • 设置位移初始值 const [translateY, setTranslateY] = useState(currentYearIndex * -dateItemHeight)

参考

  • 如何在 tsx 的 styles 中声明 css 变量

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    // 全局扩展
    // global.d.ts
    import 'react';
    
    declare module 'react' {
    interface CSSProperties {
    // 允许任意以 -- 开头的 CSS 变量
    [key: `--${string}`]: string | number | undefined;
    }
    }
    // 配置完成后,在任何 .tsx 文件中都可以直接编写,无需类型断言
    // <div style={{ '--bg-color': '#f3f4f6', '--columns': 3 }}>内容</div>
    
  • 数字单位行为:与普通的 React 内联样式(如 { width: 100 } 会自动转为 100px)不同,

  • CSS 变量传入纯数字(如 { '--gap': 10 })不会自动追加 px

  • 如果该变量用于长度或尺寸,请务必显式传入带单位的字符串(如 ‘10px’)


动态渲染年份列 ⇧

``

1


8. 手机真机测试 ⇧

``

1


9. 一列变三列 ⇧

``

1


10. useRef 配合 forceUpdate ⇧

``

1


11. value变化时更新y ⇧

``

1


12. 确定和取消按钮 ⇧

``

1


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

``

1


·未完待续·

参考文章

相关文章

  • 无

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