import {Component, FC, ReactNode, FocusEvent} from 'react' import c from 'classnames' import {Popper} from '@befe/brick-comp-popper' import {createListenerGroup, getClosestMappedSize, Ref, StateOperation} from '@befe/brick-utils' import {Input} from '@befe/brick-comp-input' import {pick} from 'lodash-es' import {getDefaultValueUsingContextTheme} from '@befe/brick-core'; import {UiTimePanel} from '../ui-comps/ui-time-panel' import {SafeChainInvoker} from '../utils/safe-chain-invoker' import {assign} from '../utils/assign' import {isAllUndefined} from '../utils/has-undefined' import {getColNames} from '../defs/types' import {TimeUserInput} from './comp-time-user-input' export interface PartialInputProps { disabled?: boolean status?: 'normal' | 'error' /** * default 值 : 请选择时间 */ placeholder?: string } export interface TimePickerProps extends PartialTimePickerIconProps, PartialInputProps { /** * 自定义 class */ className?: string /** * 当前时间,使用 TimePicker.TimeValue 创建 */ value?: TimeValue /** * 默认时间,使用 TimePicker.TimeValue 创建 */ defaultValue?: TimeValue /** * 时间选择器面板的额外 className */ popupClassName?: string /** * 尺寸 */ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' /** * 是否显示秒 */ showSecond?: boolean /** * 值变化时的回调 */ onChange?: (value: TimeValue) => void clearable?: boolean onFocus?: (e: FocusEvent) => void onBlur?: (e: FocusEvent) => void } export interface TimePickerState { hour?: number minute?: number second?: number open: boolean isUserInput: boolean } export interface PartialTimePickerIconProps { iconSvg?: FC iconNode?: ReactNode } function padding(num: number) { return (num < 10 ? '0' : '') + String(num) } function getTimeText( showSecond: boolean, hour?: number, minute?: number, second?: number ) { const isSecondUndefined = typeof second === 'undefined' const isMinuteUndefined = typeof minute === 'undefined' const isHourUndefined = typeof hour === 'undefined' if ( isHourUndefined && isMinuteUndefined && isSecondUndefined ) { return '' } const h = padding(hour || 0) const m = padding(minute || 0) if (!showSecond) { return `${h}:${m}` } const s = padding(second || 0) return `${h}:${m}:${s}` } const RGX_INTEGER = /^\d{0,2}$/; export function validateTimeText(text: string) { text = text || ''; const [hourText, minuteText, secondText] = text.split(':').map(num => num?.trim()); const hour = Number(hourText); if (isNaN(hour) || !RGX_INTEGER.test(hourText) || hour < 0 || hour > 23) { return false; } const minute = Number(minuteText); if (isNaN(minute) || !RGX_INTEGER.test(minuteText) || minute < 0 || minute > 59) { return false; } if (typeof secondText !== 'undefined') { const second = Number(secondText); if (isNaN(second) || !RGX_INTEGER.test(secondText) || second < 0 || second > 59) { return false; } } return true; } function extractFromTimeText(text: string) { if (!validateTimeText(text)) { return {} } const [hour, minute, second] = text.split(':') .map(i => ~~i.trim()) return { hour, minute, second, } } export class TimeValue { showSecond: boolean hour?: number minute?: number second?: number constructor( hour?: number, minute?: number, second?: number, showSecond?: boolean ) { /** * second 不为空时, 默认也是要 "show second" */ this.showSecond = showSecond || typeof second !== 'undefined' this.hour = hour this.minute = minute this.second = second } static isValid(text: string) { return validateTimeText(text) } static fromText(text: string, showSecond = false) { const {hour, minute, second} = extractFromTimeText(text) return new TimeValue(hour, minute, second, showSecond) } toText() { return getTimeText(this.showSecond, this.hour, this.minute, this.second) } toValue() { const isHourUndefined = typeof this.hour === 'undefined' const isMinuteUndefined = typeof this.minute === 'undefined' const isSecondUndefined = typeof this.second === 'undefined' if (isHourUndefined && isMinuteUndefined && (isSecondUndefined || !this.showSecond)) { return {hour: undefined, minute: undefined, second: undefined} } return { hour: this.hour || 0, minute: this.minute || 0, second: this.showSecond ? (this.second || 0) : undefined, } } } /** * 暂时保留, 后续可用 */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function debug(...args: any) { console.warn('@debug', ...args); } export function createTimePicker( { iconSvg: defaultIconSvg, iconNode: defaultIconNode, }: PartialTimePickerIconProps ) { return class TimePicker extends Component { static displayName = 'TimePicker' // static propTypes = {} static defaultProps: TimePickerProps = { className: '', showSecond: false, clearable: true, disabled: false, status: 'normal', placeholder: '请选择时间', } static TimeValue = TimeValue static getTimeText = getTimeText static validateTimeText = validateTimeText static extractFromTimeText = extractFromTimeText static getInitialSelect = (enabled: boolean) => { return { enabled, hour: !enabled, minute: !enabled, second: !enabled, } } state: TimePickerState = { isUserInput: false, open: false, } isClickedOnPanel = false; initialSelect = TimePicker.getInitialSelect(false); listenerGroup = createListenerGroup() stateUserInput = new StateOperation( this, 'isUserInput' ) stateOpen = new StateOperation( this, 'open' ) refInput = new Ref() refInputWrapper = new Ref() refPanel = new Ref() refPanelWrapper = new Ref() checkClickOnInput = new SafeChainInvoker( 'refInputWrapper.elem.contains', this ) checkClickOnPanel = new SafeChainInvoker( 'refPanelWrapper.elem.contains', this ) constructor(props: never) { super(props) const {value, defaultValue} = this.props if (!value && defaultValue) { const { hour = 0, minute = 0, second = 0, } = defaultValue; assign(this.state, {hour, minute, second}); } } isInitialSelected = () => { const {enabled, hour, minute, second} = this.initialSelect const showSecond = this.props.showSecond return enabled && ( hour && minute && (!showSecond || second) ) } get size(): TimePickerProps['size'] { return getDefaultValueUsingContextTheme(this, 'size', 'baseSize') } static getDerivedStateFromProps( nextProps: TimePickerProps ) { const partialState: Partial = {} const {value} = nextProps if (typeof value !== 'undefined') { assign(partialState, value) } return partialState } get className() { const {className} = this.props; const size = this.size; return c( 'brick-time-picker', { [`brick-time-picker-size-${size || ''}`]: size, }, className ) } get popupClassName() { const { popupClassName, } = this.props; const size = getClosestMappedSize(this.size); return c( /** * @note: popper vs. popup 的话术问题, 值得思考 */ 'brick-time-picker-popper', { [`brick-time-picker-popper-size-${size || ''}`]: size, }, popupClassName ); } get shouldShowPanel() { return this.state.open || this.state.isUserInput } checkCouldClosePanel = (propName: 'hour' | 'minute' | 'second') => { if (this.initialSelect.enabled) { this.initialSelect[propName] = true; } this.isClickedOnPanel = true; // debug('this.isClickedOnPanel = true;') void Promise.resolve().then(() => { // debug('this.isClickedOnPanel = false;') this.isClickedOnPanel = false; }) // debug('this.isInitialSelected()', this.isInitialSelected()); if (this.isInitialSelected()) { this.initialSelect.enabled = false; this.stateOpen.set(false) return; } } triggerChange( state: { hour?: number minute?: number second?: number } ) { const {onChange, showSecond = false} = this.props let { hour, minute, second, } = assign( pick(this.state, 'hour', 'minute', 'second'), state ) const shouldFormalizeTimeProp = !isAllUndefined( {hour, minute, second}, getColNames(showSecond) ) if (shouldFormalizeTimeProp) { hour = hour || 0; minute = minute || 0; second = showSecond ? (second || 0) : undefined; } this.setState({hour, minute, second}); onChange && onChange(new TimeValue( hour, minute, second, showSecond )) } handleTextChange = (text: string) => { if (!text.trim()) { const state = { hour: undefined, minute: undefined, second: undefined, } this.triggerChange(state) return } if (!validateTimeText(text)) { return } const state = extractFromTimeText(text) this.triggerChange(state) const panelElem = this.refPanel.elem panelElem?.scrollToSelected() } handleFocusOnInput = (e: FocusEvent) => { const onFocus = this.props.onFocus onFocus && onFocus(e) this.stateOpen.set(true) } handleBlurFromInput = (e: FocusEvent) => { const onBlur = this.props.onBlur onBlur && onBlur(e) } handlePanelChange: UiTimePanel['props']['onChange'] = ( propName: string, value ) => { this.checkCouldClosePanel(propName as 'hour' | 'minute' | 'second') this.triggerChange({[propName]: value}) } getSnapshotBeforeUpdate = (): any | null => { if (!this.isClickedOnPanel) { // debug('if (!this.isClickedOnPanel) {', this.isClickedOnPanel) /** * @note: 此处直接使用 state 的 value, * 因为在 getDerivedStateFromProps 处, * 已经对 state 进行了规范化, * * 所以此处 state.value, 必然是一个 TimeValue 对象 */ const {hour, minute, second} = this.state; const {showSecond} = this.props; if (isAllUndefined({hour, minute, second}, getColNames(showSecond))) { this.initialSelect = TimePicker.getInitialSelect(true); } else { this.initialSelect = TimePicker.getInitialSelect(false); } } return null } componentDidMount(): void { this.listenerGroup.add(document, { mousedown: (e: MouseEvent) => { const target = e.target as Node if ( this.state.open && !this.checkClickOnInput.call(target) && !this.checkClickOnPanel.call(target) ) { this.stateOpen.set(false) } }, }) } componentWillUnmount(): void { this.listenerGroup.removeAll() } componentDidUpdate(): void { // @note: do nothing as React complains about getSnapshotBeforeUpdate pairing problem } render() { const {hour, minute, second} = this.state const props = this.props const { showSecond, iconSvg, iconNode, } = props const computedIconSvg = iconSvg || (iconNode ? undefined : defaultIconSvg) const computedIconNode = iconNode || (iconSvg ? undefined : defaultIconNode) const { enabled, hour: isHourSelected, minute: isMinuteSelected, second: isSecondSelected, } = this.initialSelect; return (
) } } }