1
0

TimePicker.tsx 18 KB

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