您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~
DatePicker时间选择器的设计文档如下:
主体分为了单日选择器和时间区间选择器~
单日选择器:
时间区间选择器:
组件的源码如下:
DatePicker.tsx:
import React, { FC, memo, useState, useEffect, useCallback } from 'react'
import RangeDatePicker from './rangeDatePicker'
import { FieldTimeOutlined, CloseOutlined, CheckOutlined, LeftOutlined, RightOutlined, RollbackOutlined, DoubleLeftOutlined, DoubleRightOutlined } from '@ant-design/icons';
import "./index.module.less"
interface DatePickerProps {
/**
* @description 日期选择器类型(primary/input)仅支持非range
* @default primary
*/
type?: string
/**
* @description 设置日期区间选择器
* @default false
*/
showRange?: Boolean
/**
* @description 显示日期重置按钮
* @default false
*/
showClear?: Boolean
/**
* @description 方向
* @default false
*/
align?: string
/**
* @description 选择完毕后的回调函数
* @default Function
*/
handleChange?: Function
}
const monthList = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
const DatePicker: FC<DatePickerProps> = (props) => {
const { type, showRange, showClear, align, handleChange } = props;
const [showTimeDialog, setShowTimeDialog] = useState(false); //显示dialog
const [renderShowDialog, setRenderShowDialog] = useState(false);
const [nowDate, setNowDate] = useState({ //选中的日期
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate()
})
const [thisMonthFirstDay, setThisMonthFirstDay] = useState(0); //本月第一天是周几
const [dayListArray, setDayListArray] = useState<Array<number>>([]); //每月的日历
const [pickStatus, setPickStatus] = useState(0); //timerpick状态,0表示选择日期,1表示改变月份,2表示改变年份
const [iptValue, setIptValue] = useState<null | string>(null); //文本框输入的值
const [yearList, setYearList] = useState<Array<number>>([2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025, 2026]);
useEffect(() => {
window.addEventListener('click', () => {
setShowTimeDialog(false)
setTimeout(() => {
setRenderShowDialog(false)
}, 300)
})
}, [])
useEffect(() => {
const { year, month } = nowDate;
const firstDay = new Date(`${year}/${month}/1`).getDay();
const totalDay = new Date(year, month, 0).getDate()
const dayList = new Array(firstDay).fill('');
for (let i = 1; i < totalDay + 1; i++) {
dayList.push(i)
}
setThisMonthFirstDay(firstDay); //重新计算本月第一天为周几
setDayListArray(dayList); //重排本月日历
}, [nowDate.year, nowDate.month])
const openDialog = (e: any) => {
e.stopPropagation()
setShowTimeDialog(true);
setRenderShowDialog(true)
}
const changeDay = (day: number) => { //改变日期
if (!day) return;
setNowDate(old => {
old.day = day;
return { ...old }
})
handleChange && handleChange(`${nowDate.year}-${nowDate.month}-${nowDate.day}`);
setShowTimeDialog(false);
setTimeout(() => {
setRenderShowDialog(false)
}, 300)
}
const setToToday = () => { //改变到今天
const today = new Date();
setNowDate(old => {
old.year = today.getFullYear();
old.month = today.getMonth() + 1;
old.day = today.getDate();
return { ...old };
})
}
const changeToNextMonth = () => { //改变到下个月
const renderDate = {...nowDate};
if (renderDate.month == 12) { //12月新年
renderDate.year += 1;
renderDate.month = 1;
} else { //普通递增
renderDate.month += 1;
}
setNowDate(renderDate);
}
const changeToPreMonth = () => {
const renderDate = {...nowDate};
if (renderDate.month == 1) { //12月新年
renderDate.year -= 1;
renderDate.month = 12;
} else { //普通递增
renderDate.month -= 1;
}
setNowDate(renderDate);
}
const changeMonth = (month: number) => { //改变月份
setNowDate(old => {
old.month = month;
return { ...old };
})
setPickStatus(0);
}
const changeYear = (year: number) => { //改变年份
setNowDate(old => {
old.year = year;
return { ...old };
})
setPickStatus(0);
}
const bindIptText = (e: any) => { //绑定文本框
setIptValue(e.target.value);
}
const enterChangeDate = (e: any) => { //回车确认更改
if (e.keyCode == 13) { //回车
if (iptValue !== null) {
if (/^([1-2]\d{3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|30|31)$/.test(iptValue)) {
const inputValue = iptValue.split('-');
setNowDate(old => {
old.year = Number(inputValue[0]);
old.month = Number(inputValue[1]);
old.day = Number(inputValue[2]);
return { ...old };
})
handleChange && handleChange(`${Number(inputValue[0])}-${Number(inputValue[1])}-${Number(inputValue[2])}`);
}
}
setIptValue(null);
setShowTimeDialog(false);
setTimeout(() => {
setRenderShowDialog(false)
}, 300)
}
}
const blurInput = () => { //文本框失去焦点
if (iptValue !== null) {
if (/^([1-2]\d{3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|30|31)$/.test(iptValue)) {
const inputValue = iptValue.split('-');
setNowDate(old => {
old.year = Number(inputValue[0]);
old.month = Number(inputValue[1]);
old.day = Number(inputValue[2]);
return { ...old };
})
}
}
setIptValue(null);
}
const setNextGroupYear = () => { //设定下一组年份
setYearList(old => {
return [...old.map(y => y + 9)]
})
}
const setPreGroupYear = () => {
setYearList(old => {
return [...old.map(y => y - 9)]
})
}
const clearDate = () => { //清空
setNowDate(old => {
old.year = new Date().getFullYear(),
old.month = new Date().getMonth() + 1,
old.day = new Date().getDate()
return { ...old }
})
setIptValue(null);
}
const rangeDatePickChangeCallback = (start: string, end: string) => {
handleChange && handleChange(start, end);
}
const activeStyle = { //选中的所有样式
result: {
letterSpacing: "normal",
color: "#1890FF",
width: "120px"
},
icon: {
opacity: 1,
marginLeft: "5px"
},
checkBox: {
opacity: 1,
},
dayActive: {
backgroundColor: "#1890FF",
color: "#fff",
fontWeight: "bold",
borderRadius: "5px"
}
}
const alignFn = useCallback(() => { //对齐方式
if(!align) {
return {
bottom: {
top: "40px"
}
}
}
return {
right: {
left: "170px",
bottom: "20px",
},
left: {
right: "800px",
bottom: "40px",
},
top: {
bottom: "40px",
},
bottom: {
top: "40px"
}
}[align]
}, [align])
return (
showRange
?
<RangeDatePicker showClear={showClear} align={align ? align : "bottom"} handleChange={rangeDatePickChangeCallback} />
:
<div className="time-picker">
{
(type == "primary"
||
!type)
&&
<div className="result" style={showTimeDialog ? activeStyle.result : {}} onClick={(e) => openDialog(e)}>
<span>{nowDate.year}-{nowDate.month}-{nowDate.day}</span>
<div className="icon" style={showTimeDialog ? activeStyle.icon : {}}>
<FieldTimeOutlined />
</div>
</div>
}
{
type == "input"
&&
<div>
<input
className="input"
value={iptValue !== null ? iptValue : `${nowDate.year}-${nowDate.month}-${nowDate.day}`}
onClick={(e) => openDialog(e)}
onChange={(e) => bindIptText(e)}
onKeyDown={(e) => enterChangeDate(e)}
onBlur={blurInput}
/>
{
showClear
&&
<CloseOutlined style={{ position: 'relative', right: '15px', fontSize: '12px', cursor: 'pointer' }} onClick={clearDate} />
}
</div>
}
{
renderShowDialog
&&
<div className="check-box" style={{...showTimeDialog ? activeStyle.checkBox : {}, ...alignFn()} as any} onClick={(e) => e.stopPropagation()}>
<div className="top-bar">
<b className="year" onClick={() => setPickStatus(2)}>{nowDate.year}</b>
<b className="month" onClick={() => setPickStatus(1)} style={{ marginRight: "20px" }}>{nowDate.month}</b>
<div className="close-icon" onClick={() => {
setShowTimeDialog(false)
setTimeout(() => {
setRenderShowDialog(false)
}, 300)
}}>
<CloseOutlined />
</div>
</div>
<div className="date-content">
{/* 日历 */}
{
pickStatus == 0
&&
<>
<div className="week">
<div>日</div>
<div>一</div>
<div>二</div>
<div>三</div>
<div>四</div>
<div>五</div>
<div>六</div>
</div>
<div className="day-list">
{
dayListArray.map((day, index) => {
return (
<div key={index} className={day ? "day" : "white"} style={nowDate.day == day ? activeStyle.dayActive : {}} onClick={() => changeDay(day)}>
{day}
</div>
)
})
}
</div>
</>
}
{/* 月份选择框 */}
{
pickStatus == 1
&&
<div className="month-toggle-box">
{
monthList.map((m: string, index) => {
return (
<div key={m} className="month" style={index + 1 == nowDate.month ? activeStyle.dayActive : {}} onClick={() => changeMonth(index + 1)}>
{m}
</div>
)
})
}
</div>
}
{/* 年份选择框 */}
{
pickStatus == 2
&&
<div className="year-toggle-box">
<div className="toggle-bar">
<DoubleLeftOutlined style={{ cursor: "pointer" }} onClick={setPreGroupYear} />
<span>{yearList[0]}-{yearList[8]}</span>
<DoubleRightOutlined style={{ cursor: "pointer" }} onClick={setNextGroupYear} />
</div>
<div className="list">
{
yearList.map((m: number) => {
return (
<div key={m} className="year" style={m == nowDate.year ? activeStyle.dayActive : {}} onClick={() => changeYear(m)}>
{m}
</div>
)
})
}
</div>
</div>
}
</div>
<div className="time-footer">
{
pickStatus == 0
&&
<>
<div className="today" onClick={setToToday}>
<span>今天</span>
<CheckOutlined />
</div>
<div className="toggle-month">
<LeftOutlined style={{ marginRight: "5px" }} onClick={changeToPreMonth} />
<RightOutlined onClick={changeToNextMonth} />
</div>
</>
}
{
(pickStatus == 1 || pickStatus == 2)
&&
<>
<div></div>
<div className="go-back-icon" onClick={() => setPickStatus(0)}>
<RollbackOutlined />
</div>
</>
}
</div>
</div>
}
</div>
)
}
export default memo(DatePicker);
RangeDatePicker.tsx:
import React, { useEffect, FC, memo, useState, useCallback } from 'react'
import { DoubleLeftOutlined, LeftOutlined, DoubleRightOutlined, RightOutlined, SwapRightOutlined } from '@ant-design/icons'
import Input from '../../Input'
import './index.module.less'
interface RangeProps {
showClear?: Boolean
align?: string
handleChange?: Function
}
const RangeDatePicker: FC<RangeProps> = (props) => {
const { showClear, align, handleChange } = props
const [startDate, setStartDate] = useState({
startYear: new Date().getFullYear(),
startMonth: new Date().getMonth() + 1,
startDay: new Date().getDate()
})
const [endDate, setEndDate] = useState({
endYear: new Date().getFullYear(),
endMonth: new Date().getMonth() + 2,
endDay: new Date().getDate()
})
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('')
const [startMonthFirstDay, setStartMonthFirstDay] = useState(0); //本月第一天是周几
const [endMonthFirstDay, setEndMonthFirstDay] = useState(0); //本月第一天是周几
const [startDayListArray, setStartDayListArray] = useState<Array<number>>([]); //start月的日历
const [endDayListArray, setEndDayListArray] = useState<Array<number>>([]); //end月的日历
const [showTimeDiaglog, setShowTimeDialog] = useState(false); //日期选择器dialog show
const [renderShowDialog, setRenderShowDialog] = useState(false);
const [chooseStatus, setChooseStatus] = useState({
start: false,
end: false
}); //是否被选择过
let activeBorderDom: Element | null = document.querySelector('.activeBorder');
useEffect(() => {
const { startYear, startMonth } = startDate;
const { endYear, endMonth } = endDate;
const startFirstDay = new Date(`${startYear}/${startMonth}/1`).getDay();
const endFirstDay = new Date(`${endYear}/${endMonth}/1`).getDay();
const startTotalDay = new Date(startYear, startMonth, 0).getDate()
const endTotalDay = new Date(endYear, endMonth, 0).getDate()
const startDayList = new Array(startFirstDay).fill('');
const endDayList = new Array(endFirstDay).fill('');
for (let i = 1; i < startTotalDay + 1; i++) {
startDayList.push(i)
}
for (let i = 1; i < endTotalDay + 1; i++) {
endDayList.push(i)
}
setStartDayListArray(startDayList);
setStartMonthFirstDay(startFirstDay);
setEndDayListArray(endDayList);
setEndMonthFirstDay(endFirstDay);
}, [startDate.startYear, startDate.startMonth, endDate.endYear, endDate.endMonth])
useEffect(() => {
window.addEventListener('click', () => {
setShowTimeDialog(false)
setTimeout(() => {
setRenderShowDialog(false)
}, 300)
})
}, [])
useEffect(() => {
if(chooseStatus.start && chooseStatus.end) {
setShowTimeDialog(false)
setTimeout(() => {
setRenderShowDialog(false)
}, 300);
setChooseStatus(old => {
old.start = false;
old.end = false;
return {...old};
})
handleChange && handleChange(startTime, endTime);
}
}, [chooseStatus])
const startIptFocus = () => {
setShowTimeDialog(true);
setRenderShowDialog(true);
(activeBorderDom as any).style.left = "0";
}
const endIptFocus = () => {
setShowTimeDialog(true);
setRenderShowDialog(true);
(activeBorderDom as any).style.left = "190px";
}
const preYear = (type: string) => { //切换上一年
if (type == "start") {
const renderDate = {...startDate};
renderDate.startYear -= 1;
setStartDate(renderDate);
} else if (type == "end") {
if (endDate.endYear > startDate.startYear) {
const renderDate = {...endDate};
renderDate.endYear -= 1;
setEndDate(renderDate)
}
}
}
const nextYear = (type: string) => { //切换下一年
if (type == "start") {
if (startDate.startYear < endDate.endYear) {
const renderDate = {...startDate};
renderDate.startYear += 1;
setStartDate(renderDate);
}
} else if (type == "end") {
const renderDate = {...endDate};
renderDate.endYear += 1;
setEndDate(renderDate)
}
}
const preMonth = (type: string) => { //切换上一个月
if (type == "start") {
const renderDate = {...startDate};
if (renderDate.startMonth == 1) {
renderDate.startMonth = 12;
renderDate.startYear -= 1;
} else {
renderDate.startMonth -= 1;
}
setStartDate(renderDate);
} else if (type == "end") {
if (endDate.endYear == startDate.startYear && endDate.endMonth == startDate.startMonth) {
return;
} else {
const renderDate = {...endDate};
if (renderDate.endMonth == 1) {
renderDate.endMonth = 12;
renderDate.endYear -= 1;
} else {
renderDate.endMonth -= 1;
}
if (renderDate.endDay < startDate.startDay) {
renderDate.endDay = startDate.startDay;
}
setEndDate(renderDate);
}
}
}
const nextMonth = (type: string) => { //切换下一个月
if (type == "start") {
if (endDate.endYear == startDate.startYear && endDate.endMonth == startDate.startMonth) {
return;
} else {
const renderDate = {...startDate};
if (renderDate.startMonth == 12) {
renderDate.startMonth = 1;
renderDate.startYear += 1;
} else {
renderDate.startMonth += 1;
}
setStartDate(renderDate);
}
} else if (type == "end") {
const renderDate = {...endDate};
if (renderDate.endMonth == 12) {
renderDate.endMonth = 1;
renderDate.endYear += 1;
} else {
renderDate.endMonth += 1;
}
setEndDate(renderDate);
}
}
const chooseStartDay = (day: number | string) => { //选择开始日期
if (day == "") return;
setStartDate(old => {
old.startDay = day as number;
return { ...old };
})
setChooseStatus(old => {
old.start = true;
return {...old};
})
setStartTime(`${startDate.startYear}-${startDate.startMonth}-${day}`);
if (startDate.startYear == endDate.endYear && startDate.startMonth == endDate.endMonth) {
if (day > endDate.endDay) {
setEndDate(old => {
old.endDay = day as number;
return { ...old };
})
}
}
}
const chooseEndDay = (day: number | string) => { //选择结束日期
if (startDate.startYear == endDate.endYear && startDate.startMonth == endDate.endMonth) {
if (day < startDate.startDay) {
return;
}
}
setChooseStatus(old => {
old.end = true;
return {...old};
})
setEndDate(old => {
old.endDay = day as number;
return { ...old };
})
setEndTime(`${endDate.endYear}-${endDate.endMonth}-${day}`)
}
const enterChangeStartTime = (e: any) => { //回车改变
if (e.keyCode == 13) {
if (/^([1-2]\d{3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|30|31)$/.test(startTime)) {
const start = startTime.split('-');
if (Number(start[0]) <= endDate.endYear && Number(start[1]) <= endDate.endMonth && Number(start[2]) <= endDate.endDay) {
setStartDate(old => {
old.startYear = Number(start[0]);
old.startMonth = Number(start[1]);
old.startDay = Number(start[2]);
return { ...old };
})
setChooseStatus(old => {
old.start = true;
return {...old}
})
} else {
setStartTime('');
}
} else {
setStartTime('');
}
}
}
const blurStartTime = () => { //失去焦点
if (!/^([1-2]\d{3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|30|31)$/.test(startTime)) {
setStartTime('');
}
}
const enterChangeEndTime = (e: any) => { //回车改变
if (e.keyCode == 13) {
if (/^([1-2]\d{3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|30|31)$/.test(endTime)) {
const start = endTime.split('-');
if (Number(start[0]) >= startDate.startYear && Number(start[1]) >= startDate.startMonth && Number(start[2]) >= startDate.startDay) {
setEndDate(old => {
old.endYear = Number(start[0]);
old.endMonth = Number(start[1]);
old.endDay = Number(start[2]);
return { ...old };
})
setChooseStatus(old => {
old.end = true;
return {...old}
})
} else {
setEndTime('');
}
} else {
setEndTime('');
}
}
}
const blurEndTime = () => { //失去焦点
if (!/^([1-2]\d{3})-(0?[1-9]|1[0-2])-(0?[1-9]|[1-2][0-9]|30|31)$/.test(endTime)) {
setEndTime('');
}
}
const clearStartTime = () => { //清空开始时间
setStartTime('');
setStartDate(old => {
const now = new Date();
old.startDay = now.getDate();
old.startMonth = now.getMonth() + 1;
old.startYear = now.getFullYear();
return { ...old };
})
}
const clearEndTime = () => { //清空结束时间
setEndTime('');
setEndDate(old => {
const now = new Date();
old.endDay = now.getDate();
old.endMonth = now.getMonth() + 1;
old.endYear = now.getFullYear();
return { ...old };
})
}
const activeStyles = () => { //选中的样式
return {
activeDay: {
color: "#fff",
background: "#1890FF",
fontWeight: "bold",
borderRadius: "5px"
},
showDialog: {
opacity: 1
}
}
}
const alignFn = useCallback(() => {
if (!align) {
return {
bottom: {
top: "40px"
}
}
}
return {
right: {
left: "360px",
bottom: "20px",
},
left: {
right: "600px",
bottom: "20px",
},
top: {
bottom: "40px",
},
bottom: {
top: "40px"
}
}[align]
}, [align])
const disabledClass = useCallback((day: number | string) => {
if (day == "") {
return "white"
}
if (startDate.startYear == endDate.endYear && startDate.startMonth == endDate.endMonth) {
if (day < startDate.startDay) {
return "disabled-day"
}
return "day-box";
}
return "day-box";
}, [startDate, endDate])
return (
<div className="range" onClick={(e) => e.stopPropagation()} >
<div className="rangePicker" onClick={(e) => e.stopPropagation()}>
<Input
placeholder="请输入开始日期"
defaultValue={startTime ? startTime : `${startDate.startYear}-${startDate.startMonth}-${startDate.startDay}`}
handleIptChange={(v: string) => setStartTime(v)}
handleIptFocus={startIptFocus}
handleKeyDown={(e: any) => enterChangeStartTime(e)}
handleIptBlur={blurStartTime}
clearCallback={clearStartTime}
showClear={showClear as boolean} />
<SwapRightOutlined style={{ color: "#cccccc", fontSize: "20px" }} />
<Input
placeholder="请输入结束日期"
defaultValue={endTime ? endTime : `${endDate.endYear}-${endDate.endMonth}-${endDate.endDay}`}
handleIptChange={(v: string) => setEndTime(v)}
handleIptFocus={endIptFocus}
handleKeyDown={(e: any) => enterChangeEndTime(e)}
handleIptBlur={blurEndTime}
clearCallback={clearEndTime}
showClear={showClear as boolean} />
<div className="activeBorder"></div>
</div>
{
renderShowDialog
&&
<div className="date-box" onClick={(e) => e.stopPropagation()} style={{ ...showTimeDiaglog ? activeStyles().showDialog : {}, ...alignFn() } as any}>
<div className="left">
<div className="top-bar">
<div className="icon">
<DoubleLeftOutlined style={{ cursor: "pointer" }} onClick={() => preYear('start')} />
<LeftOutlined style={{ marginLeft: "10px", cursor: "pointer" }} onClick={() => preMonth('start')} />
</div>
<div className="info">
{startDate.startYear}年 {startDate.startMonth}月
</div>
<div>
<RightOutlined style={{ cursor: "pointer" }} onClick={() => nextMonth('start')} />
<DoubleRightOutlined style={{ marginLeft: "10px", cursor: "pointer" }} onClick={() => nextYear('start')} />
</div>
</div>
<div className="week">
<div>一</div>
<div>二</div>
<div>三</div>
<div>四</div>
<div>五</div>
<div>六</div>
<div>日</div>
</div>
<div className="day-list">
{
startDayListArray.map((i: string | number, index) => {
return (
<div key={index} className={i == "" ? "white" : "box-list"} style={i == startDate.startDay ? activeStyles().activeDay : {}} onClick={() => chooseStartDay(Number(i))}>
{i}
</div>
)
})
}
</div>
</div>
<div className="right">
<div className="top-bar">
<div>
<DoubleLeftOutlined style={{ cursor: "pointer" }} onClick={() => preYear('end')} />
<LeftOutlined style={{ marginLeft: "10px", cursor: "pointer" }} onClick={() => preMonth('end')} />
</div>
<div className="info">
{endDate.endYear}年 {endDate.endMonth}月
</div>
<div className="icon">
<RightOutlined style={{ cursor: "pointer" }} onClick={() => nextMonth('end')} />
<DoubleRightOutlined style={{ marginLeft: "10px", cursor: "pointer" }} onClick={() => nextYear('end')} />
</div>
</div>
<div className="week">
<div>一</div>
<div>二</div>
<div>三</div>
<div>四</div>
<div>五</div>
<div>六</div>
<div>日</div>
</div>
<div className="day-list">
{
endDayListArray.map((i: string | number, index) => {
return (
<div key={index} className={disabledClass(i)} style={i == endDate.endDay ? activeStyles().activeDay : {}} onClick={() => chooseEndDay(Number(i))}>
{i}
</div>
)
})
}
</div>
</div>
</div>
}
</div>
)
}
export default memo(RangeDatePicker);
设计思路主要就是找到每个月的第一天是周几,将这个月的日历排版做好,主要用到了Date中的getDay和getDate,getDay来获取某个月的第一天为周几;getDate来统计某个月一共有多少天,将日历设计为数组的形式。 React-View-UI日期选择器文档链接在这里~ 值得一提的是时间区间选择器支持跨多个月多个年来进行选择,这也是我对于自身的需求来进行市场组件扩充的一个点。 如果你对React-View-UI有兴趣,欢迎下载体验留言,有BUG欢迎吐槽~~
- Concis组件库线上链接:react-view-ui.com:92
- github:github.com/fengxinhhh/…
- npm:www.npmjs.com/package/con…