Skip to content

Commit 634fd44

Browse files
committed
feat(calendar): add calendar component
feat Tencent#402
1 parent 0786bb5 commit 634fd44

10 files changed

+551
-1
lines changed

src/calendar/Calendar.tsx

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React, { useEffect, useRef, FC, useState } from 'react';
2+
import TPopup from '../popup';
3+
import CalendarTemplate from './CalendarTemplate';
4+
import { usePrefixClass } from '../hooks/useClass';
5+
import useDefaultProps from '../hooks/useDefaultProps';
6+
import { calendarDefaultProps } from './defaultProps';
7+
import { TdCalendarProps } from './type';
8+
import { StyledProps } from '../common';
9+
10+
export interface CalendarProps extends TdCalendarProps, StyledProps {}
11+
12+
export interface CalendarContextValue {
13+
inject: (props: CalendarProps) => CalendarProps;
14+
}
15+
16+
export const CalendarContext = React.createContext<CalendarContextValue>(null);
17+
18+
const Calendar: FC<CalendarProps> = (_props) => {
19+
const calendarTemplateRef = useRef(null);
20+
const calendarClass = usePrefixClass('calendar');
21+
22+
const props = useDefaultProps(_props, calendarDefaultProps);
23+
const { title, type, onClose, confirmBtn, usePopup, visible, value } = props;
24+
25+
const [currentVisible, setCurrentVisible] = useState(visible);
26+
const contextValue: CalendarContextValue = {
27+
inject(props) {
28+
return {
29+
...props,
30+
onClose: (trigger) => {
31+
props.onClose?.(trigger);
32+
setCurrentVisible(false);
33+
},
34+
};
35+
},
36+
};
37+
38+
const selectedValueIntoView = () => {
39+
const selectType = type === 'range' ? 'start' : 'selected';
40+
const { templateRef } = calendarTemplateRef.current;
41+
const scrollContainer = templateRef.querySelector(`.${calendarClass}__months`);
42+
const selectedDate = templateRef.querySelector(`.${calendarClass}__dates-item--${selectType}`)?.parentNode
43+
?.previousElementSibling;
44+
if (selectedDate) {
45+
scrollContainer.scrollTop = selectedDate.offsetTop - scrollContainer.offsetTop;
46+
}
47+
};
48+
49+
const onPopupVisibleChange = (v) => {
50+
if (!v) {
51+
onClose?.('overlay');
52+
} else {
53+
selectedValueIntoView();
54+
}
55+
setCurrentVisible(v);
56+
};
57+
58+
useEffect(() => {
59+
if (!usePopup) selectedValueIntoView();
60+
// eslint-disable-next-line react-hooks/exhaustive-deps
61+
}, []);
62+
63+
useEffect(() => {
64+
setCurrentVisible(visible);
65+
}, [visible]);
66+
67+
useEffect(() => {
68+
calendarTemplateRef.current.valueRef = value;
69+
}, [value]);
70+
71+
return (
72+
<CalendarContext.Provider value={contextValue}>
73+
<div>
74+
{!usePopup ? (
75+
<CalendarTemplate ref={calendarTemplateRef} title={title} confirmBtn={confirmBtn} />
76+
) : (
77+
<TPopup visible={currentVisible} placement="bottom" onVisibleChange={onPopupVisibleChange}>
78+
<CalendarTemplate ref={calendarTemplateRef} title={title} confirmBtn={confirmBtn} />
79+
</TPopup>
80+
)}
81+
</div>
82+
</CalendarContext.Provider>
83+
);
84+
};
85+
86+
export default Calendar;

src/calendar/CalendarTemplate.tsx

+284
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import React, { useEffect, useState, useContext, useMemo, forwardRef } from 'react';
2+
import { CloseIcon } from 'tdesign-icons-react';
3+
import Button from '../button';
4+
import { TDateType, TCalendarValue } from './type';
5+
6+
import { usePrefixClass } from '../hooks/useClass';
7+
import useDefaultProps from '../hooks/useDefaultProps';
8+
import { calendarDefaultProps } from './defaultProps';
9+
import { CalendarContext, CalendarProps } from './Calendar';
10+
11+
const CalendarTemplate = forwardRef<HTMLDivElement, CalendarProps>((_props, ref) => {
12+
const calendarClass = usePrefixClass('calendar');
13+
const context = useContext(CalendarContext);
14+
const props = useDefaultProps(context ? context.inject(_props) : _props, calendarDefaultProps);
15+
16+
const [selectedDate, setSelectedDate] = useState<number | Date | TCalendarValue[]>(props.value);
17+
const [firstDayOfWeek] = useState(props.firstDayOfWeek || 0);
18+
19+
useEffect(() => {
20+
if (Array.isArray(props.value)) {
21+
setSelectedDate(props.value?.map((item) => new Date(item)));
22+
} else if (props.value) {
23+
setSelectedDate(new Date(props.value));
24+
} else {
25+
setSelectedDate(props.type === 'multiple' ? [new Date()] : new Date());
26+
}
27+
}, [props.type, props.value]);
28+
29+
const getYearMonthDay = (date: Date) => ({
30+
year: date.getFullYear(),
31+
month: date.getMonth(),
32+
date: date.getDate(),
33+
});
34+
35+
const today = new Date();
36+
const minDate = props.minDate ? new Date(props.minDate) : today;
37+
const maxDate = props.maxDate
38+
? new Date(props.maxDate)
39+
: new Date(today.getFullYear(), today.getMonth() + 6, today.getDate());
40+
41+
const days = useMemo(() => {
42+
// TODO: 国际化
43+
const raw = ['日', '一', '二', '三', '四', '五', '六'];
44+
const ans = [];
45+
let i = firstDayOfWeek % 7;
46+
while (ans.length < 7) {
47+
ans.push(raw[i]);
48+
i = (i + 1) % 7;
49+
}
50+
return ans;
51+
}, [firstDayOfWeek]);
52+
53+
const confirmBtn = useMemo(() => {
54+
if (typeof props.confirmBtn === 'string') {
55+
return { content: props.confirmBtn || '确定' };
56+
}
57+
return props.confirmBtn;
58+
}, [props.confirmBtn]);
59+
60+
const getDate = (year: number, month: number, day: number) => new Date(year, month, day);
61+
62+
const isSameDate = (date1, date2) => {
63+
const getYearMonthDay = (date) => ({
64+
year: date.getFullYear(),
65+
month: date.getMonth(),
66+
date: date.getDate(),
67+
});
68+
let [tDate1, tDate2] = [date1, date2];
69+
if (date1 instanceof Date) tDate1 = getYearMonthDay(date1);
70+
if (date2 instanceof Date) tDate2 = getYearMonthDay(date2);
71+
const keys = ['year', 'month', 'date'];
72+
if (!tDate1 || !tDate2) {
73+
return;
74+
}
75+
return keys.every((key) => tDate1[key] === tDate2[key]);
76+
};
77+
78+
const getDateItemClass = (dateItem) => {
79+
let className = `${calendarClass}__dates-item`;
80+
if (dateItem.type) {
81+
className = `${className} ${calendarClass}__dates-item--${dateItem.type}`;
82+
}
83+
if (dateItem.className) {
84+
className = `${className} ${dateItem.className}`;
85+
}
86+
return className;
87+
};
88+
89+
// 选择日期
90+
const handleSelect = (year, month, date, dateItem) => {
91+
if (dateItem.type === 'disabled') return;
92+
const selected = new Date(year, month, date);
93+
if (props.type === 'range' && Array.isArray(selectedDate)) {
94+
if (selectedDate.length === 1) {
95+
if (selectedDate[0] > selected) {
96+
setSelectedDate([selected]);
97+
} else {
98+
setSelectedDate([selectedDate[0], selected]);
99+
}
100+
} else {
101+
setSelectedDate([selected]);
102+
if (!confirmBtn && selectedDate.length === 2) {
103+
props.onChange?.(new Date(selectedDate[0]));
104+
}
105+
}
106+
} else if (props.type === 'multiple') {
107+
const newVal = [...(Array.isArray(selectedDate) ? selectedDate : [selectedDate])];
108+
const index = newVal.findIndex((item) => isSameDate(item, selected));
109+
if (index > -1) {
110+
newVal.splice(index, 1);
111+
} else {
112+
newVal.push(selected);
113+
}
114+
setSelectedDate(newVal);
115+
} else {
116+
setSelectedDate(selected);
117+
if (!confirmBtn) {
118+
props.onChange?.(selected);
119+
}
120+
}
121+
props.onSelect?.(selectedDate[0]);
122+
};
123+
124+
// 计算月份
125+
const getMonthDates = (date) => {
126+
const { year, month } = getYearMonthDay(date);
127+
const firstDay = getDate(year, month, 1);
128+
const weekdayOfFirstDay = firstDay.getDay();
129+
const lastDate = new Date(+getDate(year, month + 1, 1) - 24 * 3600 * 1000).getDate();
130+
return {
131+
year,
132+
month,
133+
weekdayOfFirstDay,
134+
lastDate,
135+
};
136+
};
137+
138+
const months = useMemo(() => {
139+
const ans = [];
140+
let { year: minYear, month: minMonth } = getYearMonthDay(minDate);
141+
const { year: maxYear, month: maxMonth } = getYearMonthDay(maxDate);
142+
const calcType = (year: number, month: number, date: number): TDateType => {
143+
const curDate = new Date(year, month, date, 23, 59, 59);
144+
145+
if (props.type === 'single') {
146+
if (isSameDate({ year, month, date }, selectedDate)) return 'selected';
147+
}
148+
if (props.type === 'multiple') {
149+
const hit = (Array.isArray(selectedDate) ? selectedDate : [selectedDate]).some((item: Date) =>
150+
isSameDate({ year, month, date }, item),
151+
);
152+
if (hit) {
153+
return 'selected';
154+
}
155+
}
156+
if (props.type === 'range') {
157+
if (Array.isArray(selectedDate)) {
158+
const [startDate, endDate] = selectedDate;
159+
160+
if (startDate && isSameDate({ year, month, date }, startDate)) return 'start';
161+
if (endDate && isSameDate({ year, month, date }, endDate)) return 'end';
162+
if (startDate && endDate && curDate.getTime() > startDate.getTime() && curDate.getTime() < endDate.getTime())
163+
return 'centre';
164+
}
165+
}
166+
167+
const minCurDate = new Date(year, month, date, 0, 0, 0);
168+
if (curDate.getTime() < minDate.getTime() || minCurDate.getTime() > maxDate.getTime()) {
169+
return 'disabled';
170+
}
171+
return '';
172+
};
173+
174+
while (minYear < maxYear || (minYear === maxYear && minMonth <= maxMonth)) {
175+
const target = getMonthDates(getDate(minYear, minMonth, 1));
176+
const monthDates = [];
177+
for (let i = 1; i <= target.lastDate; i++) {
178+
const dateObj = {
179+
date: getDate(minYear, minMonth, i),
180+
day: i,
181+
type: calcType(minYear, minMonth, i),
182+
};
183+
monthDates.push(props.format ? props.format(dateObj) : dateObj);
184+
}
185+
ans.push({
186+
year: minYear,
187+
month: minMonth,
188+
months: monthDates,
189+
weekdayOfFirstDay: target.weekdayOfFirstDay,
190+
});
191+
const curDate = getYearMonthDay(getDate(minYear, minMonth + 1, 1));
192+
minYear = curDate.year;
193+
minMonth = curDate.month;
194+
}
195+
return ans;
196+
// eslint-disable-next-line react-hooks/exhaustive-deps
197+
}, [selectedDate]);
198+
199+
const handleConfirm = () => {
200+
props.onClose?.('confirm-btn');
201+
props.onConfirm?.(new Date(selectedDate[0]));
202+
};
203+
204+
const handleClose = () => {
205+
props.onClose?.('close-btn');
206+
};
207+
208+
// 渲染日期
209+
const renderCell = (dateItem) => {
210+
let className = `${calendarClass}__dates-item-suffix`;
211+
if (dateItem.type) {
212+
className = `${className} ${calendarClass}__dates-item-suffix--${dateItem.type}`;
213+
}
214+
215+
return (
216+
<>
217+
{dateItem.prefix && <div className={`${calendarClass}__dates-item-prefix`}>{dateItem.prefix}</div>}
218+
{dateItem.day}
219+
{dateItem.suffix && <div className={`${className}`}>{dateItem.suffix}</div>}
220+
</>
221+
);
222+
};
223+
224+
const renderConfirmBtn = () => {
225+
if (confirmBtn && typeof confirmBtn !== 'object') {
226+
return confirmBtn;
227+
}
228+
if (confirmBtn && Array.isArray(confirmBtn)) {
229+
return confirmBtn;
230+
}
231+
if (confirmBtn && typeof confirmBtn === 'object') {
232+
return <Button block theme="primary" {...confirmBtn} onClick={handleConfirm} />;
233+
}
234+
};
235+
236+
const className = useMemo(
237+
() => (props.usePopup ? `${calendarClass} ${calendarClass}--popup` : `${calendarClass}`),
238+
// eslint-disable-next-line react-hooks/exhaustive-deps
239+
[],
240+
);
241+
242+
return (
243+
<div ref={ref} className={`${className}`}>
244+
<div className={`${calendarClass}__title`}>{props.title || '请选择日期'}</div>
245+
{props.usePopup && <CloseIcon className={`${calendarClass}__close-btn`} size={24} onClick={handleClose} />}
246+
<div className={`${calendarClass}__days`}>
247+
{days.map((item, index) => (
248+
<div key={index} className={`${calendarClass}__days-item`}>
249+
{item}
250+
</div>
251+
))}
252+
</div>
253+
254+
<div className={`${calendarClass}__months`} style={{ overflow: 'auto' }}>
255+
{months.map((item, index) => (
256+
<>
257+
<div className={`${calendarClass}__month`} key={index}>
258+
{item.year} / {item.month}
259+
</div>
260+
<div className={`${calendarClass}__dates`} key={index}>
261+
{new Array((item.weekdayOfFirstDay - firstDayOfWeek + 7) % 7).fill(0).map((emptyItem, index) => (
262+
<div key={index} />
263+
))}
264+
{item.months.map((dateItem, dateIndex) => (
265+
<>
266+
<div
267+
key={`${index}_${dateIndex}`}
268+
className={getDateItemClass(dateItem)}
269+
onClick={() => handleSelect(item.year, item.month, dateItem.day, dateItem)}
270+
>
271+
{renderCell(dateItem)}
272+
</div>
273+
</>
274+
))}
275+
</div>
276+
</>
277+
))}
278+
</div>
279+
{props.usePopup && <div className={`${calendarClass}__footer`}>{renderConfirmBtn()}</div>}
280+
</div>
281+
);
282+
});
283+
284+
export default CalendarTemplate;

src/calendar/calendar.en-US.md

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
:: BASE_DOC ::
2+
3+
## API
4+
5+
6+
### Calendar Props
7+
8+
name | type | default | description | required
9+
-- | -- | -- | -- | --
10+
className | String | - | className of component | N
11+
style | Object | - | CSS(Cascading Style Sheets),Typescript:`React.CSSProperties` | N
12+
autoClose | Boolean | true | \- | N
13+
confirmBtn | TNode | '' | Typescript:`string \| ButtonProps \| TNode \| null`[Button API Documents](./button?tab=api)[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts)[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/calendar/type.ts) | N
14+
firstDayOfWeek | Number | 0 | \- | N
15+
format | Function | - | Typescript:`CalendarFormatType ` `type CalendarFormatType = (day: TDate) => TDate` `type TDateType = 'selected' \| 'disabled' \| 'start' \| 'centre' \| 'end' \| ''` `interface TDate { date: Date; day: number; type: TDateType; className?: string; prefix?: string; suffix?: string;}`[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/calendar/type.ts) | N
16+
maxDate | Number / Date | - | Typescript:` number \| Date` | N
17+
minDate | Number / Date | - | Typescript:` number \| Date` | N
18+
title | TNode | '请选择日期' | Typescript:`string \| TNode`[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N
19+
type | String | 'single' | options: single/multiple/range | N
20+
usePopup | Boolean | true | \- | N
21+
value | Number / Array / Date | - | Typescript:`number \| Date \| TCalendarValue[]` `type TCalendarValue = number \| Date`[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/calendar/type.ts) | N
22+
defaultValue | Number / Array / Date | - | uncontrolled property。Typescript:`number \| Date \| TCalendarValue[]` `type TCalendarValue = number \| Date`[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/calendar/type.ts) | N
23+
visible | Boolean | false | \- | N
24+
onChange | Function | | Typescript:`(value: Date) => void`<br/> | N
25+
onClose | Function | | Typescript:`(trigger: CalendarTrigger) => void`<br/>[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/calendar/type.ts)。<br/>`type CalendarTrigger = 'close-btn' \| 'confirm-btn' \| 'overlay'`<br/> | N
26+
onConfirm | Function | | Typescript:`(value: Date) => void`<br/> | N
27+
onSelect | Function | | Typescript:`(value: Date) => void`<br/> | N

0 commit comments

Comments
 (0)