1
0

TimePicker.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. /* eslint-disable no-unused-vars */
  2. import React from 'react';
  3. import PropTypes from 'prop-types';
  4. import classNames from 'classnames';
  5. import { noop, get } from 'lodash-es';
  6. import ConfigContext from '../configProvider/context';
  7. import BaseComponent, { ValidateStatus } from '../_base/baseComponent';
  8. import { strings, cssClasses } from '@douyinfe/semi-foundation/timePicker/constants';
  9. import Popover from '../popover';
  10. import { numbers as popoverNumbers } from '@douyinfe/semi-foundation/popover/constants';
  11. import TimePickerFoundation, { TimePickerAdapter } from '@douyinfe/semi-foundation/timePicker/foundation';
  12. import isNullOrUndefined from '@douyinfe/semi-foundation/utils/isNullOrUndefined';
  13. import Combobox from './Combobox';
  14. import TimeInput from './TimeInput';
  15. import { PanelShape, PanelShapeDefaults } from './PanelShape';
  16. import { TimeShape } from './TimeShape';
  17. import '@douyinfe/semi-foundation/timePicker/timePicker.scss';
  18. import Trigger from '../trigger';
  19. import { InputSize } from '../input';
  20. import { Position } from '../tooltip';
  21. import { ScrollItemProps } from '../scrollList/scrollItem';
  22. import { Locale } from '../locale/interface';
  23. import { Motion } from '../_base/base';
  24. export interface Panel {
  25. panelHeader?: React.ReactNode;
  26. panelFooter?: React.ReactNode;
  27. }
  28. export type BaseValueType = string | number | Date;
  29. export type Type = 'time' | 'timeRange';
  30. export type TimePickerProps = {
  31. autoAdjustOverflow?: boolean;
  32. autoFocus?: boolean; // TODO: autoFocus did not take effect
  33. className?: string;
  34. clearText?: string;
  35. dateFnsLocale?: Locale['dateFnsLocale'];
  36. defaultOpen?: boolean;
  37. defaultValue?: BaseValueType | BaseValueType[];
  38. disabled?: boolean;
  39. disabledHours?: () => number[];
  40. disabledMinutes?: (selectedHour: number) => number[];
  41. disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[];
  42. focusOnOpen?: boolean;
  43. format?: string;
  44. getPopupContainer?: () => HTMLElement;
  45. hideDisabledOptions?: boolean;
  46. hourStep?: number;
  47. id?: string;
  48. inputReadOnly?: boolean;
  49. inputStyle?: React.CSSProperties;
  50. insetLabel?: React.ReactNode;
  51. locale?: Locale['TimePicker'];
  52. localeCode?: string;
  53. minuteStep?: number;
  54. motion?: Motion;
  55. open?: boolean;
  56. panelFooter?: React.ReactNode;
  57. panelHeader?: React.ReactNode;
  58. panels?: Panel[]; // FIXME:
  59. placeholder?: string;
  60. popupClassName?: string;
  61. popupStyle?: React.CSSProperties;
  62. position?: Position;
  63. prefixCls?: string;
  64. rangeSeparator?: string;
  65. scrollItemProps?: ScrollItemProps;
  66. secondStep?: number;
  67. showClear?: boolean;
  68. size?: InputSize;
  69. style?: React.CSSProperties;
  70. timeZone?: string | number;
  71. triggerRender?: (props?: any) => React.ReactNode;
  72. type?: Type;
  73. use12Hours?: boolean;
  74. validateStatus?: ValidateStatus;
  75. value?: BaseValueType | BaseValueType[];
  76. zIndex?: number | string;
  77. onBlur?: React.FocusEventHandler<HTMLInputElement>;
  78. onChange?: TimePickerAdapter['notifyChange'];
  79. onFocus?: React.FocusEventHandler<HTMLInputElement>;
  80. onOpenChange?: (open: boolean) => void;
  81. };
  82. export interface TimePickerState {
  83. open: boolean;
  84. value: Date[];
  85. inputValue: string;
  86. currentSelectPanel: string | number;
  87. isAM: [boolean, boolean];
  88. showHour: boolean;
  89. showMinute: boolean;
  90. showSecond: boolean;
  91. invalid: boolean;
  92. }
  93. export default class TimePicker extends BaseComponent<TimePickerProps, TimePickerState> {
  94. static contextType = ConfigContext;
  95. static propTypes = {
  96. prefixCls: PropTypes.string,
  97. clearText: PropTypes.string,
  98. value: TimeShape,
  99. inputReadOnly: PropTypes.bool,
  100. disabled: PropTypes.bool,
  101. showClear: PropTypes.bool,
  102. defaultValue: TimeShape,
  103. open: PropTypes.bool,
  104. defaultOpen: PropTypes.bool,
  105. onOpenChange: PropTypes.func,
  106. position: PropTypes.any,
  107. getPopupContainer: PropTypes.func,
  108. placeholder: PropTypes.string,
  109. format: PropTypes.string,
  110. style: PropTypes.object,
  111. className: PropTypes.string,
  112. popupClassName: PropTypes.string,
  113. popupStyle: PropTypes.object,
  114. disabledHours: PropTypes.func,
  115. disabledMinutes: PropTypes.func,
  116. disabledSeconds: PropTypes.func,
  117. hideDisabledOptions: PropTypes.bool,
  118. onChange: PropTypes.func,
  119. use12Hours: PropTypes.bool,
  120. hourStep: PropTypes.number,
  121. minuteStep: PropTypes.number,
  122. secondStep: PropTypes.number,
  123. focusOnOpen: PropTypes.bool,
  124. autoFocus: PropTypes.bool,
  125. size: PropTypes.oneOf(strings.SIZE),
  126. panels: PropTypes.arrayOf(PropTypes.shape(PanelShape)),
  127. onFocus: PropTypes.func,
  128. onBlur: PropTypes.func,
  129. locale: PropTypes.object,
  130. localeCode: PropTypes.string,
  131. dateFnsLocale: PropTypes.object,
  132. zIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  133. insetLabel: PropTypes.node,
  134. validateStatus: PropTypes.oneOf(strings.STATUS),
  135. type: PropTypes.oneOf<TimePickerProps['type']>(strings.TYPES),
  136. rangeSeparator: PropTypes.string,
  137. triggerRender: PropTypes.func,
  138. timeZone: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
  139. scrollItemProps: PropTypes.object,
  140. motion: PropTypes.oneOfType([PropTypes.bool, PropTypes.func, PropTypes.object]),
  141. autoAdjustOverflow: PropTypes.bool,
  142. ...PanelShape,
  143. inputStyle: PropTypes.object,
  144. };
  145. static defaultProps = {
  146. autoAdjustOverflow: true,
  147. getPopupContainer: () => document.body,
  148. showClear: true,
  149. zIndex: popoverNumbers.DEFAULT_Z_INDEX,
  150. rangeSeparator: strings.DEFAULT_RANGE_SEPARATOR,
  151. onOpenChange: noop,
  152. clearText: 'clear',
  153. prefixCls: cssClasses.PREFIX,
  154. inputReadOnly: false,
  155. style: {},
  156. className: '',
  157. popupClassName: '',
  158. popupStyle: { left: '0px', top: '0px' },
  159. disabledHours: () => [] as number[],
  160. disabledMinutes: () => [] as number[],
  161. disabledSeconds: () => [] as number[],
  162. hideDisabledOptions: false,
  163. // position: 'bottomLeft',
  164. onFocus: noop,
  165. onBlur: noop,
  166. onChange: noop,
  167. use12Hours: false,
  168. focusOnOpen: false,
  169. onKeyDown: noop,
  170. size: 'default' as const,
  171. type: strings.DEFAULT_TYPE,
  172. ...PanelShapeDefaults,
  173. // format: strings.DEFAULT_FORMAT,
  174. // open and value controlled
  175. };
  176. foundation: TimePickerFoundation;
  177. timePickerRef: React.MutableRefObject<HTMLDivElement>;
  178. savePanelRef: React.RefObject<HTMLDivElement>;
  179. clickOutSideHandler: (e: MouseEvent) => void;
  180. constructor(props: TimePickerProps) {
  181. super(props);
  182. const { format = strings.DEFAULT_FORMAT } = props;
  183. this.state = {
  184. open: props.open || props.defaultOpen || false,
  185. value: [], // Date[]
  186. inputValue: '', // time string
  187. currentSelectPanel: 0,
  188. isAM: [true, false],
  189. showHour: Boolean(format.match(/HH|hh|H|h/g)),
  190. showMinute: Boolean(format.match(/mm/g)),
  191. showSecond: Boolean(format.match(/ss/g)),
  192. invalid: undefined
  193. };
  194. this.foundation = new TimePickerFoundation(this.adapter);
  195. this.timePickerRef = React.createRef();
  196. this.savePanelRef = React.createRef();
  197. }
  198. get adapter(): TimePickerAdapter<TimePickerProps, TimePickerState> {
  199. return {
  200. ...super.adapter,
  201. togglePanel: show => {
  202. this.setState({ open: show });
  203. },
  204. registerClickOutSide: () => {
  205. if (this.clickOutSideHandler) {
  206. this.adapter.unregisterClickOutSide();
  207. }
  208. this.clickOutSideHandler = e => {
  209. const panel = this.savePanelRef && this.savePanelRef.current;
  210. const isInPanel = e.target && panel && panel.contains(e.target as Node);
  211. const isInTimepicker =
  212. this.timePickerRef &&
  213. this.timePickerRef.current &&
  214. this.timePickerRef.current.contains(e.target as Node);
  215. if (!isInTimepicker && !isInPanel) {
  216. const clickedOutside = true;
  217. this.foundation.hanldePanelClose(clickedOutside, e);
  218. }
  219. };
  220. document.addEventListener('mousedown', this.clickOutSideHandler);
  221. },
  222. setInputValue: (inputValue, cb) => this.setState({ inputValue }, cb),
  223. unregisterClickOutSide: () => {
  224. if (this.clickOutSideHandler) {
  225. document.removeEventListener('mousedown', this.clickOutSideHandler);
  226. this.clickOutSideHandler = null;
  227. }
  228. },
  229. notifyOpenChange: (...args) => this.props.onOpenChange(...args),
  230. notifyChange: (...args) => this.props.onChange && this.props.onChange(...args),
  231. notifyFocus: (...args) => this.props.onFocus && this.props.onFocus(...args),
  232. notifyBlur: (...args) => this.props.onBlur && this.props.onBlur(...args),
  233. isRangePicker: () => this.props.type === strings.TYPE_TIME_RANGE_PICKER,
  234. };
  235. }
  236. static getDerivedStateFromProps(nextProps: TimePickerProps, prevState: TimePickerState) {
  237. if ('open' in nextProps && nextProps.open !== prevState.open) {
  238. return {
  239. open: nextProps.open,
  240. };
  241. }
  242. return null;
  243. }
  244. componentDidUpdate(prevProps: TimePickerProps) {
  245. // if (this.isControlled('open') && this.props.open != null && this.props.open !== prevProps.open) {
  246. // this.foundation.setPanel(this.props.open);
  247. // }
  248. if (this.isControlled('value') && this.props.value !== prevProps.value) {
  249. this.foundation.refreshProps({
  250. ...this.props,
  251. });
  252. } else if (this.props.timeZone !== prevProps.timeZone) {
  253. this.foundation.refreshProps({
  254. timeZone: this.props.timeZone,
  255. __prevTimeZone: prevProps.timeZone,
  256. value: this.state.value,
  257. });
  258. }
  259. }
  260. onCurrentSelectPanelChange = (currentSelectPanel: string) => {
  261. this.setState({ currentSelectPanel });
  262. };
  263. handlePanelChange = (
  264. value: { isAM: boolean; value: string; timeStampValue: number },
  265. index: number
  266. ) => this.foundation.handlePanelChange(value, index);
  267. handleInput = (value: string) => this.foundation.handleInputChange(value);
  268. createPanelProps = (index = 0) => {
  269. const { panels, panelFooter, panelHeader, locale } = this.props;
  270. const panelProps = {
  271. panelHeader,
  272. panelFooter,
  273. };
  274. if (this.adapter.isRangePicker()) {
  275. const defaultHeaderMap = {
  276. 0: locale.begin,
  277. 1: locale.end,
  278. };
  279. panelProps.panelHeader = get(
  280. panels,
  281. index,
  282. isNullOrUndefined(panelHeader) ? get(defaultHeaderMap, index, null) : panelHeader
  283. );
  284. panelProps.panelFooter = get(panels, index, panelFooter);
  285. }
  286. return panelProps;
  287. };
  288. getPanelElement() {
  289. const { prefixCls, type } = this.props;
  290. const { isAM, value } = this.state;
  291. const format = this.foundation.getDefaultFormatIfNeed();
  292. const timePanels = [
  293. <Combobox
  294. {...this.props}
  295. key={0}
  296. format={format}
  297. isAM={isAM[0]}
  298. timeStampValue={value[0]}
  299. prefixCls={`${prefixCls}-panel`}
  300. onChange={v => this.handlePanelChange(v, 0)}
  301. onCurrentSelectPanelChange={this.onCurrentSelectPanelChange}
  302. {...this.createPanelProps(0)}
  303. />,
  304. ];
  305. if (type === strings.TYPE_TIME_RANGE_PICKER) {
  306. timePanels.push(
  307. <Combobox
  308. {...this.props}
  309. key={1}
  310. format={format}
  311. isAM={isAM[1]}
  312. timeStampValue={value[1]}
  313. prefixCls={`${prefixCls}-panel`}
  314. onChange={v => this.handlePanelChange(v, 1)}
  315. onCurrentSelectPanelChange={this.onCurrentSelectPanelChange}
  316. {...this.createPanelProps(1)}
  317. />
  318. );
  319. }
  320. const wrapCls = classNames({
  321. [cssClasses.RANGE_PANEL_LISTS]: this.adapter.isRangePicker(),
  322. });
  323. return (
  324. <div ref={this.savePanelRef} className={wrapCls}>
  325. {timePanels.map(panel => panel)}
  326. </div>
  327. );
  328. }
  329. getPopupClassName() {
  330. const { use12Hours, prefixCls, popupClassName } = this.props;
  331. const { showHour, showMinute, showSecond } = this.state;
  332. let selectColumnCount = 0;
  333. if (showHour) {
  334. selectColumnCount += 1;
  335. }
  336. if (showMinute) {
  337. selectColumnCount += 1;
  338. }
  339. if (showSecond) {
  340. selectColumnCount += 1;
  341. }
  342. if (use12Hours) {
  343. selectColumnCount += 1;
  344. }
  345. return classNames(
  346. `${prefixCls}-panel`,
  347. popupClassName,
  348. {
  349. [`${prefixCls}-panel-narrow`]: (!showHour || !showMinute || !showSecond) && !use12Hours,
  350. [cssClasses.RANGE_PICKER]: this.adapter.isRangePicker(),
  351. },
  352. `${prefixCls}-panel-column-${selectColumnCount}`
  353. );
  354. }
  355. focus() {
  356. // TODO this.picker is undefined, confirm keep this func or not
  357. // this.picker.focus();
  358. }
  359. blur() {
  360. // TODO this.picker is undefined, confirm keep this func or not
  361. // this.picker.blur();
  362. }
  363. handlePanelVisibleChange = (visible: boolean) => this.foundation.handleVisibleChange(visible);
  364. openPanel = () => {
  365. this.foundation.handlePanelOpen();
  366. };
  367. handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
  368. this.foundation.handleFocus(e);
  369. };
  370. handleBlur = (e: React.FocusEvent<HTMLInputElement>) => this.foundation.handleInputBlur(e);
  371. setTimePickerRef: React.LegacyRef<HTMLDivElement> = node => (this.timePickerRef.current = node);
  372. render() {
  373. const {
  374. prefixCls,
  375. placeholder,
  376. disabled,
  377. defaultValue,
  378. className,
  379. popupStyle,
  380. size,
  381. style,
  382. locale,
  383. localeCode,
  384. zIndex,
  385. getPopupContainer,
  386. insetLabel,
  387. inputStyle,
  388. showClear,
  389. panelHeader,
  390. panelFooter,
  391. rangeSeparator,
  392. onOpenChange,
  393. popupClassName: propPopupClassName,
  394. hideDisabledOptions,
  395. use12Hours,
  396. minuteStep,
  397. hourStep,
  398. secondStep,
  399. scrollItemProps,
  400. triggerRender,
  401. motion,
  402. autoAdjustOverflow,
  403. ...rest
  404. } = this.props;
  405. const format = this.foundation.getDefaultFormatIfNeed();
  406. const position = this.foundation.getPosition();
  407. const useCustomTrigger = typeof triggerRender === 'function';
  408. const { open, inputValue, invalid, value } = this.state;
  409. const popupClassName = this.getPopupClassName();
  410. const headerPrefix = classNames({
  411. [`${prefixCls}-header`]: true,
  412. });
  413. const panelPrefix = classNames({
  414. [`${prefixCls}-panel`]: true,
  415. [`${prefixCls}-panel-${ size}`]: size,
  416. });
  417. const inputProps = {
  418. ...rest,
  419. disabled,
  420. prefixCls,
  421. size,
  422. showClear: disabled ? false : showClear,
  423. style: inputStyle,
  424. value: inputValue,
  425. onFocus: this.handleFocus,
  426. insetLabel,
  427. format,
  428. locale,
  429. localeCode,
  430. invalid,
  431. placeholder,
  432. onChange: this.handleInput,
  433. onBlur: this.handleBlur,
  434. };
  435. const outerProps = {} as { onClick: () => void };
  436. if (useCustomTrigger) {
  437. outerProps.onClick = this.openPanel;
  438. }
  439. return (
  440. <div
  441. ref={this.setTimePickerRef}
  442. className={classNames({ [prefixCls]: true }, className)}
  443. style={style}
  444. {...outerProps}
  445. >
  446. <Popover
  447. getPopupContainer={getPopupContainer}
  448. zIndex={zIndex as number}
  449. prefixCls={panelPrefix}
  450. contentClassName={popupClassName}
  451. style={popupStyle}
  452. content={this.getPanelElement()}
  453. trigger={'custom'}
  454. position={position}
  455. visible={disabled ? false : Boolean(open)}
  456. motion={motion}
  457. autoAdjustOverflow={autoAdjustOverflow}
  458. >
  459. {useCustomTrigger ? (
  460. <Trigger
  461. triggerRender={triggerRender}
  462. disabled={disabled}
  463. value={value}
  464. inputValue={inputValue}
  465. onChange={this.handleInput}
  466. placeholder={placeholder}
  467. componentName={'TimePicker'}
  468. componentProps={{ ...this.props }}
  469. />
  470. ) : (
  471. <span className={headerPrefix}>
  472. <TimeInput {...inputProps} />
  473. </span>
  474. )}
  475. </Popover>
  476. </div>
  477. );
  478. }
  479. }