monthCalendar.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */
  2. import React, { ReactInstance } from 'react';
  3. import ReactDOM from 'react-dom';
  4. import cls from 'classnames';
  5. import { isEqual } from 'lodash';
  6. import PropTypes from 'prop-types';
  7. import { IconClose } from '@douyinfe/semi-icons';
  8. // eslint-disable-next-line max-len
  9. import CalendarFoundation, { CalendarAdapter, EventObject, MonthData, MonthlyEvent, ParsedEventsType, ParsedEventsWithArray, ParsedRangeEvent } from '@douyinfe/semi-foundation/calendar/foundation';
  10. import { cssClasses } from '@douyinfe/semi-foundation/calendar/constants';
  11. import { DateObj } from '@douyinfe/semi-foundation/calendar/eventUtil';
  12. import LocaleConsumer from '../locale/localeConsumer';
  13. import localeContext from '../locale/context';
  14. import BaseComponent from '../_base/baseComponent';
  15. import Popover from '../popover';
  16. import Button from '../iconButton';
  17. import { Locale } from '../locale/interface';
  18. import { MonthCalendarProps } from './interface';
  19. import '@douyinfe/semi-foundation/calendar/calendar.scss';
  20. const toPercent = (num: number) => {
  21. const res = num < 1 ? num * 100 : 100;
  22. return `${res}%`;
  23. };
  24. const prefixCls = `${cssClasses.PREFIX}-month`;
  25. const contentPadding = 60;
  26. const contentHeight = 24;
  27. export interface MonthCalendarState {
  28. itemLimit: number;
  29. showCard: Record<string, [boolean] | [boolean, string]>;
  30. parsedEvents: MonthlyEvent;
  31. cachedKeys: Array<string>
  32. }
  33. export default class monthCalendar extends BaseComponent<MonthCalendarProps, MonthCalendarState> {
  34. static propTypes = {
  35. displayValue: PropTypes.instanceOf(Date),
  36. header: PropTypes.node,
  37. events: PropTypes.array,
  38. mode: PropTypes.string,
  39. markWeekend: PropTypes.bool,
  40. width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  41. height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  42. style: PropTypes.object,
  43. className: PropTypes.string,
  44. dateGridRender: PropTypes.func,
  45. onClick: PropTypes.func,
  46. onClose: PropTypes.func,
  47. };
  48. static defaultProps = {
  49. displayValue: new Date(),
  50. events: [] as EventObject[],
  51. mode: 'month',
  52. };
  53. static contextType = localeContext;
  54. cellDom: React.RefObject<HTMLDivElement>;
  55. foundation: CalendarFoundation;
  56. cardRef: Map<string, ReactInstance>;
  57. contentCellHeight: number;
  58. monthlyData: MonthData;
  59. clickOutsideHandler: (e: MouseEvent) => void;
  60. constructor(props: MonthCalendarProps) {
  61. super(props);
  62. this.state = {
  63. itemLimit: 0,
  64. showCard: {},
  65. parsedEvents: {} as MonthlyEvent,
  66. cachedKeys: []
  67. };
  68. this.cellDom = React.createRef();
  69. this.foundation = new CalendarFoundation(this.adapter);
  70. this.handleClick = this.handleClick.bind(this);
  71. this.cardRef = new Map();
  72. }
  73. get adapter(): CalendarAdapter<MonthCalendarProps, MonthCalendarState> {
  74. return {
  75. ...super.adapter,
  76. registerClickOutsideHandler: (key: string, cb: () => void) => {
  77. const clickOutsideHandler = (e: MouseEvent) => {
  78. const cardInstance = this.cardRef && this.cardRef.get(key);
  79. // eslint-disable-next-line react/no-find-dom-node
  80. const cardDom = ReactDOM.findDOMNode(cardInstance);
  81. if (cardDom && !cardDom.contains(e.target as any)) {
  82. cb();
  83. }
  84. };
  85. this.clickOutsideHandler = clickOutsideHandler;
  86. document.addEventListener('mousedown', clickOutsideHandler, false);
  87. },
  88. unregisterClickOutsideHandler: () => {
  89. document.removeEventListener('mousedown', this.clickOutsideHandler, false);
  90. },
  91. setMonthlyData: data => {
  92. this.monthlyData = data;
  93. },
  94. getMonthlyData: () => this.monthlyData,
  95. notifyClose: (e, key) => {
  96. const updates = {};
  97. updates[key] = [false];
  98. this.setState(prevState => ({
  99. showCard: { ...prevState.showCard, ...updates }
  100. }));
  101. this.props.onClose && this.props.onClose(e);
  102. },
  103. openCard: (key, spacing) => {
  104. const updates = {};
  105. const pos = spacing ? 'leftTopOver' : 'rightTopOver';
  106. updates[key] = [true, pos];
  107. this.setState(prevState => ({
  108. showCard: { ...updates }
  109. }));
  110. },
  111. setParsedEvents: (parsedEvents: ParsedEventsType) => {
  112. this.setState({ parsedEvents: parsedEvents as MonthlyEvent });
  113. },
  114. setItemLimit: itemLimit => {
  115. this.setState({ itemLimit });
  116. },
  117. cacheEventKeys: cachedKeys => {
  118. this.setState({ cachedKeys });
  119. }
  120. };
  121. }
  122. calcItemLimit = () => {
  123. this.contentCellHeight = this.cellDom.current.getBoundingClientRect().height;
  124. return Math.max(0, Math.ceil((this.contentCellHeight - contentPadding) / contentHeight));
  125. };
  126. componentDidMount() {
  127. this.foundation.init();
  128. const itemLimit = this.calcItemLimit();
  129. this.foundation.parseMonthlyEvents(itemLimit);
  130. }
  131. componentWillUnmount() {
  132. this.foundation.destroy();
  133. }
  134. componentDidUpdate(prevProps: MonthCalendarProps, prevState: MonthCalendarState) {
  135. const prevEventKeys = prevState.cachedKeys;
  136. const nowEventKeys = this.props.events.map(event => event.key);
  137. let itemLimitUpdate = false;
  138. let { itemLimit } = this.state;
  139. if (prevProps.height !== this.props.height) {
  140. itemLimit = this.calcItemLimit();
  141. if (prevState.itemLimit !== itemLimit) {
  142. itemLimitUpdate = true;
  143. }
  144. }
  145. if (!isEqual(prevEventKeys, nowEventKeys) || itemLimitUpdate) {
  146. this.foundation.parseMonthlyEvents((itemLimit || this.props.events) as any);
  147. }
  148. }
  149. handleClick = (e: React.MouseEvent, val: [Date]) => {
  150. const { onClick } = this.props;
  151. const value = this.foundation.formatCbValue(val);
  152. onClick && onClick(e, value);
  153. };
  154. closeCard(e: React.MouseEvent, key: string) {
  155. this.foundation.closeCard(e, key);
  156. }
  157. showCard = (e: React.MouseEvent, key: string) => {
  158. this.foundation.showCard(e, key);
  159. };
  160. renderHeader = (dateFnsLocale: Locale['dateFnsLocale']) => {
  161. const { markWeekend, displayValue } = this.props;
  162. this.monthlyData = this.foundation.getMonthlyData(displayValue, dateFnsLocale);
  163. return (
  164. <div className={`${prefixCls}-header`} role="presentation">
  165. <div role="presentation" className={`${prefixCls}-grid`}>
  166. <ul role="row" className={`${prefixCls}-grid-row`}>
  167. {this.monthlyData[0].map(day => {
  168. const { weekday } = day;
  169. const listCls = cls({
  170. [`${cssClasses.PREFIX}-weekend`]: markWeekend && day.isWeekend,
  171. });
  172. return (
  173. <li role="columnheader" aria-label={weekday} key={`${weekday}-monthheader`} className={listCls}>
  174. <span>{weekday}</span>
  175. </li>
  176. );
  177. })}
  178. </ul>
  179. </div>
  180. </div>
  181. );
  182. };
  183. renderEvents = (events: ParsedRangeEvent[]) => {
  184. if (!events) {
  185. return undefined;
  186. }
  187. const list = events.map((event, ind) => {
  188. const { leftPos, width, topInd, key, children } = event;
  189. const style = {
  190. left: toPercent(leftPos),
  191. width: toPercent(width),
  192. top: `${topInd}em`
  193. };
  194. return (
  195. <li
  196. className={`${cssClasses.PREFIX}-event-item ${cssClasses.PREFIX}-event-month`}
  197. key={key || `${ind}-monthevent`}
  198. style={style}
  199. >
  200. {children}
  201. </li>
  202. );
  203. });
  204. return list;
  205. };
  206. renderCollapsed = (events: MonthlyEvent['day'][number], itemInfo: DateObj, listCls: string, month: string) => {
  207. const { itemLimit, showCard } = this.state;
  208. const { weekday, dayString, date } = itemInfo;
  209. const key = date.toString();
  210. const remained = events.filter(i => Boolean(i)).length - itemLimit;
  211. const cardCls = `${prefixCls}-event-card`;
  212. // const top = contentPadding / 2 + this.state.itemLimit * contentHeight;
  213. const shouldRenderCard = remained > 0;
  214. const closer = (
  215. <Button
  216. className={`${cardCls}-close`}
  217. onClick={e => this.closeCard(e, key)}
  218. type="tertiary"
  219. icon={<IconClose />}
  220. theme="borderless"
  221. size="small"
  222. />
  223. );
  224. const header = (
  225. <div className={`${cardCls}-header-info`}>
  226. <div className={`${cardCls}-header-info-weekday`}>{weekday}</div>
  227. <div className={`${cardCls}-header-info-date`}>{dayString}</div>
  228. </div>
  229. );
  230. const content = (
  231. <div className={cardCls}>
  232. <div className={`${cardCls}-content`}>
  233. <div className={`${cardCls}-header`}>
  234. {header}
  235. {closer}
  236. </div>
  237. <div className={`${cardCls}-body`}>
  238. <ul className={`${cardCls}-list`}>
  239. {events.map(item => (
  240. <li key={item.key || `${item.start.toString()}-event`}>{item.children}</li>
  241. ))}
  242. </ul>
  243. </div>
  244. </div>
  245. </div>
  246. );
  247. const pos = showCard && showCard[key] ? showCard[key][1] : 'leftTopOver';
  248. const text = (
  249. <LocaleConsumer componentName="Calendar">
  250. {(locale: Locale['Calendar']) => (// eslint-disable-next-line jsx-a11y/no-static-element-interactions
  251. <div
  252. className={`${cardCls}-wrapper`}
  253. style={{ bottom: 0 }}
  254. onClick={e => this.showCard(e, key)}
  255. >
  256. {locale.remaining.replace('${remained}', String(remained))}
  257. </div>
  258. )}
  259. </LocaleConsumer>
  260. );
  261. return (
  262. <Popover
  263. key={`${date.valueOf()}`}
  264. content={content}
  265. position={pos as any}
  266. trigger="custom"
  267. visible={showCard && showCard[key] && showCard[key][0]}
  268. ref={ref => this.cardRef.set(key, ref)}
  269. >
  270. <li key={date as any} className={listCls} onClick={e => this.handleClick(e, [date])}>
  271. {this.formatDayString(month, dayString)}
  272. {shouldRenderCard ? text : null}
  273. {this.renderCusDateGrid(date)}
  274. </li>
  275. </Popover>
  276. );
  277. };
  278. formatDayString = (month: string, date: string) => {
  279. if (date === '1') {
  280. return (
  281. <LocaleConsumer componentName="Calendar">
  282. {(locale: Locale['Calendar'], localeCode: string) => (
  283. <span className={`${prefixCls}-date`}>
  284. {month}
  285. <span className={`${cssClasses.PREFIX}-today-date`}>&nbsp;{date}</span>
  286. {locale.datestring}
  287. </span>
  288. )}
  289. </LocaleConsumer>
  290. );
  291. }
  292. return (
  293. // eslint-disable-next-line max-len
  294. <span className={`${prefixCls}-date`}><span className={`${cssClasses.PREFIX}-today-date`}>{date}</span></span>
  295. );
  296. };
  297. renderCusDateGrid = (date: Date) => {
  298. const { dateGridRender } = this.props;
  299. if (!dateGridRender) {
  300. return null;
  301. }
  302. return dateGridRender(date.toString(), date);
  303. };
  304. renderWeekRow = (index: number | string, weekDay: MonthData[number], events: MonthlyEvent = {} as MonthlyEvent) => {
  305. const { markWeekend } = this.props;
  306. const { itemLimit } = this.state;
  307. const { display, day } = events;
  308. return (
  309. <div role="presentation" className={`${prefixCls}-weekrow`} ref={this.cellDom} key={`${index}-weekrow`}>
  310. <ul role="row" className={`${prefixCls}-skeleton`}>
  311. {weekDay.map(each => {
  312. const { date, dayString, isToday, isSameMonth, isWeekend, month, ind } = each;
  313. const listCls = cls({
  314. [`${cssClasses.PREFIX}-today`]: isToday,
  315. [`${cssClasses.PREFIX}-weekend`]: markWeekend && isWeekend,
  316. [`${prefixCls}-same`]: isSameMonth
  317. });
  318. const shouldRenderCollapsed = Boolean(day && day[ind] && day[ind].length > itemLimit);
  319. const inner = (
  320. <li role="gridcell" aria-label={date.toLocaleDateString()} aria-current={isToday ? "date" : false} key={`${date}-weeksk`} className={listCls} onClick={e => this.handleClick(e, [date])}>
  321. {this.formatDayString(month, dayString)}
  322. {this.renderCusDateGrid(date)}
  323. </li>
  324. );
  325. if (!shouldRenderCollapsed) {
  326. return inner;
  327. }
  328. return this.renderCollapsed(day[ind], each, listCls, month);
  329. })}
  330. </ul>
  331. <ul className={`${cssClasses.PREFIX}-event-items`}>
  332. {display ? this.renderEvents(display) : null}
  333. </ul>
  334. </div>
  335. );
  336. };
  337. renderMonthGrid = () => {
  338. const { parsedEvents } = this.state;
  339. return (
  340. <div role="presentation" className={`${prefixCls}-week`}>
  341. <ul role="presentation" className={`${prefixCls}-grid-col`}>
  342. {Object.keys(this.monthlyData).map(weekInd =>
  343. this.renderWeekRow(weekInd, this.monthlyData[weekInd], parsedEvents[weekInd])
  344. )}
  345. </ul>
  346. </div>
  347. );
  348. };
  349. render() {
  350. const { className, height, width, style, header } = this.props;
  351. const monthCls = cls(prefixCls, className);
  352. const monthStyle = {
  353. height,
  354. width,
  355. ...style,
  356. };
  357. return (
  358. <LocaleConsumer componentName="Calendar">
  359. {(locale: Locale['Calendar'], localeCode: string, dateFnsLocale: Locale['dateFnsLocale']) => (
  360. <div role="grid" className={monthCls} key={this.state.itemLimit} style={monthStyle}>
  361. <div role="presentation" className={`${prefixCls}-sticky-top`}>
  362. {header}
  363. {this.renderHeader(dateFnsLocale)}
  364. </div>
  365. <div role="presentation" className={`${prefixCls}-grid-wrapper`}>
  366. {this.renderMonthGrid()}
  367. </div>
  368. </div>
  369. )}
  370. </LocaleConsumer>
  371. );
  372. }
  373. }