month.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  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 { rangeInputFocus } = this.props;
  93. const { fullDate, todayText, selected, disabledDate, rangeStart, rangeEnd } = options;
  94. const disabledOptions = { rangeStart, rangeEnd, rangeInputFocus };
  95. const isToday = fullDate === todayText;
  96. const isSelected = selected.has(fullDate);
  97. let isDisabled = disabledDate && disabledDate(parseISO(fullDate), disabledOptions);
  98. if (
  99. !isDisabled &&
  100. this.props.rangeInputFocus === 'rangeStart' &&
  101. rangeEnd &&
  102. this.props.focusRecordsRef &&
  103. this.props.focusRecordsRef.current.rangeEnd
  104. ) {
  105. // The reason for splitting is that the dateRangeTime format: 'yyyy-MM-dd HH:MM:SS'
  106. isDisabled = isAfter(fullDate, rangeEnd.trim().split(/\s+/)[0]);
  107. }
  108. if (
  109. !isDisabled &&
  110. this.props.rangeInputFocus === 'rangeEnd' &&
  111. rangeStart &&
  112. this.props.focusRecordsRef &&
  113. this.props.focusRecordsRef.current.rangeStart
  114. ) {
  115. // The reason for splitting is that the dateRangeTime format: 'yyyy-MM-dd HH:MM:SS'
  116. isDisabled = isBefore(fullDate, rangeStart.trim().split(/\s+/)[0]);
  117. }
  118. return {
  119. isToday, // Today
  120. isSelected, // Selected
  121. isDisabled // Disabled
  122. };
  123. }
  124. getDateRangeStatus(options: Partial<MonthProps> & { fullDate: string }) {
  125. const { rangeStart, rangeEnd, fullDate, hoverDay, offsetRangeStart, offsetRangeEnd, rangeInputFocus } = options;
  126. // If no item is selected, return the empty object directly
  127. const _isDateRangeAnySelected = Boolean(rangeStart || rangeEnd);
  128. const _isDateRangeSelected = Boolean(rangeStart && rangeEnd);
  129. const _isOffsetDateRangeAnyExist = offsetRangeStart || offsetRangeEnd;
  130. if (!_isDateRangeAnySelected) {
  131. return ({});
  132. }
  133. // The range selects the hover date, and the normal hover is .semi-datepicker-main: hover
  134. const _isHoverDay = isSameDay(hoverDay, fullDate);
  135. // When one is selected
  136. // eslint-disable-next-line one-var
  137. let _isHoverAfterStart, _isHoverBeforeEnd, isSelectedStart, isSelectedEnd, isHoverDayAroundOneSelected;
  138. if (rangeStart) {
  139. isSelectedStart = isSameDay(fullDate, rangeStart);
  140. if (rangeInputFocus === 'rangeEnd') {
  141. _isHoverAfterStart = isBetween(fullDate, { start: rangeStart, end: hoverDay });
  142. }
  143. }
  144. if (rangeEnd) {
  145. isSelectedEnd = isSameDay(fullDate, rangeEnd);
  146. if (rangeInputFocus === 'rangeStart') {
  147. _isHoverBeforeEnd = isBetween(fullDate, { start: hoverDay, end: rangeEnd });
  148. }
  149. }
  150. if (!_isDateRangeSelected && _isDateRangeAnySelected) {
  151. isHoverDayAroundOneSelected = _isHoverDay;
  152. }
  153. // eslint-disable-next-line one-var
  154. let isHover;
  155. if (!_isOffsetDateRangeAnyExist) {
  156. isHover = _isHoverAfterStart || _isHoverBeforeEnd || _isHoverDay;
  157. }
  158. // Select all
  159. // eslint-disable-next-line one-var
  160. let isInRange, isSelectedStartAfterHover, isSelectedEndBeforeHover, isHoverDayInStartSelection, isHoverDayInEndSelection, isHoverDayInRange;
  161. if (_isDateRangeSelected) {
  162. isInRange = isBetween(fullDate, { start: rangeStart, end: rangeEnd });
  163. if (!_isOffsetDateRangeAnyExist) {
  164. isSelectedStartAfterHover = isSelectedStart && isAfter(rangeStart, hoverDay);
  165. isSelectedEndBeforeHover = isSelectedEnd && isBefore(rangeEnd, hoverDay);
  166. isHoverDayInStartSelection = _isHoverDay && rangeInputFocus === 'rangeStart';
  167. isHoverDayInEndSelection = _isHoverDay && rangeInputFocus === 'rangeEnd';
  168. isHoverDayInRange = _isHoverDay && isBetween(hoverDay, { start: rangeStart, end: rangeEnd });
  169. }
  170. }
  171. return {
  172. isHoverDay: _isHoverDay, // Is the current hover date
  173. isSelectedStart, // Select Start
  174. isSelectedEnd, // End of selection
  175. isInRange, // Range within the selected date
  176. isHover, // Date between selection and hover date
  177. isSelectedStartAfterHover, // Choose to start behind the hover
  178. isSelectedEndBeforeHover, // Choose to end in front of the hover
  179. isHoverDayInRange, // Hover date within range
  180. isHoverDayInStartSelection, // Hover date when starting Date is selected
  181. isHoverDayInEndSelection, // Hover date when endDate is selected
  182. isHoverDayAroundOneSelected, // Hover date and select a date
  183. };
  184. }
  185. getOffsetDateStatus(options: Partial<MonthProps> & { fullDate: string }) {
  186. const { offsetRangeStart, offsetRangeEnd, rangeStart, rangeEnd, fullDate, hoverDay } = options;
  187. // When there is no offset, return the empty object directly
  188. const _isOffsetDateRangeNull = !(offsetRangeStart || offsetRangeEnd);
  189. if (_isOffsetDateRangeNull) {
  190. return ({});
  191. }
  192. // Range Select base date
  193. const _isInRange = isBetween(fullDate, { start: rangeStart, end: rangeEnd });
  194. const _isHoverDay = isSameDay(hoverDay, fullDate);
  195. const _isSelectedStart = rangeStart && isSameDay(fullDate, rangeStart);
  196. const _isSelectedEnd = rangeEnd && isSameDay(fullDate, rangeEnd);
  197. const _isDateRangeSelected = Boolean(rangeStart && rangeEnd);
  198. // Determine whether it is offsetStart or offsetRangeEnd
  199. const isOffsetRangeStart = isSameDay(fullDate, offsetRangeStart);
  200. const isOffsetRangeEnd = isSameDay(fullDate, offsetRangeEnd);
  201. const isHoverDayOffset = _isHoverDay;
  202. // When selected
  203. let isHoverInOffsetRange, isInOffsetRange;
  204. if (_isDateRangeSelected) {
  205. isHoverInOffsetRange = _isInRange && _isHoverDay;
  206. }
  207. // When there is an offset area
  208. const _isOffsetDateRangeSelected = Boolean(offsetRangeStart && offsetRangeEnd);
  209. if (_isOffsetDateRangeSelected) {
  210. isInOffsetRange = (_isSelectedStart || isBetween(fullDate, { start: offsetRangeStart, end: offsetRangeEnd }) || _isSelectedEnd);
  211. }
  212. return {
  213. isOffsetRangeStart, // Week selection start
  214. isOffsetRangeEnd, // End of week selection
  215. isHoverInOffsetRange, // Hover in the week selection
  216. isHoverDayOffset, // Week selection hover day
  217. 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)
  218. };
  219. }
  220. /**
  221. * get day current status
  222. * @param {Object} fullDate
  223. * @param {Object} options
  224. * @returns {Object}
  225. */
  226. getDayStatus(currentDay: MonthDayInfo, options: MonthProps & { todayText: string }) {
  227. const { fullDate } = currentDay;
  228. const { hoverDay, rangeStart, rangeEnd, todayText, offsetRangeStart, offsetRangeEnd, disabledDate, selected, rangeInputFocus } = options;
  229. const singleDayStatus = this.getSingleDayStatus({ fullDate, todayText, hoverDay, selected, disabledDate, rangeStart, rangeEnd });
  230. const dateRangeStatus = this.getDateRangeStatus({ fullDate, rangeStart, rangeEnd, hoverDay, offsetRangeStart, offsetRangeEnd, rangeInputFocus, ...singleDayStatus });
  231. const offsetDataStatus = this.getOffsetDateStatus({ offsetRangeStart, offsetRangeEnd, rangeStart, rangeEnd, fullDate, hoverDay, ...singleDayStatus, ...dateRangeStatus });
  232. // this parameter will pass to the user when given renderFullDate function, do not delete or modify its key
  233. const dayStatus = {
  234. ...singleDayStatus,
  235. ...dateRangeStatus,
  236. ...offsetDataStatus,
  237. };
  238. return dayStatus;
  239. }
  240. renderDayOfWeek() {
  241. const { locale } = this.props;
  242. const weekdayCls = classNames(cssClasses.WEEKDAY);
  243. const weekdayItemCls = classNames(`${prefixCls}-weekday-item`);
  244. const { weekdays } = this.state;
  245. // i18n
  246. const weekdaysText = weekdays.map(key => locale.weeks[key]);
  247. return (
  248. <div role="row" className={weekdayCls}>
  249. {weekdaysText.map((E, i) => (
  250. <div role="columnheader" key={E + i} className={weekdayItemCls}>
  251. {E}
  252. </div>
  253. ))}
  254. </div>
  255. );
  256. }
  257. renderWeeks() {
  258. const { month } = this.state;
  259. const { weeks } = month;
  260. const { weeksRowNum } = this.props;
  261. let style = {};
  262. if (weeksRowNum) {
  263. const height = weeksRowNum * numbers.WEEK_HEIGHT;
  264. style = { height };
  265. }
  266. const weeksCls = classNames(cssClasses.WEEKS);
  267. return (
  268. <div className={weeksCls} style={style}>
  269. {weeks.map((week, weekIndex) => this.renderWeek(week, weekIndex))}
  270. </div>
  271. );
  272. }
  273. renderWeek(week: MonthDayInfo[], weekIndex: number) {
  274. const weekCls = cssClasses.WEEK;
  275. return (
  276. <div role="row" className={weekCls} key={weekIndex}>
  277. {week.map((day, dayIndex) => this.renderDay(day, dayIndex))}
  278. </div>
  279. );
  280. }
  281. renderDay(day: MonthDayInfo, dayIndex: number) {
  282. const { todayText } = this.state;
  283. const { renderFullDate, renderDate } = this.props;
  284. const { fullDate, dayNumber } = day;
  285. if (!fullDate) {
  286. return (
  287. <div role="gridcell" tabIndex={-1} key={(dayNumber as number) + dayIndex} className={cssClasses.DAY}>
  288. <span />
  289. </div>
  290. );
  291. }
  292. const dayStatus = this.getDayStatus(day, { todayText, ...this.props });
  293. const dayCls = classNames(cssClasses.DAY, {
  294. [cssClasses.DAY_TODAY]: dayStatus.isToday,
  295. [cssClasses.DAY_IN_RANGE]: dayStatus.isInRange,
  296. [cssClasses.DAY_HOVER]: dayStatus.isHover,
  297. [cssClasses.DAY_SELECTED]: dayStatus.isSelected,
  298. [cssClasses.DAY_SELECTED_START]: dayStatus.isSelectedStart,
  299. [cssClasses.DAY_SELECTED_END]: dayStatus.isSelectedEnd,
  300. [cssClasses.DAY_DISABLED]: dayStatus.isDisabled,
  301. // offsetDate class
  302. [cssClasses.DAY_HOVER_DAY]: dayStatus.isHoverDayOffset,
  303. [cssClasses.DAY_IN_OFFSET_RANGE]: dayStatus.isInOffsetRange,
  304. [cssClasses.DAY_SELECTED_RANGE_HOVER]: dayStatus.isHoverInOffsetRange,
  305. [cssClasses.DAY_OFFSET_RANGE_START]: dayStatus.isOffsetRangeStart,
  306. [cssClasses.DAY_OFFSET_RANGE_END]: dayStatus.isOffsetRangeEnd,
  307. // range input class
  308. [cssClasses.DAY_SELECTED_START_AFTER_HOVER]: dayStatus.isSelectedStartAfterHover,
  309. [cssClasses.DAY_SELECTED_END_BEFORE_HOVER]: dayStatus.isSelectedEndBeforeHover,
  310. [cssClasses.DAY_HOVER_DAY_BEFORE_RANGE]: dayStatus.isHoverDayInStartSelection,
  311. [cssClasses.DAY_HOVER_DAY_AFTER_RANGE]: dayStatus.isHoverDayInEndSelection,
  312. [cssClasses.DAY_HOVER_DAY_AROUND_SINGLE_SELECTED]: dayStatus.isHoverDayAroundOneSelected,
  313. });
  314. const dayMainCls = classNames({
  315. [`${cssClasses.DAY}-main`]: true,
  316. });
  317. const fullDateArgs = [dayNumber, fullDate, dayStatus];
  318. const customRender = isFunction(renderFullDate);
  319. return (
  320. <div
  321. role="gridcell"
  322. tabIndex={dayStatus.isDisabled ? -1 : 0}
  323. aria-disabled={dayStatus.isDisabled}
  324. aria-selected={dayStatus.isSelected}
  325. aria-label={fullDate}
  326. className={!customRender ? dayCls : cssClasses.DAY}
  327. title={fullDate}
  328. key={(dayNumber as number) + dayIndex}
  329. onClick={e => !dayStatus.isDisabled && this.foundation.handleClick(day)}
  330. onMouseEnter={() => this.foundation.handleHover(day)}
  331. onMouseLeave={() => this.foundation.handleHover()}
  332. >
  333. {customRender ? renderFullDate(...fullDateArgs) : (
  334. <div className={dayMainCls}>
  335. {isFunction(renderDate) ? renderDate(dayNumber, fullDate) : <span>{dayNumber}</span>}
  336. </div>
  337. )}
  338. </div>
  339. );
  340. }
  341. render() {
  342. const { forwardRef, multiple } = this.props;
  343. const weekday = this.renderDayOfWeek();
  344. const weeks = this.renderWeeks();
  345. const monthCls = classNames(cssClasses.MONTH);
  346. const ref = forwardRef || this.monthRef;
  347. return (
  348. <div role="grid" aria-multiselectable={multiple} ref={ref} className={monthCls} >
  349. {weekday}
  350. {weeks}
  351. </div>
  352. );
  353. }
  354. }