/* eslint-disable camelcase */ import {FC, ReactNode, FocusEvent, MouseEvent} from 'react' import {Popper} from '@befe/brick-comp-popper' import { createDelayActionQueue, createListenerGroup, getClosestMappedSize, getDefault, isDev, pickDerivedStateFromProps, StringIndexedObject, } from '@befe/brick-utils' import dayjs, {Dayjs} from 'dayjs' import c from 'classnames' import {InputProps} from '@befe/brick-comp-input' import {ConfiguredComponent} from '@befe/brick-core' import {UiTimePanel, COL_NAMES} from '@befe/brick-comp-time-picker' import {Link} from '@befe/brick-comp-link' import {Button} from '@befe/brick-comp-button' import {createDatePanel} from '../modules/create-date-panel' import {createMonthPanel, TypeModuleMonthPanel} from '../modules/create-month-panel' import {createQuarterPanel} from '../modules/create-quarter-panel' import {getPickerMode} from '../utils/picker-utils' import {getValueExtractor} from '../utils/get-value-extractor' import {extendDayjsPlugins} from '../utils/extend-dayjs-plugins' import {localeDictDatePicker} from '../locale' import {TypeActionLink, TypeDatePickerMode, TypeDateValue, TypePickerSize, TypePickerStatus} from './prop-types' import {DEFAULT_ITEM_FORMAT} from './const-default-format' import {CalendarIcon} from './partial-calender-icon' import {QuickActions} from './create-quick-actions' import {createDatePickerUserInput} from './create-user-input' type InnerDatePickerPropsFromInput = Pick export interface DatePickerProps extends InnerDatePickerPropsFromInput { /** * 自定义 class */ className?: string /** * 选中的日期值 (控制属性) * * @type Date | DayJS | null */ value?: TypeDateValue /** * 选中的日期值 (非控制属性) * * @type Date | DayJS | null */ defaultValue?: TypeDateValue disabled?: boolean /** * 整体大小控制 : xs, sm, md, lg, xl * 会影响 "输入框" & "面板" 的尺寸 */ size?: TypePickerSize /** * 是否当前出于出错状态 */ status?: TypePickerStatus /** * 选择器的取值类型 */ mode?: TypeDatePickerMode /** * 用来将 value 格式化给 input 框, 以及从 input 框中还原 value * * 默认: * - date: 'YYYY-MM-DD' * - month: 'YYYY-MM' * - quarter: 'YYYY-\QQ' * * 可参考: * [基础 format 格式](https://github.com/iamkun/dayjs/blob/HEAD/docs/en/API-reference.md#format-formatstringwithtokens-string) * & [高级 format 格式](https://github.com/iamkun/dayjs/blob/66ce23f2e18c5506e8f1a7ef20d3483a4df80087/docs/en/Plugin.md#advancedformat) */ itemFormat?: string renderItem?: ( currentDate: Dayjs, today: Dayjs ) => (JSX.Element | string | number) getDisabledItem?: (currentDate: Dayjs, today: Dayjs) => boolean /** * 当日期面板打开时, 设置其 "显示的面板位置" * * 这个可能主要在当没有选择日期时, 用于确定面板月份的显示策略 ( 缺省方案是显示 "当月" 或 "当年" ) */ getDefaultDisplayDate?: (today: Dayjs) => Dayjs /** * 只在 date 模式有意义, 是否允许选择时间 * * @default false */ showTime?: boolean /** * 是否显示此刻 * * @default false */ hideNow?: boolean /** * 是否显示时间 * * @default false */ showSecond?: boolean /** * 某天中, 可选的时间范围 * * start, end 0 ~ 24 x 60 x 60 (一天的秒数), 如果为 null, 视为 无限制 */ getDisabledTime?: (currentDate: Dayjs) => ({ start: number | null end: number | null }) /** * 面板的显隐 (控制属性) */ open?: boolean | undefined /** * 当选择器的面板显隐状态发生更改时, 会触发该回调 * 这个是提供给外部组件, 进行完全的 date picker 面板显隐控制用 * * @note: 如果你要接管 open 属性, 请务必确保这个 API 也实现了 * * @param openStatus */ onOpenChange?: ( openStatus: boolean, value: Dayjs | null, ) => void /** * 日期选择器 input 框的 placeholder */ placeholder?: string style?: CSSStyleDeclaration /** * 面板的附加 className */ popupClassName?: string /** * @todo * * 面板的附加 style 控制 */ popupStyle?: CSSStyleDeclaration /** * 当选择中一个日期时, 会触发该回调 * * @param selected */ onChange?: (selected: Dayjs | null) => void /** * 自定义绘制 "面板的脚区域" */ renderFooter?: () => JSX.Element | TypeActionLink[] /** * @todo * 自定义绘制面板的侧边快捷操作 */ renderQuickActions?: () => TypeActionLink[] /** * 提供给日期 (date) 选择中, 快速切换月份时, 年份的起始范围 * * 默认: 已选中年或当前年的往前 12年 */ yearStart?: number /** * 参见 yearStart, * * 默认: 已选中年或当前年的往后 12年 */ yearEnd?: number /** * 是否支持清除已选值 */ clearable?: boolean onFocus?: (e: FocusEvent) => void onBlur?: (e: FocusEvent) => void autoFocus?: boolean onMouseEnter?: (e: MouseEvent) => void onMouseLeave?: (e: MouseEvent) => void iconSvg?: FC iconNode?: ReactNode } export interface DatePickerState { open: boolean isZooming: boolean inputValue: string value: Dayjs | null timeState: { hour: number | undefined minute: number | undefined second: number | undefined } displayedDate: Dayjs | null zoomedDisplayedDate: Dayjs | null isUserInput: boolean userInputText: string isHoverOverCalendar: boolean } export interface Type_InnerDatePicker { changeValue: ( selectedDate: (dayjs.Dayjs | null), opts?: { timeState?: DatePickerState['timeState'] onStateChanged?: () => void } ) => void processOpenStatus: (opts: { open?: boolean; isUserInput?: boolean }) => void } const mode2PanelProp: StringIndexedObject = { month: 'monthPanel', quarter: 'quarterPanel', } export const preventPickerBlurred = (e: MouseEvent) => { e.preventDefault() } function getModeForTitle( mode: 'date' | 'month' | 'quarter', showTime = false, showSecond = false ) { if (mode === 'date') { if (!showTime) { return 'date' } if (!showSecond) { return 'date-time-without-second' } return 'date-time'; } return mode; } /** * @todo. @review. * 这个是之前为了 "luffy" (如流那边的使用场景) 预留的一种写法, 目前已经不应该这么写了... * 太过费劲... * 包括 => create input 的部分 * * @param customIconDefaultOption */ export function createInnerDatePicker( customIconDefaultOption: CalendarIcon['opts'] = {} ) { return class InnerDatePicker extends ConfiguredComponent implements Type_InnerDatePicker { // === 对外 static API === static dayjs = dayjs // === locale === componentLocale = localeDictDatePicker // === 内部 static API === static displayName = 'DatePicker' static defaultProps: DatePickerProps = { mode: 'date', className: '', disabled: false, status: 'normal', showTime: false, hideNow: false, showSecond: false, yearStart: 12, yearEnd: 12, clearable: true, } // === 内部 props === state: DatePickerState = { open: typeof this.props.open === 'undefined' ? false : this.props.open, isZooming: false, inputValue: '', // @todo:check value: null, // @note: showTime = false 的场景下, 我们不记录或维护 timeState, 默认为 00:00:00 timeState: { hour: undefined, minute: undefined, second: undefined, }, displayedDate: null, zoomedDisplayedDate: null, isUserInput: false, userInputText: '', isHoverOverCalendar: false, } userInput = createDatePickerUserInput(this, customIconDefaultOption) get inputElem() { return this.userInput.inputElem } get size(): DatePickerProps['size'] { return this.getDefaultValueUsingTheme('size', 'baseSize') } getWeekDayText = (day: number) => { return this.getLocaleText('get_week_day_text', day) } datePanel = createDatePanel(this, { valueProp: 'value', displayProp: 'displayedDate', onMouseDown: preventPickerBlurred, getTitle: (date: Dayjs) => this.getLocaleText('get_year_month_title', date as any), getWeekDayText: this.getWeekDayText, renderPanelSideContent: () => { return !this.props.showTime ? null : this.renderTimeArea() }, }) getPanelTitle_year = (date: Dayjs) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument return this.getLocaleText('get_year_title', date as any) } getMonthText = (month: number) => this.getLocaleText('get_month_text', month) zoomedDatePanel: TypeModuleMonthPanel = createMonthPanel(this, { isForDateZooming: true, valueProp: 'displayedDate', displayProp: 'zoomedDisplayedDate', onMouseDown: preventPickerBlurred, getTitle: this.getPanelTitle_year, getMonthText: this.getMonthText, }) monthPanel = createMonthPanel(this, { isForDateZooming: false, valueProp: 'value', displayProp: 'displayedDate', onMouseDown: preventPickerBlurred, getTitle: this.getPanelTitle_year, getMonthText: this.getMonthText, }) quarterPanel = createQuarterPanel(this, { valueProp: 'value', displayProp: 'displayedDate', onMouseDown: preventPickerBlurred, getTitle: this.getPanelTitle_year, }) inputWrapperElem: HTMLDivElement | null = null panelWrapperElem: Popper | null = null actionQueue = createDelayActionQueue() listenerGroup = createListenerGroup() previousOpen: boolean | null = null quickActions = new QuickActions() // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor(props: any) { super(props) // 确保 dayjs 插件扩展成功 extendDayjsPlugins() const initValue = typeof this.props.defaultValue === 'undefined' ? this.props.value : this.props.defaultValue this.state.value = initValue ? dayjs(initValue) : null // 如果有 state.value 值, 需要对齐该显示月份 this.state.displayedDate = dayjs(this.state.value || undefined) const onItemSelected = (selectedDate: Dayjs) => { const showTime = this.props.showTime if (showTime) { const timeState = this.state.timeState selectedDate = selectedDate.set('hour', timeState.hour || 0) .set('minute', timeState.minute || 0) .set('second', timeState.second || 0) } this.changeValue(selectedDate, { onStateChanged: () => { this.setState({ isUserInput: Boolean(showTime), userInputText: this.userInput.formatInputValue(selectedDate), }) this.changeDatePickerOpenStatus(false, selectedDate) }, }) } this.datePanel.init({ onZoomOut: () => { this.setState({ isZooming: true, zoomedDisplayedDate: this.state.displayedDate, }) this.zoomedDatePanel.setYearRangeAnchor(this.state.displayedDate!.year()) this.zoomedDatePanel.syncAsideScroll() }, onSelected: onItemSelected, }) this.zoomedDatePanel.init({ onZoomIn: () => { this.setState({ isZooming: false, }) }, onSelected: () => { this.setState({ isZooming: false, }) }, }) this.monthPanel.init({ onSelected: onItemSelected, }) this.quarterPanel.init({ onSelected: onItemSelected, }) } static getDerivedStateFromProps(nextProps: DatePickerProps, prevState: DatePickerState) { const partialState: Partial | null = pickDerivedStateFromProps(nextProps, ['open', 'value']) as (Partial | null) if (partialState && partialState.value) { const value = dayjs(partialState.value) partialState.value = value const hour = value.hour() const minute = value.minute() const second = value.second() if ( prevState.timeState.hour !== hour || prevState.timeState.minute !== minute || prevState.timeState.second !== second ) { partialState.timeState = { hour, minute, second, } } } return partialState || null } refInputWrapper = (node: HTMLDivElement | null): void => { this.inputWrapperElem = node } refPanelWrapper = (node: Popper | null): void => { this.panelWrapperElem = node } // === 公共对内控制 API === syncDisplayDate = () => { const mode = getPickerMode(this.props.mode) const value = this.state.value this.setState({ displayedDate: value ? value.startOf(mode) : dayjs(), isZooming: false, }) } /** * 用于 `非控型组件` 进行取值设置 * * @param value */ setValue = (value: TypeDateValue) => { if (!value) { this.setState({ value: null, }) } else if (value) { const mode = getPickerMode(this.props.mode) value = dayjs(value).startOf(mode) this.setState({ value, displayedDate: value, isZooming: false, }) } } /** * 用于提供给 `非控型组件` 的值获取 */ getValue = () => { return this.state.value } focus() { this.inputElem?.focus() } componentDidUpdate(): void { this.actionQueue.execute() } componentDidMount(): void { this.listenerGroup.add(document, { 'mousedown': (e: MouseEvent) => { if ( this.state.open && this.panelWrapperElem && this.panelWrapperElem.elemPopperWrap && !this.panelWrapperElem.elemPopperWrap.contains(e.target as Node) && this.inputWrapperElem && !this.inputWrapperElem.contains(e.target as Node) ) { this.setOpen(false) this.processOpenStatus({open: false}) } }, }) this.actionQueue.execute() /** * @important: 这里需要确保 props.open = true 时, 在初次显示之后, * 能把面板显示出来 * * @todo: 如果 popper 有统一解决方案, 需要根据新的方案进行调整!! @郑亮亮, @吴俊 * */ if (this.props.open === true) { this.forceUpdate() } } componentWillUnmount(): void { this.listenerGroup.removeAll() } // === getter === get className() { const {className} = this.props const size = this.size; return c( 'brick-date-picker', { [`brick-date-picker-size-${size || ''}`]: size, }, className ) } get popupClassName() { const {popupClassName, showTime} = this.props; const size = getClosestMappedSize(this.size); return c( 'brick-date-picker-popper', { [`brick-date-picker-popper-size-${size || ''}`]: size, 'brick-date-picker-popper-show-time': showTime, }, popupClassName ) } get computedItemFormat() { if (this.props.itemFormat) { return this.props.itemFormat } const {mode, showTime, showSecond} = this.props; const modeForTitle = getModeForTitle(getPickerMode(mode), showTime, showSecond) return DEFAULT_ITEM_FORMAT[modeForTitle]; } get computedItemValueExtractor() { return getValueExtractor(this) } // === inner methods === changeValue = ( selectedDate: Dayjs | null, opts: { timeState?: DatePickerState['timeState'] onStateChanged?: () => void } = {} ) => { const { onChange, showSecond, } = this.props const { onStateChanged, } = opts const timeState = opts.timeState || { hour: selectedDate ? selectedDate.get('hour') : undefined, minute: selectedDate ? selectedDate.get('minute') : undefined, second: selectedDate ? selectedDate.get('second') : undefined, } if (!showSecond && selectedDate) { selectedDate = selectedDate.set('second', 0); } if (onChange) { onChange(selectedDate) } /** * @note: 对应的 setState 应该是不同的 ( props.value 如无, 则应为 uncontrolled 心态) * * - undefined => uncontrolled * - null => 对应 "未选中 date 的时刻, time 理应也能被选择" */ if (!this.props.value) { this.setState({ value: selectedDate, timeState, }, onStateChanged) } else { onStateChanged?.() } } /** * - 确保打开时, scroll 到特定的年份 * - displayDate 被重置 */ processOpenStatus = (opts: { open?: boolean isUserInput?: boolean }) => { const open = getDefault(opts.open, this.state.open) const userInput = getDefault(opts.isUserInput, this.state.isUserInput) const {getDefaultDisplayDate} = this.props const computedOpen = open || userInput || null if (computedOpen !== this.previousOpen && computedOpen) { const value = this.state.value let displayedDate: Dayjs if (value || !getDefaultDisplayDate) { displayedDate = value ? value.clone() : dayjs() } else { displayedDate = getDefaultDisplayDate(dayjs()) } this.setState({ displayedDate, }) const panelProp = mode2PanelProp[this.props.mode!] as 'monthPanel' | 'quarterPanel' | undefined if (panelProp) { this[panelProp].setYearRangeAnchor(displayedDate.year()) this[panelProp].syncAsideScroll() } } this.previousOpen = computedOpen } /** * 如果涉及 value 更改, 务必将 value 传递过来 * @param open * @param value */ setOpen = (open: boolean, value?: Dayjs | null) => { // open 为非控型属性 if (typeof this.props.open === 'undefined') { this.setState({open}) return } if (!this.props.onOpenChange) { if (isDev()) { throw new Error('当 `open` 为控制型属性时, 你应该接管这个属性的主动处理') } return } /** * @note: 为什么需要把 value 也回传 ? * * 因为在某些业务场景下, 我们可能会存在 : * * # 需求 * * - 当 value 被清空时, 把整个 date picker 都卸载掉 (即最终 unmount) * * # 问题 * * - 这时会存在外部需要用 value 来控制 "shouldShowDatePicker" 这个状态 * - 注意, 和 shouldOpenDatePickerPanel (即 `open` 属性) 有区别, 后者是控制 "是否打开面板" * - 而存在一种情况, 即清除掉 date picker input 内容之后, date picker 仍然获得焦点, 内部最终的 `open` 属性为 true, * - 所以, 这种场景下, 需要在 `onOpenChange` 事件下, 把 value & open 传递给上层调用方 * - 由它来决定是否某些 `open=true` 的情况下, 也等同于 `open=false` 并借由控制 `shouldShowDatePicker` * - 最终, 卸载掉整个组件. */ this.props.onOpenChange( open, typeof value === 'undefined' ? this.state.value : value ) } // === handlers === handleCalendarIconClick = () => { if (!this.props.disabled) { this.changeDatePickerOpenStatus(true) } } // ==== 选择器面板的显隐控制 ==== changeDatePickerOpenStatus = (visible: boolean, value?: Dayjs | null) => { this.setState({ isZooming: false, }) this.setOpen(visible, value) this.processOpenStatus({open: visible}) } refUITimePanel: UiTimePanel | null = null tryScrollToSelectedTime = () => { this.refUITimePanel?.scrollToSelected() } changeTime = ( propName: typeof COL_NAMES[number], propValue: number ) => { let value = this.state.value value = value && value.set(propName, propValue) const userInputText = this.userInput.formatInputValue(value) const timeState = this.state.timeState timeState[propName] = propValue this.changeValue(value, { timeState, onStateChanged: () => { this.setState({ userInputText: userInputText, }) }, }) } setCurrentDateTime = () => { const currentDateTime = dayjs().startOf('second') this.changeValue(currentDateTime, { onStateChanged: () => { this.syncDisplayDate() this.tryScrollToSelectedTime() this.setState({ userInputText: this.userInput.formatInputValue(currentDateTime), }) }, }) } confirmDateTime = () => { this.setOpen( false ) this.setState({ isUserInput: false, }) this.processOpenStatus({ open: false, isUserInput: false, }) } // === renderers === renderTimeArea = () => { const { hour, minute, second, } = this.state.timeState const {showSecond} = this.props; return ( this.refUITimePanel = panel} hour={hour} minute={minute} second={second} showSecond={showSecond} onChange={this.changeTime} /> ) } renderPanel = () => { const {mode, hideNow} = this.props const quickActionsElem = this.quickActions.renderQuickActions({ getValue: () => this.state.value, getActionQueue: () => this.actionQueue, syncDisplayDate: () => this.syncDisplayDate(), onMouseDown: preventPickerBlurred, renderQuickActions: this.props.renderQuickActions, }) let pickerElem = null if (mode === 'date') { if (!this.state.isZooming) { pickerElem = this.datePanel.renderPanel() } else { pickerElem = this.zoomedDatePanel.renderPanel() } } else if (mode === 'month') { pickerElem = this.monthPanel.renderPanel() } else if (mode === 'quarter') { pickerElem = this.quarterPanel.renderPanel() } let pickerMainContent if (quickActionsElem) { pickerMainContent = (
{quickActionsElem} {pickerElem}
) } else { pickerMainContent = pickerElem } if (!this.props.showTime) { return pickerMainContent } const linkNow = hideNow ? null : ( 此刻 ) return (
{pickerMainContent}
{linkNow}
) } render() { const shouldShowPanel = this.state.isUserInput || this.state.open return (
{this.userInput.renderInput()} {this.renderPanel()}
) } } } export type TypeInnerDatePicker = InstanceType>