feat: 日期范围支持手动输入 (#3835)

* feat: 日期范围支持手动输入

* 更新单元测试

* 修复 minDuration 和 maxDuration 错误

* 避免通过 input 绕过时间限制

* 优化时间范围编辑体验
This commit is contained in:
吴多益 2022-03-24 11:43:26 +08:00 committed by GitHub
parent c721d22ab6
commit 11cd80a33f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 329 additions and 140 deletions

View File

@ -56,11 +56,25 @@ exports[`Renderer:dateRange 1`] = `
class="cxd-DateRangePicker" class="cxd-DateRangePicker"
tabindex="0" tabindex="0"
> >
<input
autocomplete="off"
class="cxd-DateRangePicker-input"
placeholder="选择开始时间"
type="text"
value="2019-06-06"
/>
<span <span
class="cxd-DateRangePicker-value" class="cxd-DateRangePicker-input-separator"
> >
2019-06-06 至 2019-06-26 ~
</span> </span>
<input
autocomplete="off"
class="cxd-DateRangePicker-input"
placeholder="选择结束时间"
type="text"
value="2019-06-26"
/>
<a <a
class="cxd-DateRangePicker-clear" class="cxd-DateRangePicker-clear"
> >

View File

@ -31,12 +31,12 @@ test('Renderer:dateRange', async () => {
) )
); );
const input = container.querySelector('.cxd-DateRangePicker-value'); const input = container.querySelectorAll('.cxd-DateRangePicker-input');
expect(input?.innerHTML).toEqual( expect(input[0].value).toEqual(
`${moment(1559750400, 'X').format('YYYY-MM-DD')}${moment( `${moment(1559750400, 'X').format('YYYY-MM-DD')}`
1561564799, );
'X' expect(input[1].value).toEqual(
).format('YYYY-MM-DD')}` `${moment(1561564799, 'X').format('YYYY-MM-DD')}`
); );
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();

View File

@ -8,7 +8,7 @@ import {makeEnv} from '../../helper';
import moment from 'moment'; import moment from 'moment';
test('Renderer:inputYearRange click', async () => { test('Renderer:inputYearRange click', async () => {
const {container, findByText, getByText} = render( const {container, findByPlaceholderText, getByText} = render(
amisRender( amisRender(
{ {
type: 'form', type: 'form',
@ -28,7 +28,7 @@ test('Renderer:inputYearRange click', async () => {
) )
); );
const inputDate = await findByText('请选择年份范围'); const inputDate = await findByPlaceholderText('选择开始时间');
fireEvent.click(inputDate); fireEvent.click(inputDate);
@ -51,9 +51,8 @@ test('Renderer:inputYearRange click', async () => {
fireEvent.click(confirm); fireEvent.click(confirm);
const value = document.querySelector( const value = document.querySelectorAll('.cxd-DateRangePicker-input')!;
'.cxd-DateRangePicker-value'
) as HTMLSpanElement;
expect(value.innerHTML).toEqual(thisYearText + ' 至 ' + nextYearText); expect((value[0] as HTMLInputElement).value).toEqual(thisYearText);
expect((value[1] as HTMLInputElement).value).toEqual(nextYearText);
}); });

View File

@ -31,6 +31,22 @@
box-shadow: var(--Form-input-boxShadow); box-shadow: var(--Form-input-boxShadow);
} }
.#{$ns}DateRangePicker-input {
border: none;
border-bottom: 1px solid transparent;
outline: none;
padding: 0;
background: 0;
}
.#{$ns}DateRangePicker-input.isActive {
border-bottom: 1px solid var(--DatePicker-onFocused-borderColor);
}
.#{$ns}DateRangePicker-input-separator {
margin: 0 var(--gap-sm);
}
&.is-disabled { &.is-disabled {
background: $gray200; background: $gray200;

View File

@ -7,7 +7,6 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import {findDOMNode} from 'react-dom'; import {findDOMNode} from 'react-dom';
import cx from 'classnames';
import {Icon} from './icons'; import {Icon} from './icons';
import Overlay from './Overlay'; import Overlay from './Overlay';
import {ShortCuts, ShortCutDateRange} from './DatePicker'; import {ShortCuts, ShortCutDateRange} from './DatePicker';
@ -19,11 +18,13 @@ import {PlainObject} from '../types';
import {isMobile, noop, ucFirst} from '../utils/helper'; import {isMobile, noop, ucFirst} from '../utils/helper';
import {LocaleProps, localeable} from '../locale'; import {LocaleProps, localeable} from '../locale';
import CalendarMobile from './CalendarMobile'; import CalendarMobile from './CalendarMobile';
import Input from './Input';
export interface DateRangePickerProps extends ThemeProps, LocaleProps { export interface DateRangePickerProps extends ThemeProps, LocaleProps {
className?: string; className?: string;
popoverClassName?: string; popoverClassName?: string;
placeholder?: string; startPlaceholder?: string;
endPlaceholder?: string;
theme?: any; theme?: any;
format: string; format: string;
utc?: boolean; utc?: boolean;
@ -52,6 +53,7 @@ export interface DateRangePickerProps extends ThemeProps, LocaleProps {
useMobileUI?: boolean; useMobileUI?: boolean;
onFocus?: Function; onFocus?: Function;
onBlur?: Function; onBlur?: Function;
type?: string;
} }
export interface DateRangePickerState { export interface DateRangePickerState {
@ -59,6 +61,9 @@ export interface DateRangePickerState {
isFocused: boolean; isFocused: boolean;
startDate?: moment.Moment; startDate?: moment.Moment;
endDate?: moment.Moment; endDate?: moment.Moment;
editState?: 'start' | 'end'; // 编辑开始时间还是结束时间
startInputValue?: string;
endInputValue?: string;
} }
export const availableRanges: {[propName: string]: any} = { export const availableRanges: {[propName: string]: any} = {
@ -390,7 +395,8 @@ export class DateRangePicker extends React.Component<
DateRangePickerState DateRangePickerState
> { > {
static defaultProps = { static defaultProps = {
placeholder: 'DateRange.placeholder', startPlaceholder: 'Calendar.startPick',
endPlaceholder: 'Calendar.endPick',
format: 'X', format: 'X',
inputFormat: 'YYYY-MM-DD', inputFormat: 'YYYY-MM-DD',
joinValues: true, joinValues: true,
@ -456,12 +462,21 @@ export class DateRangePicker extends React.Component<
dom: React.RefObject<HTMLDivElement>; dom: React.RefObject<HTMLDivElement>;
nextMonth = moment().add(1, 'months'); nextMonth = moment().add(1, 'months');
startInputRef: React.RefObject<HTMLInputElement>;
endInputRef: React.RefObject<HTMLInputElement>;
constructor(props: DateRangePickerProps) { constructor(props: DateRangePickerProps) {
super(props); super(props);
this.startInputRef = React.createRef();
this.endInputRef = React.createRef();
this.open = this.open.bind(this); this.open = this.open.bind(this);
this.openStart = this.openStart.bind(this);
this.openEnd = this.openEnd.bind(this);
this.close = this.close.bind(this); this.close = this.close.bind(this);
this.handleSelectChange = this.handleSelectChange.bind(this); this.startInputChange = this.startInputChange.bind(this);
this.endInputChange = this.endInputChange.bind(this);
this.handleDateChange = this.handleDateChange.bind(this);
this.handleTimeStartChange = this.handleTimeStartChange.bind(this); this.handleTimeStartChange = this.handleTimeStartChange.bind(this);
this.handleTimeEndChange = this.handleTimeEndChange.bind(this); this.handleTimeEndChange = this.handleTimeEndChange.bind(this);
this.handleFocus = this.handleFocus.bind(this); this.handleFocus = this.handleFocus.bind(this);
@ -477,22 +492,61 @@ export class DateRangePicker extends React.Component<
this.renderDay = this.renderDay.bind(this); this.renderDay = this.renderDay.bind(this);
this.renderQuarter = this.renderQuarter.bind(this); this.renderQuarter = this.renderQuarter.bind(this);
this.handleMobileChange = this.handleMobileChange.bind(this); this.handleMobileChange = this.handleMobileChange.bind(this);
const {format, joinValues, delimiter, value} = this.props; this.handleOutClick = this.handleOutClick.bind(this);
const {format, joinValues, delimiter, value, inputFormat} = this.props;
const {startDate, endDate} = DateRangePicker.unFormatValue(
value,
format,
joinValues,
delimiter
);
this.state = { this.state = {
isOpened: false, isOpened: false,
isFocused: false, isFocused: false,
...DateRangePicker.unFormatValue(value, format, joinValues, delimiter) startDate,
endDate,
startInputValue: startDate?.format(inputFormat),
endInputValue: endDate?.format(inputFormat)
}; };
} }
componentDidMount() {
document.body.addEventListener('click', this.handleOutClick, true);
}
componentWillUnmount() {
document.body.removeEventListener('click', this.handleOutClick, true);
}
handleOutClick(e: Event) {
if (
!e.target ||
!this.dom.current ||
this.dom.current.contains(e.target as HTMLElement)
) {
return;
}
if (this.state.isOpened) {
e.preventDefault();
this.close();
}
}
componentDidUpdate(prevProps: DateRangePickerProps) { componentDidUpdate(prevProps: DateRangePickerProps) {
const props = this.props; const props = this.props;
const {value, format, joinValues, delimiter} = props; const {value, format, joinValues, inputFormat, delimiter} = props;
if (prevProps.value !== value) { if (prevProps.value !== value) {
const {startDate, endDate} = DateRangePicker.unFormatValue(
value,
format,
joinValues,
delimiter
);
this.setState({ this.setState({
...DateRangePicker.unFormatValue(value, format, joinValues, delimiter) startDate,
endDate,
startInputValue: startDate?.format(inputFormat),
endInputValue: endDate?.format(inputFormat)
}); });
} }
} }
@ -539,10 +593,31 @@ export class DateRangePicker extends React.Component<
}); });
} }
openStart() {
if (this.props.disabled) {
return;
}
this.setState({
isOpened: true,
editState: 'start'
});
}
openEnd() {
if (this.props.disabled) {
return;
}
this.setState({
isOpened: true,
editState: 'end'
});
}
close() { close() {
this.setState( this.setState(
{ {
isOpened: false isOpened: false,
editState: undefined
}, },
this.blur this.blur
); );
@ -612,77 +687,163 @@ export class DateRangePicker extends React.Component<
return value; return value;
} }
handleSelectChange(newValue: moment.Moment) { handleDateChange(newValue: moment.Moment) {
const {embed, timeFormat, minDuration, maxDuration, minDate} = this.props; const {
let {startDate, endDate} = this.state; embed,
timeFormat,
// 第一次点击只标记起始时间,或者点击了开始时间前面的时间 minDuration,
if (this.isFirstClick || newValue.isBefore(startDate)) { maxDuration,
// 这种情况说明第二次点击点击了前面的时间,这时要标记为第二次点击 minDate,
if (newValue.isBefore(startDate)) { inputFormat,
this.isFirstClick = true; type
} } = this.props;
let {startDate, endDate, editState} = this.state;
if (editState === 'start') {
if (minDate && newValue.isBefore(minDate)) { if (minDate && newValue.isBefore(minDate)) {
newValue = minDate; newValue = minDate;
} }
this.setState({ const date = this.filterDate(
startDate: this.filterDate( newValue,
newValue, startDate || minDate,
startDate || minDate, timeFormat,
timeFormat, 'start'
'start' );
), const newState = {
endDate: undefined startDate: date,
}); startInputValue: date.format(inputFormat)
} else { } as any;
// 第二次点击作为结束时间 // 这些没有时间的选择点第一次后第二次就是选结束时间
if (!startDate) { if (
// 不大可能,但只能作为开始时间了 type === 'input-date-range' ||
startDate = newValue; type === 'input-year-range' ||
type === 'input-quarter-range'
) {
newState.editState = 'end';
}
this.setState(newState);
} else if (editState === 'end') {
newValue = this.getEndDateByDuration(newValue);
// 如果结束时间在前面,需要清空开始时间
if (newValue.isBefore(startDate)) {
this.setState({
startDate: undefined,
startInputValue: ''
});
} }
if (minDuration && newValue.isAfter(startDate.clone().add(minDuration))) { const date = this.filterDate(newValue, endDate, timeFormat, 'end');
newValue = startDate.clone().add(minDuration);
}
if (
maxDuration &&
newValue.isBefore(startDate.clone().add(maxDuration))
) {
newValue = startDate.clone().add(maxDuration);
}
this.setState( this.setState(
{ {
endDate: this.filterDate(newValue, endDate, timeFormat, 'end') endDate: date,
endInputValue: date.format(inputFormat)
}, },
() => { () => {
embed && this.confirm(); embed && this.confirm();
} }
); );
} }
}
this.isFirstClick = !this.isFirstClick; // 手动控制输入时间
startInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const {onChange, inputFormat, format, utc} = this.props;
const value = e.currentTarget.value;
this.setState({startInputValue: value});
if (value === '') {
onChange('');
} else {
let newDate = this.getStartDateByDuration(moment(value, inputFormat));
this.setState({startDate: newDate});
}
}
endInputChange(e: React.ChangeEvent<HTMLInputElement>) {
const {onChange, inputFormat, format, utc} = this.props;
const value = e.currentTarget.value;
this.setState({endInputValue: value});
if (value === '') {
onChange('');
} else {
let newDate = this.getEndDateByDuration(moment(value, inputFormat));
this.setState({endDate: newDate});
}
}
// 根据 duration 修复结束时间
getEndDateByDuration(newValue: moment.Moment) {
const {minDuration, maxDuration, type} = this.props;
let {startDate, endDate, editState} = this.state;
if (!startDate) {
return newValue;
}
// 时间范围必须统一成同一天,不然会不一致
if (type === 'input-time-range' && startDate) {
newValue.set({
year: startDate.year(),
month: startDate.month(),
date: startDate.date()
});
}
if (minDuration && newValue.isBefore(startDate.clone().add(minDuration))) {
newValue = startDate.clone().add(minDuration);
}
if (maxDuration && newValue.isAfter(startDate.clone().add(maxDuration))) {
newValue = startDate.clone().add(maxDuration);
}
return newValue;
}
// 根据 duration 修复起始时间
getStartDateByDuration(newValue: moment.Moment) {
const {minDuration, maxDuration, type} = this.props;
let {endDate, editState} = this.state;
if (!endDate) {
return newValue;
}
// 时间范围必须统一成同一天,不然会不一致
if (type === 'input-time-range' && endDate) {
newValue.set({
year: endDate.year(),
month: endDate.month(),
date: endDate.date()
});
}
if (
minDuration &&
newValue.isBefore(endDate.clone().subtract(minDuration))
) {
newValue = endDate.clone().subtract(minDuration);
}
if (
maxDuration &&
newValue.isAfter(endDate.clone().subtract(maxDuration))
) {
newValue = endDate.clone().subtract(maxDuration);
}
return newValue;
} }
// 主要用于处理时间的情况 // 主要用于处理时间的情况
handleTimeStartChange(newValue: moment.Moment) { handleTimeStartChange(newValue: moment.Moment) {
const {embed, timeFormat, minDuration, maxDuration, minDate} = this.props; const {embed, timeFormat, inputFormat, minDuration, maxDuration, minDate} =
this.props;
const {startDate, endDate} = this.state; const {startDate, endDate} = this.state;
if ( // 时间范围必须统一成同一天,不然会不一致
startDate && if (endDate) {
(!endDate || (endDate && newValue.isSame(startDate))) && // 没有结束时间,或者新的时间也是开始时间,这时都会将新值当成结束时间 newValue.set({
newValue.isSameOrAfter(startDate) && year: endDate.year(),
(!minDuration || newValue.isAfter(startDate.clone().add(minDuration))) && month: endDate.month(),
(!maxDuration || newValue.isBefore(startDate.clone().add(maxDuration))) date: endDate.date()
) { });
return this.setState(
{
endDate: this.filterDate(newValue, endDate, timeFormat, 'end')
},
() => {
embed && this.confirm();
}
);
} }
if (minDate && newValue && newValue.isBefore(minDate, 'second')) { if (minDate && newValue && newValue.isBefore(minDate, 'second')) {
@ -691,12 +852,8 @@ export class DateRangePicker extends React.Component<
this.setState( this.setState(
{ {
startDate: this.filterDate( startDate: newValue,
newValue, startInputValue: newValue.format(inputFormat)
startDate || minDate,
timeFormat,
'start'
)
}, },
() => { () => {
embed && this.confirm(); embed && this.confirm();
@ -705,39 +862,40 @@ export class DateRangePicker extends React.Component<
} }
handleTimeEndChange(newValue: moment.Moment) { handleTimeEndChange(newValue: moment.Moment) {
const {embed, timeFormat, minDuration, maxDuration, maxDate} = this.props; const {embed, timeFormat, inputFormat, minDuration, maxDuration, maxDate} =
this.props;
const {startDate, endDate} = this.state; const {startDate, endDate} = this.state;
if (startDate) {
if ( newValue.set({
endDate && year: startDate.year(),
!startDate && month: startDate.month(),
newValue.isSameOrBefore(endDate) && date: startDate.date()
(!minDuration || });
newValue.isBefore(endDate.clone().subtract(minDuration))) &&
(!maxDuration || newValue.isAfter(endDate.clone().subtract(maxDuration)))
) {
return this.setState(
{
startDate: this.filterDate(newValue, startDate, timeFormat, 'start')
},
() => {
embed && this.confirm();
}
);
} }
if (maxDate && newValue && newValue.isAfter(maxDate, 'second')) { if (maxDate && newValue && newValue.isAfter(maxDate, 'second')) {
newValue = maxDate; newValue = maxDate;
} }
if (
startDate &&
minDuration &&
newValue.isAfter(startDate.clone().add(minDuration))
) {
newValue = startDate.clone().add(minDuration);
}
if (
startDate &&
maxDuration &&
newValue.isBefore(startDate.clone().add(maxDuration))
) {
newValue = startDate.clone().add(maxDuration);
}
this.setState( this.setState(
{ {
endDate: this.filterDate( endDate: newValue,
newValue, endInputValue: newValue.format(inputFormat)
endDate || maxDate,
timeFormat,
'end'
)
}, },
() => { () => {
embed && this.confirm(); embed && this.confirm();
@ -836,7 +994,7 @@ export class DateRangePicker extends React.Component<
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const {resetValue, onChange} = this.props; const {resetValue, onChange} = this.props;
this.setState({startInputValue: '', endInputValue: ''});
onChange(resetValue); onChange(resetValue);
} }
@ -950,6 +1108,7 @@ export class DateRangePicker extends React.Component<
ranges, ranges,
locale, locale,
embed, embed,
type,
viewMode = 'days' viewMode = 'days'
} = this.props; } = this.props;
const __ = this.props.translate; const __ = this.props.translate;
@ -959,7 +1118,6 @@ export class DateRangePicker extends React.Component<
return ( return (
<div className={`${ns}DateRangePicker-wrap`}> <div className={`${ns}DateRangePicker-wrap`}>
{this.renderRanges(ranges)} {this.renderRanges(ranges)}
<Calendar <Calendar
className={`${ns}DateRangePicker-start`} className={`${ns}DateRangePicker-start`}
value={startDate} value={startDate}
@ -968,7 +1126,7 @@ export class DateRangePicker extends React.Component<
onChange={ onChange={
viewMode === 'time' viewMode === 'time'
? this.handleTimeStartChange ? this.handleTimeStartChange
: this.handleSelectChange : this.handleDateChange
} }
requiredConfirm={false} requiredConfirm={false}
dateFormat={dateFormat} dateFormat={dateFormat}
@ -982,14 +1140,13 @@ export class DateRangePicker extends React.Component<
renderQuarter={this.renderQuarter} renderQuarter={this.renderQuarter}
locale={locale} locale={locale}
/> />
<Calendar <Calendar
className={`${ns}DateRangePicker-end`} className={`${ns}DateRangePicker-end`}
value={endDate} value={endDate}
onChange={ onChange={
viewMode === 'time' viewMode === 'time'
? this.handleTimeEndChange ? this.handleTimeEndChange
: this.handleSelectChange : this.handleDateChange
} }
requiredConfirm={false} requiredConfirm={false}
dateFormat={dateFormat} dateFormat={dateFormat}
@ -1013,7 +1170,10 @@ export class DateRangePicker extends React.Component<
</a> </a>
<a <a
className={cx('Button', 'Button--primary', 'm-l-sm', { className={cx('Button', 'Button--primary', 'm-l-sm', {
'is-disabled': !this.state.startDate || !this.state.endDate 'is-disabled':
!this.state.startDate ||
!this.state.endDate ||
this.state.endDate?.isBefore(this.state.startDate)
})} })}
onClick={this.confirm} onClick={this.confirm}
> >
@ -1030,8 +1190,10 @@ export class DateRangePicker extends React.Component<
className, className,
popoverClassName, popoverClassName,
classPrefix: ns, classPrefix: ns,
classnames: cx,
value, value,
placeholder, startPlaceholder,
endPlaceholder,
popOverContainer, popOverContainer,
inputFormat, inputFormat,
format, format,
@ -1059,21 +1221,6 @@ export class DateRangePicker extends React.Component<
const {isOpened, isFocused, startDate, endDate} = this.state; const {isOpened, isFocused, startDate, endDate} = this.state;
const selectedDate = DateRangePicker.unFormatValue(
value,
format,
joinValues,
delimiter
);
const startViewValue = selectedDate.startDate
? selectedDate.startDate.format(inputFormat)
: '';
const endViewValue = selectedDate.endDate
? selectedDate.endDate.format(inputFormat)
: '';
const arr = [];
startViewValue && arr.push(startViewValue);
endViewValue && arr.push(endViewValue);
const __ = this.props.translate; const __ = this.props.translate;
const calendarMobile = ( const calendarMobile = (
@ -1138,17 +1285,32 @@ export class DateRangePicker extends React.Component<
className className
)} )}
ref={this.dom} ref={this.dom}
onClick={this.handleClick}
> >
{arr.length ? ( <Input
<span className={`${ns}DateRangePicker-value`}> className={cx('DateRangePicker-input', {
{arr.join(__('DateRange.valueConcat'))} isActive: this.state.editState === 'start'
</span> })}
) : ( onChange={this.startInputChange}
<span className={`${ns}DateRangePicker-placeholder`}> onClick={this.openStart}
{__(placeholder)} ref={this.startInputRef}
</span> placeholder={__(startPlaceholder)}
)} autoComplete="off"
value={this.state.startInputValue || ''}
disabled={disabled}
/>
<span className={cx('DateRangePicker-input-separator')}>~</span>
<Input
className={cx('DateRangePicker-input', {
isActive: this.state.editState === 'end'
})}
onChange={this.endInputChange}
onClick={this.openEnd}
ref={this.endInputRef}
placeholder={__(endPlaceholder)}
autoComplete="off"
value={this.state.endInputValue || ''}
disabled={disabled}
/>
{clearable && !disabled && value ? ( {clearable && !disabled && value ? (
<a className={`${ns}DateRangePicker-clear`} onClick={this.clearValue}> <a className={`${ns}DateRangePicker-clear`} onClick={this.clearValue}>
@ -1185,7 +1347,6 @@ export class DateRangePicker extends React.Component<
className={cx(`${ns}DateRangePicker-popover`, popoverClassName)} className={cx(`${ns}DateRangePicker-popover`, popoverClassName)}
onHide={this.close} onHide={this.close}
onClick={this.handlePopOverClick} onClick={this.handlePopOverClick}
overlay
> >
{this.renderCalendar()} {this.renderCalendar()}
</PopOver> </PopOver>

View File

@ -223,8 +223,7 @@ class BaseDatePicker extends React.Component<
updatedState.viewDate = moment(props.viewDate); updatedState.viewDate = moment(props.viewDate);
} }
// time-range 下会有问题,先不支持 if (Object.keys(updatedState).length) {
if (Object.keys(updatedState).length && props.viewMode !== 'time') {
this.setState(updatedState); this.setState(updatedState);
} }