From 634fd4458e27dcc771c77497dab5979a71cec7d5 Mon Sep 17 00:00:00 2001 From: epoll-j Date: Fri, 20 Sep 2024 15:20:51 +0800 Subject: [PATCH 1/6] feat(calendar): add calendar component feat #402 --- src/calendar/Calendar.tsx | 86 +++++++++ src/calendar/CalendarTemplate.tsx | 284 ++++++++++++++++++++++++++++++ src/calendar/calendar.en-US.md | 27 +++ src/calendar/calendar.md | 27 +++ src/calendar/defaultProps.ts | 15 ++ src/calendar/index.ts | 10 ++ src/calendar/style/css.js | 1 + src/calendar/style/index.js | 1 + src/calendar/type.ts | 99 +++++++++++ src/index.ts | 2 +- 10 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 src/calendar/Calendar.tsx create mode 100644 src/calendar/CalendarTemplate.tsx create mode 100644 src/calendar/calendar.en-US.md create mode 100644 src/calendar/calendar.md create mode 100644 src/calendar/defaultProps.ts create mode 100644 src/calendar/index.ts create mode 100644 src/calendar/style/css.js create mode 100644 src/calendar/style/index.js create mode 100644 src/calendar/type.ts diff --git a/src/calendar/Calendar.tsx b/src/calendar/Calendar.tsx new file mode 100644 index 00000000..c47fafe2 --- /dev/null +++ b/src/calendar/Calendar.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef, FC, useState } from 'react'; +import TPopup from '../popup'; +import CalendarTemplate from './CalendarTemplate'; +import { usePrefixClass } from '../hooks/useClass'; +import useDefaultProps from '../hooks/useDefaultProps'; +import { calendarDefaultProps } from './defaultProps'; +import { TdCalendarProps } from './type'; +import { StyledProps } from '../common'; + +export interface CalendarProps extends TdCalendarProps, StyledProps {} + +export interface CalendarContextValue { + inject: (props: CalendarProps) => CalendarProps; +} + +export const CalendarContext = React.createContext(null); + +const Calendar: FC = (_props) => { + const calendarTemplateRef = useRef(null); + const calendarClass = usePrefixClass('calendar'); + + const props = useDefaultProps(_props, calendarDefaultProps); + const { title, type, onClose, confirmBtn, usePopup, visible, value } = props; + + const [currentVisible, setCurrentVisible] = useState(visible); + const contextValue: CalendarContextValue = { + inject(props) { + return { + ...props, + onClose: (trigger) => { + props.onClose?.(trigger); + setCurrentVisible(false); + }, + }; + }, + }; + + const selectedValueIntoView = () => { + const selectType = type === 'range' ? 'start' : 'selected'; + const { templateRef } = calendarTemplateRef.current; + const scrollContainer = templateRef.querySelector(`.${calendarClass}__months`); + const selectedDate = templateRef.querySelector(`.${calendarClass}__dates-item--${selectType}`)?.parentNode + ?.previousElementSibling; + if (selectedDate) { + scrollContainer.scrollTop = selectedDate.offsetTop - scrollContainer.offsetTop; + } + }; + + const onPopupVisibleChange = (v) => { + if (!v) { + onClose?.('overlay'); + } else { + selectedValueIntoView(); + } + setCurrentVisible(v); + }; + + useEffect(() => { + if (!usePopup) selectedValueIntoView(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + setCurrentVisible(visible); + }, [visible]); + + useEffect(() => { + calendarTemplateRef.current.valueRef = value; + }, [value]); + + return ( + +
+ {!usePopup ? ( + + ) : ( + + + + )} +
+
+ ); +}; + +export default Calendar; diff --git a/src/calendar/CalendarTemplate.tsx b/src/calendar/CalendarTemplate.tsx new file mode 100644 index 00000000..420e5c58 --- /dev/null +++ b/src/calendar/CalendarTemplate.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useState, useContext, useMemo, forwardRef } from 'react'; +import { CloseIcon } from 'tdesign-icons-react'; +import Button from '../button'; +import { TDateType, TCalendarValue } from './type'; + +import { usePrefixClass } from '../hooks/useClass'; +import useDefaultProps from '../hooks/useDefaultProps'; +import { calendarDefaultProps } from './defaultProps'; +import { CalendarContext, CalendarProps } from './Calendar'; + +const CalendarTemplate = forwardRef((_props, ref) => { + const calendarClass = usePrefixClass('calendar'); + const context = useContext(CalendarContext); + const props = useDefaultProps(context ? context.inject(_props) : _props, calendarDefaultProps); + + const [selectedDate, setSelectedDate] = useState(props.value); + const [firstDayOfWeek] = useState(props.firstDayOfWeek || 0); + + useEffect(() => { + if (Array.isArray(props.value)) { + setSelectedDate(props.value?.map((item) => new Date(item))); + } else if (props.value) { + setSelectedDate(new Date(props.value)); + } else { + setSelectedDate(props.type === 'multiple' ? [new Date()] : new Date()); + } + }, [props.type, props.value]); + + const getYearMonthDay = (date: Date) => ({ + year: date.getFullYear(), + month: date.getMonth(), + date: date.getDate(), + }); + + const today = new Date(); + const minDate = props.minDate ? new Date(props.minDate) : today; + const maxDate = props.maxDate + ? new Date(props.maxDate) + : new Date(today.getFullYear(), today.getMonth() + 6, today.getDate()); + + const days = useMemo(() => { + // TODO: 国际化 + const raw = ['日', '一', '二', '三', '四', '五', '六']; + const ans = []; + let i = firstDayOfWeek % 7; + while (ans.length < 7) { + ans.push(raw[i]); + i = (i + 1) % 7; + } + return ans; + }, [firstDayOfWeek]); + + const confirmBtn = useMemo(() => { + if (typeof props.confirmBtn === 'string') { + return { content: props.confirmBtn || '确定' }; + } + return props.confirmBtn; + }, [props.confirmBtn]); + + const getDate = (year: number, month: number, day: number) => new Date(year, month, day); + + const isSameDate = (date1, date2) => { + const getYearMonthDay = (date) => ({ + year: date.getFullYear(), + month: date.getMonth(), + date: date.getDate(), + }); + let [tDate1, tDate2] = [date1, date2]; + if (date1 instanceof Date) tDate1 = getYearMonthDay(date1); + if (date2 instanceof Date) tDate2 = getYearMonthDay(date2); + const keys = ['year', 'month', 'date']; + if (!tDate1 || !tDate2) { + return; + } + return keys.every((key) => tDate1[key] === tDate2[key]); + }; + + const getDateItemClass = (dateItem) => { + let className = `${calendarClass}__dates-item`; + if (dateItem.type) { + className = `${className} ${calendarClass}__dates-item--${dateItem.type}`; + } + if (dateItem.className) { + className = `${className} ${dateItem.className}`; + } + return className; + }; + + // 选择日期 + const handleSelect = (year, month, date, dateItem) => { + if (dateItem.type === 'disabled') return; + const selected = new Date(year, month, date); + if (props.type === 'range' && Array.isArray(selectedDate)) { + if (selectedDate.length === 1) { + if (selectedDate[0] > selected) { + setSelectedDate([selected]); + } else { + setSelectedDate([selectedDate[0], selected]); + } + } else { + setSelectedDate([selected]); + if (!confirmBtn && selectedDate.length === 2) { + props.onChange?.(new Date(selectedDate[0])); + } + } + } else if (props.type === 'multiple') { + const newVal = [...(Array.isArray(selectedDate) ? selectedDate : [selectedDate])]; + const index = newVal.findIndex((item) => isSameDate(item, selected)); + if (index > -1) { + newVal.splice(index, 1); + } else { + newVal.push(selected); + } + setSelectedDate(newVal); + } else { + setSelectedDate(selected); + if (!confirmBtn) { + props.onChange?.(selected); + } + } + props.onSelect?.(selectedDate[0]); + }; + + // 计算月份 + const getMonthDates = (date) => { + const { year, month } = getYearMonthDay(date); + const firstDay = getDate(year, month, 1); + const weekdayOfFirstDay = firstDay.getDay(); + const lastDate = new Date(+getDate(year, month + 1, 1) - 24 * 3600 * 1000).getDate(); + return { + year, + month, + weekdayOfFirstDay, + lastDate, + }; + }; + + const months = useMemo(() => { + const ans = []; + let { year: minYear, month: minMonth } = getYearMonthDay(minDate); + const { year: maxYear, month: maxMonth } = getYearMonthDay(maxDate); + const calcType = (year: number, month: number, date: number): TDateType => { + const curDate = new Date(year, month, date, 23, 59, 59); + + if (props.type === 'single') { + if (isSameDate({ year, month, date }, selectedDate)) return 'selected'; + } + if (props.type === 'multiple') { + const hit = (Array.isArray(selectedDate) ? selectedDate : [selectedDate]).some((item: Date) => + isSameDate({ year, month, date }, item), + ); + if (hit) { + return 'selected'; + } + } + if (props.type === 'range') { + if (Array.isArray(selectedDate)) { + const [startDate, endDate] = selectedDate; + + if (startDate && isSameDate({ year, month, date }, startDate)) return 'start'; + if (endDate && isSameDate({ year, month, date }, endDate)) return 'end'; + if (startDate && endDate && curDate.getTime() > startDate.getTime() && curDate.getTime() < endDate.getTime()) + return 'centre'; + } + } + + const minCurDate = new Date(year, month, date, 0, 0, 0); + if (curDate.getTime() < minDate.getTime() || minCurDate.getTime() > maxDate.getTime()) { + return 'disabled'; + } + return ''; + }; + + while (minYear < maxYear || (minYear === maxYear && minMonth <= maxMonth)) { + const target = getMonthDates(getDate(minYear, minMonth, 1)); + const monthDates = []; + for (let i = 1; i <= target.lastDate; i++) { + const dateObj = { + date: getDate(minYear, minMonth, i), + day: i, + type: calcType(minYear, minMonth, i), + }; + monthDates.push(props.format ? props.format(dateObj) : dateObj); + } + ans.push({ + year: minYear, + month: minMonth, + months: monthDates, + weekdayOfFirstDay: target.weekdayOfFirstDay, + }); + const curDate = getYearMonthDay(getDate(minYear, minMonth + 1, 1)); + minYear = curDate.year; + minMonth = curDate.month; + } + return ans; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDate]); + + const handleConfirm = () => { + props.onClose?.('confirm-btn'); + props.onConfirm?.(new Date(selectedDate[0])); + }; + + const handleClose = () => { + props.onClose?.('close-btn'); + }; + + // 渲染日期 + const renderCell = (dateItem) => { + let className = `${calendarClass}__dates-item-suffix`; + if (dateItem.type) { + className = `${className} ${calendarClass}__dates-item-suffix--${dateItem.type}`; + } + + return ( + <> + {dateItem.prefix &&
{dateItem.prefix}
} + {dateItem.day} + {dateItem.suffix &&
{dateItem.suffix}
} + + ); + }; + + const renderConfirmBtn = () => { + if (confirmBtn && typeof confirmBtn !== 'object') { + return confirmBtn; + } + if (confirmBtn && Array.isArray(confirmBtn)) { + return confirmBtn; + } + if (confirmBtn && typeof confirmBtn === 'object') { + return + + + + +
+
+
+ 单个选择日期 +
+
+
+ + + +
+
+
+
+ +`; + +exports[`csr snapshot test > csr test src/calendar/_example/custom-button.tsx 1`] = ` +
+
+
+