Skip to content

Commit ccca2b2

Browse files
Merge pull request #29 from hernansartorio/min-max
Add minimumLength and maximumLength props to date range picker
2 parents dc2e010 + f9cc320 commit ccca2b2

File tree

8 files changed

+159
-44
lines changed

8 files changed

+159
-44
lines changed

index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ declare module "react-nice-dates" {
2929
children: JSX.Element;
3030
startDate?: Date | undefined;
3131
endDate?: Date | undefined;
32+
minimumLength?: number | undefined;
33+
maximumLength?: number | undefined;
3234
onStartDateChange?: (date: Date | undefined) => void;
3335
onEndDateChange?: (date: Date | undefined) => void;
3436
format?: string;
@@ -46,6 +48,8 @@ declare module "react-nice-dates" {
4648
endDate?: Date | undefined;
4749
focus?: "startDate, endDate";
4850
month?: Date | undefined;
51+
minimumLength?: number | undefined;
52+
maximumLength?: number | undefined;
4953
onFocusChange: (focus: "startDate" | "endDate") => void;
5054
onStartDateChange: (date: Date | undefined) => void;
5155
onEndDateChange: (date: Date | undefined) => void;

src/DateRangePicker.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React, { useState } from 'react'
2-
import { func, instanceOf, object, objectOf, string } from 'prop-types'
3-
import { addDays, subDays } from 'date-fns'
4-
import { isSelectable } from './utils'
2+
import { func, instanceOf, number, object, objectOf, string } from 'prop-types'
3+
import { isRangeLengthValid } from './utils'
54
import { START_DATE, END_DATE } from './constants'
65
import useDateInput from './useDateInput'
76
import useOutsideClickHandler from './useOutsideClickHandler'
@@ -19,6 +18,8 @@ export default function DateRangePicker({
1918
format,
2019
minimumDate,
2120
maximumDate,
21+
minimumLength,
22+
maximumLength,
2223
modifiers,
2324
modifiersClassNames,
2425
weekdayFormat
@@ -41,7 +42,7 @@ export default function DateRangePicker({
4142
onStartDateChange(date)
4243
date && setMonth(date)
4344
},
44-
validate: date => isSelectable(date, { maximumDate: subDays(endDate, 1) })
45+
validate: date => !endDate || isRangeLengthValid({ startDate: date, endDate }, { minimumLength, maximumLength })
4546
})
4647

4748
const endDateInputProps = useDateInput({
@@ -54,7 +55,7 @@ export default function DateRangePicker({
5455
onEndDateChange(date)
5556
date && setMonth(date)
5657
},
57-
validate: date => isSelectable(date, { minimumDate: addDays(startDate, 1) })
58+
validate: date => !startDate || isRangeLengthValid({ startDate, endDate: date }, { minimumLength, maximumLength })
5859
})
5960

6061
return (
@@ -102,6 +103,8 @@ export default function DateRangePicker({
102103
onMonthChange={setMonth}
103104
minimumDate={minimumDate}
104105
maximumDate={maximumDate}
106+
minimumLength={minimumLength}
107+
maximumLength={maximumLength}
105108
modifiers={modifiers}
106109
modifiersClassNames={modifiersClassNames}
107110
weekdayFormat={weekdayFormat}
@@ -121,6 +124,8 @@ DateRangePicker.propTypes = {
121124
format: string,
122125
minimumDate: instanceOf(Date),
123126
maximumDate: instanceOf(Date),
127+
minimumLength: number,
128+
maximumLength: number,
124129
modifiers: objectOf(func),
125130
modifiersClassNames: objectOf(string),
126131
weekdayFormat: string

src/DateRangePickerCalendar.js

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useState } from 'react'
2-
import { func, instanceOf, object, objectOf, oneOf, string } from 'prop-types'
3-
import { isSameDay, isAfter, isBefore, startOfMonth, startOfDay } from 'date-fns'
4-
import { isSelectable, mergeModifiers, setTime } from './utils'
2+
import { func, instanceOf, number, object, objectOf, oneOf, string } from 'prop-types'
3+
import { differenceInDays, isSameDay, isAfter, isBefore, startOfMonth, startOfDay } from 'date-fns'
4+
import { isRangeLengthValid, isSelectable, mergeModifiers, setTime } from './utils'
55
import { START_DATE, END_DATE } from './constants'
66
import useControllableState from './useControllableState'
77
import Calendar from './Calendar'
@@ -18,12 +18,18 @@ export default function DateRangePickerCalendar({
1818
onMonthChange,
1919
minimumDate,
2020
maximumDate,
21+
minimumLength,
22+
maximumLength,
2123
modifiers: receivedModifiers,
2224
modifiersClassNames,
2325
weekdayFormat
2426
}) {
2527
const [hoveredDate, setHoveredDate] = useState()
26-
const [month, setMonth] = useControllableState(receivedMonth, onMonthChange, startOfMonth(startDate || endDate || new Date()))
28+
const [month, setMonth] = useControllableState(
29+
receivedMonth,
30+
onMonthChange,
31+
startOfMonth(startDate || endDate || new Date())
32+
)
2733

2834
const displayedStartDate =
2935
focus === START_DATE && !startDate && endDate && hoveredDate && !isSameDay(hoveredDate, endDate)
@@ -47,46 +53,53 @@ export default function DateRangePickerCalendar({
4753
isMiddleDate(date) ||
4854
isEndDate(date) ||
4955
isSameDay(date, startDate) ||
50-
isSameDay(date, endDate)
51-
),
56+
isSameDay(date, endDate)),
5257
selectedStart: isStartDate,
5358
selectedMiddle: isMiddleDate,
5459
selectedEnd: isEndDate,
55-
disabled: date => (focus === START_DATE && isEndDate(date)) || (focus === END_DATE && isStartDate(date))
60+
disabled: date =>
61+
(focus === START_DATE &&
62+
endDate &&
63+
((differenceInDays(endDate, date) < minimumLength && (!startDate || !isAfter(date, endDate))) ||
64+
(!startDate && maximumLength && differenceInDays(endDate, date) > maximumLength))) ||
65+
(focus === END_DATE &&
66+
startDate &&
67+
((differenceInDays(date, startDate) < minimumLength && (!endDate || !isBefore(date, startDate))) ||
68+
(!endDate && maximumLength && differenceInDays(date, startDate) > maximumLength)))
5669
},
5770
receivedModifiers
5871
)
5972

6073
const handleSelectDate = date => {
6174
if (focus === START_DATE) {
62-
if (endDate && !isAfter(endDate, date)) {
75+
const invalidEndDate =
76+
endDate && !isRangeLengthValid({ startDate: date, endDate }, { minimumLength, maximumLength })
77+
78+
if (invalidEndDate) {
6379
onEndDateChange(null)
6480
}
6581

6682
onStartDateChange(startDate ? setTime(date, startDate) : date)
6783
onFocusChange(END_DATE)
6884
} else if (focus === END_DATE) {
69-
const invalidStartDate = startDate && !isBefore(startDate, date)
85+
const invalidStartDate =
86+
startDate && !isRangeLengthValid({ startDate, endDate: date }, { minimumLength, maximumLength })
7087

7188
if (invalidStartDate) {
7289
onStartDateChange(null)
7390
}
7491

7592
onEndDateChange(endDate ? setTime(date, endDate) : date)
76-
onFocusChange(invalidStartDate ? START_DATE : null)
93+
onFocusChange(invalidStartDate || !startDate ? START_DATE : null)
7794
}
7895
}
7996

80-
const handleHoverDate = date => {
81-
setHoveredDate(date)
82-
}
83-
8497
return (
8598
<Calendar
8699
locale={locale}
87100
month={month}
88101
onMonthChange={setMonth}
89-
onDayHover={handleHoverDate}
102+
onDayHover={setHoveredDate}
90103
onDayClick={handleSelectDate}
91104
minimumDate={minimumDate}
92105
maximumDate={maximumDate}
@@ -109,6 +122,8 @@ DateRangePickerCalendar.propTypes = {
109122
onMonthChange: func,
110123
minimumDate: instanceOf(Date),
111124
maximumDate: instanceOf(Date),
125+
minimumLength: number,
126+
maximumLength: number,
112127
modifiers: objectOf(func),
113128
modifiersClassNames: objectOf(string),
114129
weekdayFormat: string
@@ -117,5 +132,7 @@ DateRangePickerCalendar.propTypes = {
117132
DateRangePickerCalendar.defaultProps = {
118133
onStartDateChange: () => {},
119134
onEndDateChange: () => {},
120-
onFocusChange: () => {}
135+
onFocusChange: () => {},
136+
minimumLength: 0,
137+
maximumLength: null
121138
}

src/utils.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isAfter, isBefore, startOfDay, set } from 'date-fns'
1+
import { differenceInDays, isAfter, isBefore, startOfDay, set } from 'date-fns'
22

33
export const isSelectable = (date, { minimumDate, maximumDate }) =>
44
!isBefore(date, startOfDay(minimumDate)) && !isAfter(date, maximumDate)
@@ -21,3 +21,7 @@ export const mergeModifiers = (baseModifiers, newModifiers) => {
2121

2222
export const setTime = (date, dateWithTime) =>
2323
set(date, { hours: dateWithTime.getHours(), minutes: dateWithTime.getMinutes(), seconds: dateWithTime.getSeconds() })
24+
25+
export const isRangeLengthValid = ({ startDate, endDate }, { minimumLength, maximumLength }) =>
26+
differenceInDays(endDate, startDate) >= minimumLength &&
27+
(!maximumLength || differenceInDays(endDate, startDate) <= maximumLength)

test/DateRangePicker.test.js

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@ describe('DateRangePicker', () => {
1313
<DateRangePicker locale={locale}>
1414
{({ startDateInputProps, endDateInputProps, focus }) => (
1515
<div className='date-range'>
16-
<input aria-label={START_DATE} className={classNames({ '-focused': focus === START_DATE })} {...startDateInputProps} />
17-
<input aria-label={END_DATE} className={classNames({ '-focused': focus === END_DATE })} {...endDateInputProps} />
16+
<input
17+
aria-label={START_DATE}
18+
className={classNames({ '-focused': focus === START_DATE })}
19+
{...startDateInputProps}
20+
/>
21+
<input
22+
aria-label={END_DATE}
23+
className={classNames({ '-focused': focus === END_DATE })}
24+
{...endDateInputProps}
25+
/>
1826
</div>
1927
)}
2028
</DateRangePicker>
@@ -23,20 +31,23 @@ describe('DateRangePicker', () => {
2331
expect(getAllByText('1').length).toBeGreaterThan(0)
2432
})
2533

26-
it('should open and close', () => {
27-
const { container, getAllByText, getByLabelText } = render(
34+
it('should open and close popup', () => {
35+
const { container, getByLabelText } = render(
2836
<DateRangePicker locale={locale}>
2937
{({ startDateInputProps, endDateInputProps, focus }) => (
3038
<div className='date-range'>
31-
<input aria-label={START_DATE} className={classNames({ '-focused': focus === START_DATE })} {...startDateInputProps} />
32-
<input aria-label={END_DATE} className={classNames({ '-focused': focus === END_DATE })} {...endDateInputProps} />
39+
<input
40+
aria-label={START_DATE}
41+
className={classNames({ '-focused': focus === START_DATE })}
42+
{...startDateInputProps}
43+
/>
44+
<input className={classNames({ '-focused': focus === END_DATE })} {...endDateInputProps} />
3345
</div>
3446
)}
3547
</DateRangePicker>
3648
)
3749

3850
const startDateInput = getByLabelText(START_DATE)
39-
const endDateInput = getByLabelText(END_DATE)
4051
const popover = container.querySelector('.nice-dates-popover')
4152

4253
expect(popover).not.toHaveClass('-open')
@@ -53,20 +64,6 @@ describe('DateRangePicker', () => {
5364

5465
expect(popover).not.toHaveClass('-open')
5566
expect(startDateInput).not.toHaveClass('-focused')
56-
57-
// Should close on date range selection
58-
fireEvent.focus(startDateInput)
59-
60-
expect(popover).toHaveClass('-open')
61-
62-
fireEvent.click(getAllByText('1')[0])
63-
64-
expect(popover).toHaveClass('-open')
65-
expect(endDateInput).toHaveClass('-focused')
66-
67-
fireEvent.click(getAllByText('2')[0])
68-
69-
expect(popover).not.toHaveClass('-open')
7067
})
7168

7269
it('should display pre-selected start date’s month on initial render', () => {

test/DateRangePickerCalendar.test.js

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react'
22
import { render, fireEvent } from '@testing-library/react'
33
import '@testing-library/jest-dom/extend-expect'
4-
import { addDays, format, startOfMonth, subMonths } from 'date-fns'
4+
import { addDays, format, startOfMonth, set, startOfDay, subMonths } from 'date-fns'
55
import { enGB as locale } from 'date-fns/locale'
66
import { START_DATE, END_DATE } from '../src/constants'
77
import DateRangePickerCalendar from '../src/DateRangePickerCalendar'
@@ -130,4 +130,86 @@ describe('DateRangePickerCalendar', () => {
130130

131131
expect(handleEndDateChange).toHaveBeenCalledWith(new Date(2020, 1, 25, 18, 30))
132132
})
133+
134+
it('should allow same day selection by default (when minimumLength is 0)', () => {
135+
const startDate = startOfDay(set(new Date(), { date: 13 }))
136+
137+
const { getByText } = render(<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} />)
138+
139+
expect(getByText('13').parentElement).not.toHaveClass('-disabled')
140+
})
141+
142+
it('should disable dates before the start date when selecting an end date with no existing end date selected', () => {
143+
const startDate = startOfDay(set(new Date(), { date: 13 }))
144+
145+
const { getByText } = render(<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} />)
146+
147+
expect(getByText('11').parentElement).toHaveClass('-disabled')
148+
expect(getByText('12').parentElement).toHaveClass('-disabled')
149+
expect(getByText('13').parentElement).not.toHaveClass('-disabled')
150+
})
151+
152+
it('should disable dates after the end date when selecting a start date with no existing start date selected', () => {
153+
const endDate = startOfDay(set(new Date(), { date: 13 }))
154+
155+
const { getByText } = render(<DateRangePickerCalendar locale={locale} focus={START_DATE} endDate={endDate} />)
156+
157+
expect(getByText('13').parentElement).not.toHaveClass('-disabled')
158+
expect(getByText('14').parentElement).toHaveClass('-disabled')
159+
expect(getByText('15').parentElement).toHaveClass('-disabled')
160+
})
161+
162+
it('should disable in-between dates when minimumLength is set', () => {
163+
const startDate = startOfDay(set(new Date(), { date: 13 }))
164+
165+
const { getByText } = render(
166+
<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} minimumLength={3} />
167+
)
168+
169+
expect(getByText('13').parentElement).toHaveClass('-disabled')
170+
expect(getByText('14').parentElement).toHaveClass('-disabled')
171+
expect(getByText('15').parentElement).toHaveClass('-disabled')
172+
expect(getByText('16').parentElement).not.toHaveClass('-disabled')
173+
})
174+
175+
it('should disable in-between dates when selecting start date and minimumLength is set', () => {
176+
const endDate = startOfDay(set(new Date(), { date: 13 }))
177+
178+
const { getByText } = render(
179+
<DateRangePickerCalendar locale={locale} focus={START_DATE} endDate={endDate} minimumLength={3} />
180+
)
181+
182+
expect(getByText('13').parentElement).toHaveClass('-disabled')
183+
expect(getByText('12').parentElement).toHaveClass('-disabled')
184+
expect(getByText('11').parentElement).toHaveClass('-disabled')
185+
expect(getByText('10').parentElement).not.toHaveClass('-disabled')
186+
})
187+
188+
it('should disable later dates when maximumLength is set', () => {
189+
const startDate = startOfDay(set(new Date(), { date: 13 }))
190+
191+
const { getByText } = render(
192+
<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} maximumLength={3} />
193+
)
194+
195+
expect(getByText('13').parentElement).not.toHaveClass('-disabled')
196+
expect(getByText('14').parentElement).not.toHaveClass('-disabled')
197+
expect(getByText('15').parentElement).not.toHaveClass('-disabled')
198+
expect(getByText('16').parentElement).not.toHaveClass('-disabled')
199+
expect(getByText('17').parentElement).toHaveClass('-disabled')
200+
})
201+
202+
it('should disable earlier dates when selecting start date and maximumLength is set', () => {
203+
const endDate = startOfDay(set(new Date(), { date: 13 }))
204+
205+
const { getByText } = render(
206+
<DateRangePickerCalendar locale={locale} focus={START_DATE} endDate={endDate} maximumLength={3} />
207+
)
208+
209+
expect(getByText('13').parentElement).not.toHaveClass('-disabled')
210+
expect(getByText('12').parentElement).not.toHaveClass('-disabled')
211+
expect(getByText('11').parentElement).not.toHaveClass('-disabled')
212+
expect(getByText('10').parentElement).not.toHaveClass('-disabled')
213+
expect(getByText('9').parentElement).toHaveClass('-disabled')
214+
})
133215
})

website/examples/DateRangePickerExample.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function DateRangePickerExample() {
2020
onStartDateChange={setStartDate}
2121
onEndDateChange={setEndDate}
2222
minimumDate={new Date()}
23+
minimumLength={1}
2324
format='dd MMM yyyy'
2425
locale={enGB}
2526
>
@@ -55,6 +56,7 @@ export default function DateRangePickerExample() {
5556
onStartDateChange={setStartDate}
5657
onEndDateChange={setEndDate}
5758
minimumDate={new Date()}
59+
minimumLength={1}
5860
format='dd MMM yyyy'
5961
locale={enGB}
6062
>

website/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ onEndDateChange: func,
267267
format: string, // Default: locale.formatLong.date({ width: 'short' })
268268
minimumDate: instanceOf(Date), // See Calendar props
269269
maximumDate: instanceOf(Date), // See Calendar props
270+
minimumLength: number, // See DateRangePickerCalendar props
271+
maximumLength: number, // See DateRangePickerCalendar props
270272
modifiers: objectOf(func),
271273
modifiersClassNames: objectOf(string),
272274
weekdayFormat: string // See Calendar props`}
@@ -324,6 +326,8 @@ onFocusChange: func.isRequired,
324326
onMonthChange: func, // See Calendar props
325327
minimumDate: instanceOf(Date), // See Calendar props
326328
maximumDate: instanceOf(Date), // See Calendar props
329+
minimumLength: number, // Minimum range selection length, defaults to 0
330+
maximumLength: number, // Maximum range selection length, defaults to null
327331
modifiers: objectOf(func),
328332
modifiersClassNames: objectOf(string),
329333
weekdayFormat: string // See Calendar props`}

0 commit comments

Comments
 (0)