month.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. /* eslint-disable jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions */
  2. /* eslint-disable max-len */
  3. import React from 'react';
  4. import classNames from 'classnames';
  5. import PropTypes from 'prop-types';
  6. import MonthFoundation, { MonthAdapter, MonthDayInfo, MonthFoundationProps, MonthFoundationState } from '@douyinfe/semi-foundation/datePicker/monthFoundation';
  7. import { cssClasses, numbers } from '@douyinfe/semi-foundation/datePicker/constants';
  8. import BaseComponent, { BaseProps } from '../_base/baseComponent';
  9. import { isBefore, isAfter, isBetween, isSameDay } from '@douyinfe/semi-foundation/datePicker/_utils/index';
  10. import { noop, stubFalse, isFunction } from 'lodash';
  11. import { parseISO } from 'date-fns';
  12. import { Locale } from '../locale/interface';
  13. const prefixCls = cssClasses.PREFIX;
  14. export interface MonthProps extends MonthFoundationProps, BaseProps {
  15. forwardRef: React.Ref<any>;
  16. locale: Locale['DatePicker'];
  17. focusRecordsRef: React.RefObject<{ rangeStart: boolean; rangeEnd: boolean }>;
  18. }
  19. export type MonthState = MonthFoundationState;
  20. export default class Month extends BaseComponent<MonthProps, MonthState> {
  21. static propTypes = {
  22. month: PropTypes.object,
  23. selected: PropTypes.object,
  24. rangeStart: PropTypes.string,
  25. rangeEnd: PropTypes.string,
  26. offsetRangeStart: PropTypes.string,
  27. offsetRangeEnd: PropTypes.string,
  28. onDayClick: PropTypes.func,
  29. onDayHover: PropTypes.func,
  30. weekStartsOn: PropTypes.number,
  31. disabledDate: PropTypes.func,
  32. weeksRowNum: PropTypes.number,
  33. onWeeksRowNumChange: PropTypes.func,
  34. renderDate: PropTypes.func,
  35. renderFullDate: PropTypes.func,
  36. hoverDay: PropTypes.string, // Real-time hover date
  37. startDateOffset: PropTypes.func,
  38. endDateOffset: PropTypes.func,
  39. rangeInputFocus: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
  40. focusRecordsRef: PropTypes.object,
  41. multiple: PropTypes.bool,
  42. };
  43. static defaultProps = {
  44. month: new Date(),
  45. selected: new Set(),
  46. rangeStart: '',
  47. rangeEnd: '',
  48. onDayClick: noop,
  49. onDayHover: noop,
  50. onWeeksRowNumChange: noop,
  51. weekStartsOn: numbers.WEEK_START_ON,
  52. disabledDate: stubFalse,
  53. weeksRowNum: 0,
  54. };
  55. monthRef: React.RefObject<HTMLDivElement>;
  56. foundation: MonthFoundation;
  57. constructor(props: MonthProps) {
  58. super(props);
  59. this.state = {
  60. weekdays: [],
  61. month: { weeks: [], monthText: '' },
  62. todayText: '',
  63. weeksRowNum: props.weeksRowNum,
  64. };
  65. this.monthRef = React.createRef();
  66. }
  67. get adapter(): MonthAdapter {
  68. return {
  69. ...super.adapter,
  70. updateToday: todayText => this.setState({ todayText }),
  71. setWeekDays: weekdays => this.setState({ weekdays }),
  72. setWeeksRowNum: (weeksRowNum, callback) => this.setState({ weeksRowNum }, callback),
  73. updateMonthTable: month => this.setState({ month }),
  74. notifyDayClick: day => this.props.onDayClick(day),
  75. notifyDayHover: day => this.props.onDayHover(day),
  76. notifyWeeksRowNumChange: weeksRowNum => this.props.onWeeksRowNumChange(weeksRowNum),
  77. };
  78. }
  79. componentDidMount() {
  80. this.foundation = new MonthFoundation(this.adapter);
  81. this.foundation.init();
  82. }
  83. componentWillUnmount() {
  84. this.foundation.destroy();
  85. }
  86. componentDidUpdate(prevProps: MonthProps, prevState: MonthState) {
  87. if (prevProps.month !== this.props.month) {
  88. this.foundation.getMonthTable();
  89. }
  90. }
  91. getSingleDayStatus(options: Partial<MonthProps> & { fullDate: string; todayText: string }) {
  92. const { fullDate, todayText, selected, disabledDate, rangeStart, rangeEnd } = options;
  93. const disabledOptions = { rangeStart, rangeEnd };
  94. const isToday = fullDate === todayText;
  95. const isSelected = selected.has(fullDate);
  96. let isDisabled = disabledDate && disabledDate(parseISO(fullDate), disabledOptions);
  97. if (
  98. !isDisabled &&
  99. this.props.rangeInputFocus === 'rangeStart' &&
  100. rangeEnd &&
  101. this.props.focusRecordsRef &&
  102. this.props.focusRecordsRef.current.rangeEnd
  103. ) {
  104. // The reason for splitting is that the dateRangeTime format: 'yyyy-MM-dd HH:MM:SS'
  105. isDisabled = isAfter(fullDate, rangeEnd.trim().split(/\s+/)[0]);
  106. }
  107. if (
  108. !isDisabled &&
  109. this.props.rangeInputFocus === 'rangeEnd' &&
  110. rangeStart &&
  111. this.props.focusRecordsRef &&
  112. this.props.focusRecordsRef.current.rangeStart
  113. ) {
  114. // The reason for splitting is that the dateRangeTime format: 'yyyy-MM-dd HH:MM:SS'
  115. isDisabled = isBefore(fullDate, rangeStart.trim().split(/\s+/)[0]);
  116. }
  117. return {
  118. isToday, // Today
  119. isSelected, // Selected
  120. isDisabled // Disabled
  121. };
  122. }
  123. getDateRangeStatus(options: Partial<MonthProps> & { fullDate: string }) {
  124. const { rangeStart, rangeEnd, fullDate, hoverDay, offsetRangeStart, offsetRangeEnd, rangeInputFocus } = options;
  125. // If no item is selected, return the empty object directly
  126. const _isDateRangeAnySelected = Boolean(rangeStart || rangeEnd);
  127. const _isDateRangeSelected = Boolean(rangeStart && rangeEnd);
  128. const _isOffsetDateRangeAnyExist = offsetRangeStart || offsetRangeEnd;
  129. if (!_isDateRangeAnySelected) {
  130. return ({});
  131. }
  132. // The range selects the hover date, and the normal hover is .semi-datepicker-main: hover
  133. const _isHoverDay = isSameDay(hoverDay, fullDate);
  134. // When one is selected
  135. // eslint-disable-next-line one-var
  136. let _isHoverAfterStart, _isHoverBeforeEnd, isSelectedStart, isSelectedEnd, isHoverDayAroundOneSelected;
  137. if (rangeStart) {
  138. isSelectedStart = isSameDay(fullDate, rangeStart);
  139. if (rangeInputFocus === 'rangeEnd') {
  140. _isHoverAfterStart = isBetween(fullDate, { start: rangeStart, end: hoverDay });
  141. }
  142. }
  143. if (rangeEnd) {
  144. isSelectedEnd = isSameDay(fullDate, rangeEnd);
  145. if (rangeInputFocus === 'rangeStart') {
  146. _isHoverBeforeEnd = isBetween(fullDate, { start: hoverDay, end: rangeEnd });
  147. }
  148. }
  149. if (!_isDateRangeSelected && _isDateRangeAnySelected) {
  150. isHoverDayAroundOneSelected = _isHoverDay;
  151. }
  152. // eslint-disable-next-line one-var
  153. let isHover;
  154. if (!_isOffsetDateRangeAnyExist) {
  155. isHover = _isHoverAfterStart || _isHoverBeforeEnd || _isHoverDay;
  156. }
  157. // Select all
  158. // eslint-disable-next-line one-var
  159. let isInRange, isSelectedStartAfterHover, isSelectedEndBeforeHover, isHoverDayInStartSelection, isHoverDayInEndSelection, isHoverDayInRange;
  160. if (_isDateRangeSelected) {
  161. isInRange = isBetween(fullDate, { start: rangeStart, end: rangeEnd });
  162. if (!_isOffsetDateRangeAnyExist) {
  163. isSelectedStartAfterHover = isSelectedStart && isAfter(rangeStart, hoverDay);
  164. isSelectedEndBeforeHover = isSelectedEnd && isBefore(rangeEnd, hoverDay);
  165. isHoverDayInStartSelection = _isHoverDay && rangeInputFocus === 'rangeStart';
  166. isHoverDayInEndSelection = _isHoverDay && rangeInputFocus === 'rangeEnd';
  167. isHoverDayInRange = _isHoverDay && isBetween(hoverDay, { start: rangeStart, end: rangeEnd });
  168. }
  169. }
  170. return {
  171. isHoverDay: _isHoverDay, // Is the current hover date
  172. isSelectedStart, // Select Start
  173. isSelectedEnd, // End of selection
  174. isInRange, // Range within the selected date
  175. isHover, // Date between selection and hover date
  176. isSelectedStartAfterHover, // Choose to start behind the hover
  177. isSelectedEndBeforeHover, // Choose to end in front of the hover
  178. isHoverDayInRange, // Hover date within range
  179. isHoverDayInStartSelection, // Hover date when starting Date is selected
  180. isHoverDayInEndSelection, // Hover date when endDate is selected
  181. isHoverDayAroundOneSelected, // Hover date and select a date
  182. };
  183. }
  184. getOffsetDateStatus(options: Partial<MonthProps> & { fullDate: string }) {
  185. const { offsetRangeStart, offsetRangeEnd, rangeStart, rangeEnd, fullDate, hoverDay } = options;
  186. // When there is no offset, return the empty object directly
  187. const _isOffsetDateRangeNull = !(offsetRangeStart || offsetRangeEnd);
  188. if (_isOffsetDateRangeNull) {
  189. return ({});
  190. }
  191. // Range Select base date
  192. const _isInRange = isBetween(fullDate, { start: rangeStart, end: rangeEnd });
  193. const _isHoverDay = isSameDay(hoverDay, fullDate);
  194. const _isSelectedStart = rangeStart && isSameDay(fullDate, rangeStart);
  195. const _isSelectedEnd = rangeEnd && isSameDay(fullDate, rangeEnd);
  196. const _isDateRangeSelected = Boolean(rangeStart && rangeEnd);
  197. // Determine whether it is offsetStart or offsetRangeEnd
  198. const isOffsetRangeStart = isSameDay(fullDate, offsetRangeStart);
  199. const isOffsetRangeEnd = isSameDay(fullDate, offsetRangeEnd);
  200. const isHoverDayOffset = _isHoverDay;
  201. // When selected
  202. let isHoverInOffsetRange, isInOffsetRange;
  203. if (_isDateRangeSelected) {
  204. isHoverInOffsetRange = _isInRange && _isHoverDay;
  205. }
  206. // When there is an offset area
  207. const _isOffsetDateRangeSelected = Boolean(offsetRangeStart && offsetRangeEnd);
  208. if (_isOffsetDateRangeSelected) {
  209. isInOffsetRange = (_isSelectedStart || isBetween(fullDate, { start: offsetRangeStart, end: offsetRangeEnd }) || _isSelectedEnd);
  210. }
  211. return {
  212. isOffsetRangeStart, // Week selection start
  213. isOffsetRangeEnd, // End of week selection
  214. isHoverInOffsetRange, // Hover in the week selection
  215. isHoverDayOffset, // Week selection hover day
  216. isInOffsetRange // Include start and end within the week selection (start and end styles are the same as other dates, so start and end are included)
  217. };
  218. }
  219. /**
  220. * get day current status
  221. * @param {Object} fullDate
  222. * @param {Object} options
  223. * @returns {Object}
  224. */
  225. getDayStatus(currentDay: MonthDayInfo, options: MonthProps & { todayText: string }) {
  226. const { fullDate } = currentDay;
  227. const { hoverDay, rangeStart, rangeEnd, todayText, offsetRangeStart, offsetRangeEnd, disabledDate, selected, rangeInputFocus } = options;
  228. const singleDayStatus = this.getSingleDayStatus({ fullDate, todayText, hoverDay, selected, disabledDate, rangeStart, rangeEnd });
  229. const dateRangeStatus = this.getDateRangeStatus({ fullDate, rangeStart, rangeEnd, hoverDay, offsetRangeStart, offsetRangeEnd, rangeInputFocus, ...singleDayStatus });
  230. const offsetDataStatus = this.getOffsetDateStatus({ offsetRangeStart, offsetRangeEnd, rangeStart, rangeEnd, fullDate, hoverDay, ...singleDayStatus, ...dateRangeStatus });
  231. // this parameter will pass to the user when given renderFullDate function, do not delete or modify its key
  232. const dayStatus = {
  233. ...singleDayStatus,
  234. ...dateRangeStatus,
  235. ...offsetDataStatus,
  236. };
  237. return dayStatus;
  238. }
  239. renderDayOfWeek() {
  240. const { locale } = this.props;
  241. const weekdayCls = classNames(cssClasses.WEEKDAY);
  242. const weekdayItemCls = classNames(`${prefixCls}-weekday-item`);
  243. const { weekdays } = this.state;
  244. // i18n
  245. const weekdaysText = weekdays.map(key => locale.weeks[key]);
  246. return (
  247. <div role="row" className={weekdayCls}>
  248. {weekdaysText.map((E, i) => (
  249. <div role="columnheader" key={E + i} className={weekdayItemCls}>
  250. {E}
  251. </div>
  252. ))}
  253. </div>
  254. );
  255. }
  256. renderWeeks() {
  257. const { month } = this.state;
  258. const { weeks } = month;
  259. const { weeksRowNum } = this.props;
  260. let style = {};
  261. if (weeksRowNum) {
  262. const height = weeksRowNum * numbers.WEEK_HEIGHT;
  263. style = { height };
  264. }
  265. const weeksCls = classNames(cssClasses.WEEKS);
  266. return (
  267. <div className={weeksCls} style={style}>
  268. {weeks.map((week, weekIndex) => this.renderWeek(week, weekIndex))}
  269. </div>
  270. );
  271. }
  272. renderWeek(week: MonthDayInfo[], weekIndex: number) {
  273. const weekCls = cssClasses.WEEK;
  274. return (
  275. <div role="row" className={weekCls} key={weekIndex}>
  276. {week.map((day, dayIndex) => this.renderDay(day, dayIndex))}
  277. </div>
  278. );
  279. }
  280. renderDay(day: MonthDayInfo, dayIndex: number) {
  281. const { todayText } = this.state;
  282. const { renderFullDate, renderDate } = this.props;
  283. const { fullDate, dayNumber } = day;
  284. if (!fullDate) {
  285. return (
  286. <div role="gridcell" tabIndex={-1} key={(dayNumber as number) + dayIndex} className={cssClasses.DAY}>
  287. <span />
  288. </div>
  289. );
  290. }
  291. const dayStatus = this.getDayStatus(day, { todayText, ...this.props });
  292. const dayCls = classNames(cssClasses.DAY, {
  293. [cssClasses.DAY_TODAY]: dayStatus.isToday,
  294. [cssClasses.DAY_IN_RANGE]: dayStatus.isInRange,
  295. [cssClasses.DAY_HOVER]: dayStatus.isHover,
  296. [cssClasses.DAY_SELECTED]: dayStatus.isSelected,
  297. [cssClasses.DAY_SELECTED_START]: dayStatus.isSelectedStart,
  298. [cssClasses.DAY_SELECTED_END]: dayStatus.isSelectedEnd,
  299. [cssClasses.DAY_DISABLED]: dayStatus.isDisabled,
  300. // offsetDate class
  301. [cssClasses.DAY_HOVER_DAY]: dayStatus.isHoverDayOffset,
  302. [cssClasses.DAY_IN_OFFSET_RANGE]: dayStatus.isInOffsetRange,
  303. [cssClasses.DAY_SELECTED_RANGE_HOVER]: dayStatus.isHoverInOffsetRange,
  304. [cssClasses.DAY_OFFSET_RANGE_START]: dayStatus.isOffsetRangeStart,
  305. [cssClasses.DAY_OFFSET_RANGE_END]: dayStatus.isOffsetRangeEnd,
  306. // range input class
  307. [cssClasses.DAY_SELECTED_START_AFTER_HOVER]: dayStatus.isSelectedStartAfterHover,
  308. [cssClasses.DAY_SELECTED_END_BEFORE_HOVER]: dayStatus.isSelectedEndBeforeHover,
  309. [cssClasses.DAY_HOVER_DAY_BEFORE_RANGE]: dayStatus.isHoverDayInStartSelection,
  310. [cssClasses.DAY_HOVER_DAY_AFTER_RANGE]: dayStatus.isHoverDayInEndSelection,
  311. [cssClasses.DAY_HOVER_DAY_AROUND_SINGLE_SELECTED]: dayStatus.isHoverDayAroundOneSelected,
  312. });
  313. const dayMainCls = classNames({
  314. [`${cssClasses.DAY}-main`]: true,
  315. });
  316. const fullDateArgs = [dayNumber, fullDate, dayStatus];
  317. const customRender = isFunction(renderFullDate);
  318. return (
  319. <div
  320. role="gridcell"
  321. tabIndex={dayStatus.isDisabled ? -1 : 0}
  322. aria-disabled={dayStatus.isDisabled}
  323. aria-selected={dayStatus.isSelected}
  324. aria-label={fullDate}
  325. className={!customRender ? dayCls : cssClasses.DAY}
  326. title={fullDate}
  327. key={(dayNumber as number) + dayIndex}
  328. onClick={e => !dayStatus.isDisabled && this.foundation.handleClick(day)}
  329. onMouseEnter={() => this.foundation.handleHover(day)}
  330. onMouseLeave={() => this.foundation.handleHover()}
  331. >
  332. {customRender ? renderFullDate(...fullDateArgs) : (
  333. <div className={dayMainCls}>
  334. {isFunction(renderDate) ? renderDate(dayNumber, fullDate) : <span>{dayNumber}</span>}
  335. </div>
  336. )}
  337. </div>
  338. );
  339. }
  340. render() {
  341. const { forwardRef, multiple } = this.props;
  342. const weekday = this.renderDayOfWeek();
  343. const weeks = this.renderWeeks();
  344. const monthCls = classNames(cssClasses.MONTH);
  345. const ref = forwardRef || this.monthRef;
  346. return (
  347. <div role="grid" aria-multiselectable={multiple} ref={ref} className={monthCls} >
  348. {weekday}
  349. {weeks}
  350. </div>
  351. );
  352. }
  353. }