1- import { useMemo , useState } from 'react' ;
1+ import { useMemo , useState , type ReactNode } from 'react' ;
22import {
33 endOfMonth ,
44 endOfWeek ,
88 today ,
99} from '@internationalized/date' ;
1010import { useControlledState } from '@react-stately/utils' ;
11- import { Calendar as CalendarIcon } from '@untitledui/icons' ;
11+ import { Calendar as CalendarIcon , XClose } from '@untitledui/icons' ;
1212import { useDateFormatter } from 'react-aria' ;
1313import type {
1414 DateRangePickerProps as AriaDateRangePickerProps ,
@@ -22,7 +22,9 @@ import {
2222 useLocale ,
2323} from 'react-aria-components' ;
2424import { Button } from '@/components/base/buttons/button' ;
25+ import type { ButtonProps } from '@/components/base/buttons/button' ;
2526import { cx } from '@/utils/cx' ;
27+ import type { RangeValue } from '@react-types/shared' ;
2628import { DateInput } from './date-input' ;
2729import { RangeCalendar } from './range-calendar' ;
2830import { RangePresetButton } from './range-preset' ;
@@ -31,19 +33,55 @@ const now = today(getLocalTimeZone());
3133
3234const highlightedDates = [ today ( getLocalTimeZone ( ) ) ] ;
3335
36+ export type DateRangePickerValue = RangeValue < DateValue > ;
37+
38+ export interface DateRangePickerPreset {
39+ key : string ;
40+ label : ReactNode ;
41+ value : DateRangePickerValue ;
42+ }
43+
44+ type DateRangePickerButtonProps = ButtonProps & {
45+ [ key : `data-${string } `] : string | undefined ;
46+ } ;
47+
3448interface DateRangePickerProps extends AriaDateRangePickerProps < DateValue > {
3549 /** The function to call when the apply button is clicked. */
36- onApply ?: ( ) => void ;
50+ onApply ?: ( value : DateRangePickerValue | null , presetKey ?: string ) => void ;
3751 /** The function to call when the cancel button is clicked. */
3852 onCancel ?: ( ) => void ;
53+ /** Preset ranges to show in the side rail. */
54+ presets ?: DateRangePickerPreset [ ] ;
55+ /** Label to render in the trigger button instead of the formatted date range. */
56+ triggerLabel ?: ReactNode ;
57+ /** Placeholder to render when there is no selected date range. */
58+ placeholder ?: ReactNode ;
59+ /** Props passed to the trigger button. */
60+ buttonProps ?: DateRangePickerButtonProps ;
61+ /** Show an inline clear action in the trigger button when a range is selected. */
62+ allowClear ?: boolean ;
63+ /** The function to call when the clear action is clicked. */
64+ onClear ?: ( ) => void ;
65+ /** Test id for the clear action. */
66+ clearButtonTestId ?: string ;
67+ /** Applies and closes the picker immediately when a preset is selected. */
68+ applyOnPresetSelect ?: boolean ;
3969}
4070
4171export const DateRangePicker = ( {
72+ allowClear,
73+ applyOnPresetSelect,
74+ buttonProps,
75+ clearButtonTestId = 'clear-date-picker' ,
76+ onClear,
4277 value : valueProp ,
4378 defaultValue,
4479 onChange,
4580 onApply,
4681 onCancel,
82+ placeholder = 'Select dates' ,
83+ presets : presetsProp ,
84+ triggerLabel,
4785 ...props
4886} : DateRangePickerProps ) => {
4987 const { locale } = useLocale ( ) ;
@@ -66,7 +104,7 @@ export const DateRangePicker = ({
66104 ? formatter . format ( value . end . toDate ( getLocalTimeZone ( ) ) )
67105 : 'Select date' ;
68106
69- const presets = useMemo (
107+ const defaultPresets = useMemo (
70108 ( ) => ( {
71109 today : { label : 'Today' , value : { start : now , end : now } } ,
72110 yesterday : {
@@ -122,6 +160,40 @@ export const DateRangePicker = ({
122160 } ) ,
123161 [ locale ]
124162 ) ;
163+ const presets = useMemo (
164+ ( ) =>
165+ presetsProp ??
166+ Object . entries ( defaultPresets ) . map ( ( [ key , preset ] ) => ( {
167+ key,
168+ label : preset . label ,
169+ value : preset . value ,
170+ } ) ) ,
171+ [ defaultPresets , presetsProp ]
172+ ) ;
173+ const calendarPresets = useMemo (
174+ ( ) =>
175+ Object . fromEntries (
176+ presets
177+ . slice ( 0 , 3 )
178+ . map ( ( preset ) => [
179+ preset . key ,
180+ { label : preset . label , value : preset . value } ,
181+ ] )
182+ ) ,
183+ [ presets ]
184+ ) ;
185+
186+ const handleApply = ( close : ( ) => void , presetKey ?: string ) => {
187+ onApply ?.( value , presetKey ) ;
188+ close ( ) ;
189+ } ;
190+ const triggerContent =
191+ triggerLabel ??
192+ ( value ? (
193+ `${ formattedStartDate } – ${ formattedEndDate } `
194+ ) : (
195+ < span className = "tw:text-placeholder" > { placeholder } </ span >
196+ ) ) ;
125197
126198 return (
127199 < AriaDateRangePicker
@@ -131,11 +203,32 @@ export const DateRangePicker = ({
131203 value = { value }
132204 onChange = { setValue } >
133205 < AriaGroup >
134- < Button color = "secondary" iconLeading = { CalendarIcon } size = "md" >
135- { ! value ? (
136- < span className = "tw:text-placeholder" > Select dates</ span >
137- ) : (
138- `${ formattedStartDate } – ${ formattedEndDate } `
206+ < Button
207+ color = "secondary"
208+ iconLeading = { CalendarIcon }
209+ size = "md"
210+ { ...buttonProps } >
211+ { triggerContent }
212+ { allowClear && value && (
213+ < span
214+ data-testid = { clearButtonTestId }
215+ role = "button"
216+ tabIndex = { 0 }
217+ onClick = { ( event ) => {
218+ event . stopPropagation ( ) ;
219+ setValue ( null ) ;
220+ onClear ?.( ) ;
221+ } }
222+ onKeyDown = { ( event ) => {
223+ if ( event . key === 'Enter' || event . key === ' ' ) {
224+ event . preventDefault ( ) ;
225+ event . stopPropagation ( ) ;
226+ setValue ( null ) ;
227+ onClear ?.( ) ;
228+ }
229+ } } >
230+ < XClose className = "tw:size-4" />
231+ </ span >
139232 ) }
140233 </ Button >
141234 </ AriaGroup >
@@ -157,11 +250,15 @@ export const DateRangePicker = ({
157250 < div className = "tw:hidden tw:w-38 tw:flex-col tw:gap-0.5 tw:border-r tw:border-solid tw:border-secondary tw:p-3 tw:lg:flex" >
158251 { Object . values ( presets ) . map ( ( preset ) => (
159252 < RangePresetButton
160- key = { preset . label }
253+ key = { preset . key }
161254 value = { preset . value }
162255 onClick = { ( ) => {
163256 setValue ( preset . value ) ;
164257 setFocusedValue ( preset . value . start ) ;
258+ if ( applyOnPresetSelect ) {
259+ onApply ?.( preset . value , preset . key ) ;
260+ close ( ) ;
261+ }
165262 } } >
166263 { preset . label }
167264 </ RangePresetButton >
@@ -171,11 +268,7 @@ export const DateRangePicker = ({
171268 < RangeCalendar
172269 focusedValue = { focusedValue }
173270 highlightedDates = { highlightedDates }
174- presets = { {
175- lastWeek : presets . lastWeek ,
176- lastMonth : presets . lastMonth ,
177- lastYear : presets . lastYear ,
178- } }
271+ presets = { calendarPresets }
179272 onFocusChange = { setFocusedValue }
180273 />
181274 < div className = "tw:flex tw:justify-between tw:gap-3 tw:border-t tw:border-secondary tw:p-4" >
@@ -197,10 +290,7 @@ export const DateRangePicker = ({
197290 < Button
198291 color = "primary"
199292 size = "md"
200- onClick = { ( ) => {
201- onApply ?.( ) ;
202- close ( ) ;
203- } } >
293+ onClick = { ( ) => handleApply ( close ) } >
204294 Apply
205295 </ Button >
206296 </ div >
0 commit comments