KyongUI——InputDatePicker
一、前言
最近收到导师项目的要求,需要一个能够显示每天打卡进度的日历,由于各个组件库的日历可能都不要符合项目的需求,于是决定自己设计一个日期选择器。
准备工作:
bash
// 安装dayjs
yarn add dayjs
// 安装lodash
yarn add lodash -D
二、Antd的DatePicker
可以看出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>
如此以来一个日期的基本框架就搭好了
四、布局组件的设计
从上文可知日历分为三部分header
、body
、footer
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>
简单的日历组件只需要year
和monthIndex
两个参数,即可进行日期的控制,所以只需要在最上层组件设置state
传参即可,月份和年份的转变通过操作这两个参数即可及时转变,因为较为简单,我就不具体贴代码了。
五、组件联动与状态管理
因为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
通过onSelect
与onInputChange
两个函数来更新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)
}