TimePicker.tsx 19 KB

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