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"
tabindex="0"
>
<input
autocomplete="off"
class="cxd-DateRangePicker-input"
placeholder="选择开始时间"
type="text"
value="2019-06-06"
/>
<span
class="cxd-DateRangePicker-value"
class="cxd-DateRangePicker-input-separator"
>
2019-06-06 至 2019-06-26
~
</span>
<input
autocomplete="off"
class="cxd-DateRangePicker-input"
placeholder="选择结束时间"
type="text"
value="2019-06-26"
/>
<a
class="cxd-DateRangePicker-clear"
>

View File

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

View File

@ -8,7 +8,7 @@ import {makeEnv} from '../../helper';
import moment from 'moment';
test('Renderer:inputYearRange click', async () => {
const {container, findByText, getByText} = render(
const {container, findByPlaceholderText, getByText} = render(
amisRender(
{
type: 'form',
@ -28,7 +28,7 @@ test('Renderer:inputYearRange click', async () => {
)
);
const inputDate = await findByText('请选择年份范围');
const inputDate = await findByPlaceholderText('选择开始时间');
fireEvent.click(inputDate);
@ -51,9 +51,8 @@ test('Renderer:inputYearRange click', async () => {
fireEvent.click(confirm);
const value = document.querySelector(
'.cxd-DateRangePicker-value'
) as HTMLSpanElement;
const value = document.querySelectorAll('.cxd-DateRangePicker-input')!;
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);
}
.#{$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 {
background: $gray200;

View File

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

View File

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