/* eslint-disable camelcase */ import {ReactNode, MouseEvent, FocusEvent} from 'react' import dayjs, {Dayjs} from 'dayjs' import c from 'classnames' import {Popper} from '@befe/brick-comp-popper' import { createDelayActionQueue, createListenerGroup, createRef, getClosestMappedSize, pickDerivedStateFromProps, } from '@befe/brick-utils' import {isUndefined, pickBy} from 'lodash-es' import {InputProps} from '@befe/brick-comp-input' import {ConfiguredComponent} from '@befe/brick-core' import {UiRangePanel} from '../ui-comps/ui-range-panel' import { createMonthPanel, TypeModuleMonthPanel, } from '../modules/create-month-panel' import {getPickerMode} from '../utils/picker-utils' import {createDatePanel} from '../modules/create-date-panel' import {getValueExtractor} from '../utils/get-value-extractor' import {extendDayjsPlugins} from '../utils/extend-dayjs-plugins' import {localeDictDatePicker} from '../locale' import {syncDisplayedDatePair} from '../utils/sync-range-displayed-date-pair' import {QuickActions} from './create-quick-actions' import {DEFAULT_ITEM_FORMAT} from './const-default-format' import {createRangePickerUserInput} from './create-range-input' import { TypeActionLink, TypeDatePickerMode, TypeDateValue, TypePickerSize, TypePickerStatus, } from './prop-types' import {preventPickerBlurred} from './create-inner-date-picker' type InnerRangePickerPropsFromInput = Pick export interface RangePickerProps extends InnerRangePickerPropsFromInput { className?: string startValue?: TypeDateValue endValue?: TypeDateValue defaultStartValue?: TypeDateValue defaultEndValue?: TypeDateValue disabled?: boolean size?: TypePickerSize status?: TypePickerStatus mode?: TypeDatePickerMode itemFormat?: string renderItem?: ( currentDate: Dayjs, today: Dayjs ) => (ReactNode | string | number) /** * @todo: 未实现 range picker 的 disabled 问题 */ getDisabledItem?: (currentDate: Dayjs, today: Dayjs) => boolean /** * 当日期面板打开时, 设置其 "显示的面板位置" * * 这个可能主要在当没有选择日期时, 用于确定面板月份的显示策略 ( 缺省方案是显示 "当月" + "1 个月" ) */ getDefaultDisplayDateRange?: ( today: Dayjs, ) => { start: Dayjs end: Dayjs } showTime?: boolean getDisabledTime?: (currentDate: Dayjs) => { start: number | null end: number | null } open?: boolean | undefined clearable?: boolean startPlaceholder?: string endPlaceholder?: string startStyle?: CSSStyleDeclaration endStyle?: CSSStyleDeclaration popupClassName?: string popupStyle?: CSSStyleDeclaration onChange?: (start: Dayjs | null, end: Dayjs | null) => void /** * @todo: 待实现 * * 这个传递 start, end 的原因, 可参见 date picker 的 onOpenChange 的注释 */ onOpenChange?: ( openStatus: boolean, start: Dayjs | null, end: Dayjs | null, ) => void renderFooter?: () => ReactNode | TypeActionLink[] renderQuickActions?: () => TypeActionLink[] onFocus?: (e: FocusEvent) => void onBlur?: (e: FocusEvent) => void autoFocus?: boolean onMouseEnter?: (e: MouseEvent) => void onMouseLeave?: (e: MouseEvent) => void yearStart?: number yearEnd?: number } export interface RangePickerState { open: boolean isStartZooming: boolean isEndZooming: boolean startValue: Dayjs | null endValue: Dayjs | null startDisplayedDate: Dayjs | null endDisplayedDate: Dayjs | null startZoomedDisplayedDate: Dayjs | null endZoomedDisplayedDate: Dayjs | null isStartUserInput: boolean startUserInputText: string isEndUserInput: boolean endUserInputText: string startFocus: boolean endFocus: boolean isSelecting: boolean firstSelectedDate: Dayjs | null secondHoveringDate: Dayjs | null } type TypeRangePickerValueProp = 'startValue' | 'endValue'; type TypeRangePickerDisplayedProp = 'startDisplayedDate' | 'endDisplayedDate'; type TypeRangePickerDefaultProp = 'defaultStartValue' | 'defaultEndValue'; function initPickerValue( comp: InnerRangePicker, { defaultValueProp, valueProp, displayDateProp, mode, }: { defaultValueProp: TypeRangePickerDefaultProp valueProp: TypeRangePickerValueProp displayDateProp: TypeRangePickerDisplayedProp mode: RangePickerProps['mode'] } ) { mode = mode || 'date' const intialValue = typeof comp.props[defaultValueProp] === 'undefined' ? comp.props[valueProp] : comp.props[defaultValueProp] comp.state[valueProp] = intialValue ? dayjs(intialValue).startOf(mode) : null comp.state[displayDateProp] = dayjs(intialValue || undefined) .startOf(mode === 'date' ? 'month' : 'year') } /** */ export class InnerRangePicker extends ConfiguredComponent { static dayjs = dayjs static displayName = 'RangePicker' static defaultProps: RangePickerProps = { clearable: true, } defaultItemFormat = DEFAULT_ITEM_FORMAT[this.props.mode || 'date'] componentLocale = localeDictDatePicker state: RangePickerState = { open: false, startValue: null, endValue: null, startDisplayedDate: null, endDisplayedDate: null, isStartZooming: false, isEndZooming: false, startZoomedDisplayedDate: null, endZoomedDisplayedDate: null, isStartUserInput: false, startUserInputText: '', isEndUserInput: false, endUserInputText: '', startFocus: false, endFocus: false, isSelecting: false, firstSelectedDate: null, secondHoveringDate: null, } refPanelWrapper = createRef() userInput = createRangePickerUserInput(this) get size(): RangePickerProps['size'] { return this.getDefaultValueUsingTheme('size', 'baseSize') } /** * @deprecated @0427: 为了避免歧义, 改为 inputWrapper (因此处并非 input element) */ get inputElem() { return this.userInput.inputWrapperElem } get inputWrapperElem() { return this.userInput.inputWrapperElem } get startInputElem() { return this.userInput.startInputElem } get endInputElem() { return this.userInput.endInputElem } focus() { this.startInputElem?.focus() } getWeekDayText = (day: number) => this.getLocaleText('get_week_day_text', day) getPanelTitle_yearMonth = (date: Dayjs) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return this.getLocaleText('get_year_month_title', date as any) } getPanelTitle_year = (date: Dayjs) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return this.getLocaleText('get_year_title', date as any) } startDatePanel = createDatePanel(this, { isRange: true, isStartPanel: true, getTitle: this.getPanelTitle_yearMonth, getWeekDayText: this.getWeekDayText, }) endDatePanel = createDatePanel(this, { isRange: true, isStartPanel: false, getTitle: this.getPanelTitle_yearMonth, getWeekDayText: this.getWeekDayText, }) getMonthText = (month: number) => this.getLocaleText('get_month_text', month) zoomedStartDatePanel: TypeModuleMonthPanel = createMonthPanel(this, { isRange: true, isForDateZooming: true, isStartPanel: true, valueProp: 'startDisplayedDate', displayProp: 'startZoomedDisplayedDate', getTitle: this.getPanelTitle_year, getMonthText: this.getMonthText, }) zoomedEndDatePanel: TypeModuleMonthPanel = createMonthPanel(this, { isRange: true, isForDateZooming: true, isStartPanel: false, valueProp: 'endDisplayedDate', displayProp: 'endZoomedDisplayedDate', getTitle: this.getPanelTitle_year, getMonthText: this.getMonthText, }) /** * @todo: month range picker */ startMonthPanel = createMonthPanel(this, { isRange: true, isStartPanel: true, getTitle: this.getPanelTitle_year, getMonthText: this.getMonthText, syncRangePickersYearAside: () => { const year = this.state.startDisplayedDate?.year() if (!year) { return } const endMonthPanel = this.endMonthPanel endMonthPanel.setYearRangeAnchor(year) endMonthPanel.syncAsideScroll() }, }) endMonthPanel = createMonthPanel(this, { isRange: true, isStartPanel: false, getTitle: this.getPanelTitle_year, getMonthText: this.getMonthText, syncRangePickersYearAside: () => { const year = this.state.endDisplayedDate?.year() if (!year) { return } const startMonthPanel = this.startMonthPanel startMonthPanel.setYearRangeAnchor(year) startMonthPanel.syncAsideScroll() }, }) actionQueue = createDelayActionQueue() listenerGroup = createListenerGroup() get className() { const {className} = this.props const size = this.size return c( 'brick-range-picker', { [`brick-range-picker-size-${size || ''}`]: size, }, className ) } get popupClassName() { const {popupClassName} = this.props const size = getClosestMappedSize(this.size) return c( 'brick-range-picker-popper', { [`brick-range-picker-popper-size-${size || ''}`]: size, }, popupClassName ) } get computedItemFormat() { const itemFormat = this.props.itemFormat if (itemFormat) { return itemFormat } const mode = this.props.mode || 'date' return DEFAULT_ITEM_FORMAT[mode] } get computedItemValueExtractor() { return getValueExtractor(this) } get shouldShowPanel() { return this.state.open || this.state.isStartUserInput || this.state.isEndUserInput } triggerChange = ( { startValue = this.state.startValue, endValue = this.state.endValue, }: { startValue?: Dayjs | null endValue?: Dayjs | null } ) => { const {onChange} = this.props onChange && onChange( startValue, endValue ) } handleCalendarIconClick = () => { if (!this.props.disabled) { this.setOpenStatus({ open: true, }) this.startInputElem?.focus() } } setOpenStatus = ( { open = this.state.open, isStartUserInput = this.state.isStartUserInput, isEndUserInput = this.state.isEndUserInput, }: { open?: boolean isStartUserInput?: boolean isEndUserInput?: boolean }) => { const {getDefaultDisplayDateRange} = this.props const prevOpen = this.shouldShowPanel const currOpen = open || isStartUserInput || isEndUserInput this.setState({ open, isStartUserInput, isEndUserInput, }) if (currOpen !== prevOpen && currOpen) { const startValue = this.state.startValue const endValue = this.state.endValue const { startDisplayedDate, endDisplayedDate, } = syncDisplayedDatePair( this.props.mode, startValue, endValue, getDefaultDisplayDateRange ) this.setState({ isSelecting: false, startDisplayedDate, endDisplayedDate, }) if (this.props.mode === 'month') { const startMonthPanel = this.startMonthPanel startMonthPanel.setYearRangeAnchor( startDisplayedDate.year() ) startMonthPanel.syncAsideScroll() const endMonthPanel = this.endMonthPanel endMonthPanel.setYearRangeAnchor( endDisplayedDate.year() ) endMonthPanel.syncAsideScroll() } } } constructor(props: any) { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument super(props) // 确保 dayjs 插件扩展成功 extendDayjsPlugins() if (this.props.mode === 'quarter') { console.error('目前 brick 范围选择器暂不支持季度模式') } initPickerValue(this, { defaultValueProp: 'defaultStartValue', valueProp: 'startValue', displayDateProp: 'startDisplayedDate', mode: this.props.mode, }) initPickerValue(this, { defaultValueProp: 'defaultEndValue', valueProp: 'endValue', displayDateProp: 'endDisplayedDate', mode: this.props.mode, }) // 确保前后不要出现同一个月 if ( this.state.endDisplayedDate!.isSame(this.state.startDisplayedDate) ) { const mode = this.props.mode || 'date' if (mode === 'date') { this.state.endDisplayedDate = this.state.endDisplayedDate!.add(1, 'month') } else if (mode === 'month') { this.state.endDisplayedDate = this.state.endDisplayedDate!.add(1, 'year') } } const onRangeSelected = (startValue: Dayjs, endValue: Dayjs) => { this.triggerChange({ startValue, endValue, }) this.setState({ startUserInputText: this.userInput.formatStartInputValue(startValue), endUserInputText: this.userInput.formatEndInputValue(endValue), }) this.setOpenStatus({ open: false, isStartUserInput: false, isEndUserInput: false, }) } this.startDatePanel.init({ onZoomOut: () => { this.setState({ isStartZooming: true, startZoomedDisplayedDate: this.state.startDisplayedDate, }) this.zoomedStartDatePanel.setYearRangeAnchor( this.state.startDisplayedDate!.year() ) this.zoomedStartDatePanel.syncAsideScroll() }, onSelectedRange: onRangeSelected, }) this.endDatePanel.init({ onZoomOut: () => { this.setState({ isEndZooming: true, endZoomedDisplayedDate: this.state.endDisplayedDate, }) this.zoomedEndDatePanel.setYearRangeAnchor( this.state.startDisplayedDate!.year() ) this.zoomedEndDatePanel.syncAsideScroll() }, onSelectedRange: onRangeSelected, }) this.zoomedStartDatePanel.init({ onZoomIn: () => { this.setState({isStartZooming: false}) }, onSelected: (value) => { const nextMonth = value.add(1, 'month') if (nextMonth.isAfter(this.state.endDisplayedDate)) { this.setState({ endDisplayedDate: nextMonth, }) } this.setState({ isStartZooming: false, }) }, }) this.zoomedEndDatePanel.init({ onZoomIn: () => { this.setState({isEndZooming: false}) }, onSelected: (value) => { this.setState({isEndZooming: false}) const prevMonth = value.subtract(1, 'month') if (prevMonth.isBefore(this.state.startDisplayedDate)) { this.setState({ startDisplayedDate: prevMonth, }) } this.setState({ isEndZooming: false, }) }, }) this.startMonthPanel.init({ onSelectedRange: onRangeSelected, }) this.endMonthPanel.init({ onSelectedRange: onRangeSelected, }) } static getDerivedStateFromProps( nextProps: RangePickerProps ) { const partialState = pickDerivedStateFromProps( nextProps, ['open', 'startValue', 'endValue'] ) const mode = nextProps.mode || 'date' if (partialState) { if (partialState.startValue) { partialState.startValue = dayjs(partialState.startValue).startOf(mode) } if (partialState.endValue) { partialState.endValue = dayjs(partialState.endValue).startOf(mode) } } return partialState || null } componentDidMount(): void { this.listenerGroup.add(document, { mousedown: (e: MouseEvent) => { const targetElem = e.target as Node if ( this.state.open && this.refPanelWrapper.elem && this.refPanelWrapper.elem.elemPopperWrap && !this.refPanelWrapper.elem.elemPopperWrap.contains(targetElem) && this.userInput.inputWrapperElem && !this.userInput.inputWrapperElem.contains(targetElem) ) { this.setOpenStatus({ open: false, }) } }, }) this.actionQueue.execute() } componentDidUpdate(): void { this.actionQueue.execute() } componentWillUnmount(): void { this.listenerGroup.removeAll() } quickActions = new QuickActions() /** * @question. 这个是为了做 "测试用" 提供的内部 API? * * @param start * @param end */ setValue = (start?: TypeDateValue, end?: TypeDateValue) => { let startValue: Dayjs | null | undefined let endValue: Dayjs | null | undefined const mode = getPickerMode(this.props.mode) if (start) { startValue = dayjs(start).startOf(mode) } else if (start === null) { startValue = null } if (end) { endValue = dayjs(end).startOf(mode) if (startValue && endValue.isBefore(startValue)) { ([startValue, endValue] = [ endValue, startValue, ]) } } else if (end === null) { endValue = null } const resultState = pickBy({ startValue, endValue, }, (value) => !isUndefined(value)) as unknown as Pick const isStartUndefined = isUndefined(startValue) if (isStartUndefined) { resultState.isStartZooming = false } const isEndUndefined = isUndefined(endValue) if (isEndUndefined) { resultState.isEndZooming = false } if (isStartUndefined && isEndUndefined) { return } this.setState(resultState) this.syncDisplayDate() } getValue = () => { return [ this.state.startValue, this.state.endValue, ] } syncDisplayDate = () => { const startValue = this.state.startValue const endValue = this.state.endValue let endDisplayedDate = ( endValue && endValue.startOf('month') ) || this.state.endDisplayedDate if ( endDisplayedDate && startValue ) { const mode = this.props.mode || 'date' if ( mode === 'date' && endDisplayedDate.isSame(startValue, 'month') ) { endDisplayedDate = startValue.add(1, 'month') } else if ( mode === 'month' && endDisplayedDate.isSame(startValue, 'year') ) { endDisplayedDate = startValue.add(1, 'year') } } this.setState({ startDisplayedDate: startValue && startValue.startOf('month'), endDisplayedDate, isStartZooming: false, isEndZooming: false, isSelecting: false, }) } renderPanel() { const { titleElem: leftTitleElem, contentElem: leftContentElem, asideElem: leftAsideElem, contentType: leftContentType, } = this.state.isStartZooming ? this.zoomedStartDatePanel.getRenderElem() : this.props.mode === 'month' ? this.startMonthPanel.getRenderElem() : this.startDatePanel.getRenderElem() const { titleElem: rightTitleElem, contentElem: rightContentElem, asideElem: rightAsideElem, contentType: rightContentType, } = this.state.isEndZooming ? this.zoomedEndDatePanel.getRenderElem() : this.props.mode === 'month' ? this.endMonthPanel.getRenderElem() : this.endDatePanel.getRenderElem() const quickActionsElem = this.quickActions.renderQuickActions({ getValue: () => this.state.startValue, getActionQueue: () => this.actionQueue, syncDisplayDate: () => this.syncDisplayDate(), onMouseDown: preventPickerBlurred, renderQuickActions: this.props.renderQuickActions, }) return ( ) } render() { const shouldShowPanel = this.shouldShowPanel const props = this.props return (
{this.userInput.renderInput({ status: props.status, disabled: props.disabled, size: this.size, })} {this.renderPanel()}
) } }