Skip to content

KyongUI——InputDatePicker

一、前言

最近收到导师项目的要求,需要一个能够显示每天打卡进度的日历,由于各个组件库的日历可能都不要符合项目的需求,于是决定自己设计一个日期选择器。

准备工作:

bash
// 安装dayjs
yarn add dayjs
// 安装lodash
yarn add lodash -D

二、Antd的DatePicker

image-20230310022511422

可以看出Antd将datePicker组件分为三部分,如上图,header(头部)、Body(主要放置当月所有日期)、footer(尾部)

三、Body部位日期主题设计

首先第一步就是得到一个当月日期的二维数组,日历展示的是当月的所有日期以及当月所在第一周和最后一周的剩余日期(我设计的是6×7的日历),设计buildWeeks函数

buildWeeks函数

ts
/**
 * 返回当月日期数组
 * @param dayjsDate 当天日期
 * @returns
 */
export function buildWeeks(dayjsDate: dayjs.Dayjs) {
  //返回当前月份的第一天
  const currentMonthFirstDay = dayjsDate.startOf('month')
  // 返回当月的第一周第一天
  const currentMonthStartDay = currentMonthFirstDay.startOf('week')

  const weeks = new Array(6 * 7)
    .fill(0)
    .map((_, i) => currentMonthStartDay.add(i, 'day'))
  return _.chunk(weeks, 7)
}

表头构造函数buildDayNames

ts
/**
 * 返回一周日期名 
 * @param weekStartsOn 
 * @returns 
 */
export function buildDayNames(weekStartsOn: number): string[] {
  return new Array(7)
    .fill(0)
    .map((_, i) => (i + weekStartsOn) % 7)
    .map(dayOfWeek => {
      const day = dayjs().day(dayOfWeek)
      return day.format('dd')
    })
}

DatePicker.tsx

其中日期单元格使用了我自己自定义的Button,大家也可以自定义日期单元格,isCurrentMonth用透明度区分本月与其他月

tsx
  const weeks = useMemo(() => buildWeeks(dayjs(new Date(year, monthIndex))), [year, monthIndex])
  const dayNames = useMemo(() => buildDayNames(0), [])

<table className={classNames(sc('wrapper'))}>
      <thead className={classNames(sc('header'))}>
        <tr>
          {dayNames.map((dayName, i) => (
            <th key={i}>{dayName}</th>
          ))}
        </tr>
      </thead>
      <tbody className={classNames(sc('weeks'))}>
        {weeks.map((week, i) => (
          <tr key={i} className={classNames(sc('weeks-item'))}>
            {week.map((day, j) => {
              // 目前是当前日期
              const isToday = day.isSame(dayjs(), 'day')
              //  // 当前月日期
              const isCurrentMonth = day.month() === monthIndex
              return (
                <td key={j} className={classNames(sc('day'))}>
                  <Button
                    className={classNames(sc('day-item'), {
                      [`${sc('is-today')}`]: isToday,
                      [`${sc('is-currentMonth')}`]: !isCurrentMonth,
                    })}
                    btnType="text"
                  >
                    {day.format('D')}
                  </Button>
                </td>
              )
            })}
          </tr>
        ))}
      </tbody>
    </table>

如此以来一个日期的基本框架就搭好了

四、布局组件的设计

从上文可知日历分为三部分headerbodyfooter

ViewLayout.tsx

tsx
interface HeaderWrap {
  leftElement: ReactNode
  middleElement: ReactNode
  rightElement: ReactNode
}
export interface ViewLayoutProps {
  bodyElement: ReactNode
  header: HeaderWrap
  footerElement: ReactNode
}

const ViewLayout: FC<ViewLayoutProps> = props => {
  const {
    header: {leftElement, middleElement, rightElement},
    bodyElement,
    footerElement,
  } = props

  return (
    <div className={classNames(sc('container'))}>
      <div className={classNames(sc('header'))}>
        <div>{leftElement}</div>
        <div>{middleElement}</div>
        <div>{rightElement}</div>
      </div>
      <div className={classNames(sc('body'))}>{bodyElement}</div>
      <div className={classNames(sc('footer'))}>{footerElement}</div>
    </div>
  )
}

export default ViewLayout

示例:

tsx
    <ViewLayout
      header={{
        leftElement: <Button btnType="text" icon={<Icon icon="angle-left" />} size="sm" onClick={goToPreviousMonth} />,
        middleElement: <HeaderTitle year={year} monthIndex={monthIndex} onTitleClick={onTitleClick} />,
        rightElement: <Button btnType="text" icon={<Icon icon="angle-right" />} size="sm" onClick={goToNextMonth} />,
      }}
      bodyElement={<DatePicker calendar={props.calendar} selectedDate={selectedDate} onSelectDate={onSelectDate} />}
      footerElement={<Button btnType="text">Today</Button>}
    ></ViewLayout>

简单的日历组件只需要yearmonthIndex两个参数,即可进行日期的控制,所以只需要在最上层组件设置state传参即可,月份和年份的转变通过操作这两个参数即可及时转变,因为较为简单,我就不具体贴代码了。

image-20230310031411051

五、组件联动与状态管理

因为InputDatePicker涉及到Input组件和DatePicker组件的联动,所以在其外面包一层DateManager组件用于数据管理

定义DateManagerState类型

tsx
export type DateManagerState = {
  date: dayjs.Dayjs
  textInput: string
  origin?: 'PICKER' | 'INPUT'
  errors?: any[]
}

DataManager.tsx

tsx
interface DateManagerProps {
  onChange?: (e: ChangeEvent<Element>, value: DateManagerState) => void
  children: React.ReactNode
}

export interface DateContextType {
  value: DateManagerState
  onSelectDate: (e: ChangeEvent<HTMLInputElement>, date: dayjs.Dayjs) => void
  onInputChange: (e: ChangeEvent<HTMLInputElement>) => void
}

export const DateContext = createContext<DateContextType>({
  value: {date: dayjs(), textInput: ''},
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  onSelectDate: () => {},
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  onInputChange: () => {},
})

const DateManager = (props: DateManagerProps) => {
  const {onChange, children} = props
  const [state, setState] = useState<DateManagerState>({
    date: dayjs(),
    textInput: '',
  })

  const onSelectDate = (e: ChangeEvent<HTMLInputElement>, date: dayjs.Dayjs) => {
    const nextState: DateManagerState = {
      date,
      textInput: dateToStr(date),
    }
    setState(nextState)

    onChange && onChange(e, {...nextState, origin: 'PICKER'})
  }

  const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    const textInput = e.target.value
    let errors = []
    let date = dayjs()
    if (textInput) {
      try {
        date = strToDate(textInput)
      } catch (parseErrors) {
        errors = parseErrors as any
      }
    }
    const nextState: DateManagerState = {
      date,
      textInput,
    }
    setState(nextState)
    // 调用外部的onChange函数
    onChange && onChange(e, {...nextState, errors, origin: 'INPUT'})
  }

  const passedContext: DateContextType = {
    value: state,
    onSelectDate,
    onInputChange,
  }

  return <DateContext.Provider value={passedContext}>{children}</DateContext.Provider>
}

export default DateManager

通过onSelectonInputChange两个函数来更新DateManagerState中的状态,从而保证两个组件的数据同步

六、Date处理函数

tsx
import dayjs from 'dayjs'
import objectSupport from 'dayjs/plugin/objectSupport'

dayjs.extend(objectSupport)

// date转str
export function dateToStr(date: dayjs.Dayjs) {
  return date.format('YYYY-MM-DD')
}

function getDateRegexp(dateFormat: string) {
  //MM-dd-YYYY [MM,dd,YYYY]
  const dateFormatAsRegexp = dateFormat.replace(/[A-Za-z]{4}/g, '([0-9]{4})').replace(/[A-Za-z]{2}/g, '([0-9]{1,2})')
  return {
    regexp: new RegExp(`^\\s*${dateFormatAsRegexp}\\s*$`),
    partsOrder: dateFormat.split(/[^A-Za-z]/),
  }
}

function DatePickerException(code: string) {
  return code
}

//str 转Date
export function strToDate(strToParse: string, dateFormat = 'YYYY-MM-DD') {
  const {regexp, partsOrder} = getDateRegexp(dateFormat)
  const dateMatches = strToParse.match(regexp) // 2023-02-15, 2023 02 15;
  const dateErrors = []

  if (!dateMatches) {
    dateErrors.push(DatePickerException('INVALID_DATE_FORMAT'))
    throw dateErrors
  }

  const yearIndex = partsOrder.indexOf('YYYY')
  const monthIndex = partsOrder.indexOf('MM')
  const dayIndex = partsOrder.indexOf('DD')

  const yearString = dateMatches[yearIndex + 1]
  const monthString = dateMatches[monthIndex + 1]
  const dayString = dateMatches[dayIndex + 1]

  const month = parseInt(monthString, 10)

  if (month === 0 || month > 12) {
    dateErrors.push(DatePickerException('INVALID_MONTH_NUMBER'))
  }
  const day = parseInt(dayString, 10)
  if (day === 0) {
    dateErrors.push(DatePickerException('INVALID_DAY_NUMBER'))
  }
  const year = parseInt(yearString, 10)
  const monthDate = dayjs({year, month: month - 1})
  const monthDay = monthDate.daysInMonth()
  if (day > monthDay) {
    dateErrors.push(DatePickerException('INVALID_DAY_OF_MONTH'))
  }

  if (dateErrors.length > 0) {
    throw dateErrors
  }
  return monthDate.date(day)
}

七、最终效果图

image-20230816010133495

上次更新于: