@automattic/date-range-picker 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -0
- package/dist/cjs/button-stack.js +9 -0
- package/dist/cjs/button-stack.js.map +1 -0
- package/dist/cjs/date-inputs.js +37 -0
- package/dist/cjs/date-inputs.js.map +1 -0
- package/dist/cjs/date-range-content.js +194 -0
- package/dist/cjs/date-range-content.js.map +1 -0
- package/dist/cjs/date-range-picker.js +48 -0
- package/dist/cjs/date-range-picker.js.map +1 -0
- package/dist/cjs/datetime.js +56 -0
- package/dist/cjs/datetime.js.map +1 -0
- package/dist/cjs/index.js +16 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/presets-listbox.js +18 -0
- package/dist/cjs/presets-listbox.js.map +1 -0
- package/dist/cjs/utils.js +137 -0
- package/dist/cjs/utils.js.map +1 -0
- package/dist/esm/button-stack.js +6 -0
- package/dist/esm/button-stack.js.map +1 -0
- package/dist/esm/date-inputs.js +34 -0
- package/dist/esm/date-inputs.js.map +1 -0
- package/dist/esm/date-range-content.js +191 -0
- package/dist/esm/date-range-content.js.map +1 -0
- package/dist/esm/date-range-picker.js +45 -0
- package/dist/esm/date-range-picker.js.map +1 -0
- package/dist/esm/datetime.js +50 -0
- package/dist/esm/datetime.js.map +1 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/presets-listbox.js +15 -0
- package/dist/esm/presets-listbox.js.map +1 -0
- package/dist/esm/utils.js +130 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/tsconfig-cjs.tsbuildinfo +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/button-stack.d.ts +8 -0
- package/dist/types/button-stack.d.ts.map +1 -0
- package/dist/types/date-inputs.d.ts +21 -0
- package/dist/types/date-inputs.d.ts.map +1 -0
- package/dist/types/date-range-content.d.ts +39 -0
- package/dist/types/date-range-content.d.ts.map +1 -0
- package/dist/types/date-range-picker.d.ts +25 -0
- package/dist/types/date-range-picker.d.ts.map +1 -0
- package/dist/types/datetime.d.ts +15 -0
- package/dist/types/datetime.d.ts.map +1 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/presets-listbox.d.ts +12 -0
- package/dist/types/presets-listbox.d.ts.map +1 -0
- package/dist/types/utils.d.ts +40 -0
- package/dist/types/utils.d.ts.map +1 -0
- package/package.json +71 -0
- package/src/button-stack.tsx +17 -0
- package/src/date-inputs.tsx +142 -0
- package/src/date-range-content.tsx +379 -0
- package/src/date-range-picker.tsx +197 -0
- package/src/datetime.ts +58 -0
- package/src/index.ts +11 -0
- package/src/presets-listbox.tsx +65 -0
- package/src/style.scss +72 -0
- package/src/test/date-range-picker.test.tsx +334 -0
- package/src/test/utils.test.ts +58 -0
- package/src/types.d.ts +1 -0
- package/src/utils.ts +174 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { DateRangeCalendar, TZDate } from '@automattic/ui';
|
|
2
|
+
import {
|
|
3
|
+
__experimentalText as Text,
|
|
4
|
+
Button,
|
|
5
|
+
__experimentalHStack as HStack,
|
|
6
|
+
__experimentalVStack as VStack,
|
|
7
|
+
} from '@wordpress/components';
|
|
8
|
+
import { __, sprintf } from '@wordpress/i18n';
|
|
9
|
+
import { startOfMonth, subMonths } from 'date-fns';
|
|
10
|
+
import { useState } from 'react';
|
|
11
|
+
import { ButtonStack } from './button-stack';
|
|
12
|
+
import { DateInputs } from './date-inputs';
|
|
13
|
+
import { formatYmd, formatSiteYmd, parseYmdLocal } from './datetime';
|
|
14
|
+
import { PresetsListbox } from './presets-listbox';
|
|
15
|
+
import { computePresetRange, getActivePresetId, PresetId, presetDefs } from './utils';
|
|
16
|
+
|
|
17
|
+
type DateRangeContentProps = {
|
|
18
|
+
isSmall: boolean;
|
|
19
|
+
showTwoMonths?: boolean;
|
|
20
|
+
fromDraft?: Date;
|
|
21
|
+
toDraft?: Date;
|
|
22
|
+
fromStr: string;
|
|
23
|
+
toStr: string;
|
|
24
|
+
setFromDraft: ( date?: Date ) => void;
|
|
25
|
+
setToDraft: ( date?: Date ) => void;
|
|
26
|
+
setFromStr: ( string: string ) => void;
|
|
27
|
+
setToStr: ( string: string ) => void;
|
|
28
|
+
timezoneString?: string;
|
|
29
|
+
gmtOffset?: number;
|
|
30
|
+
onChange: ( next: { start: Date; end: Date } ) => void;
|
|
31
|
+
onClose?: () => void;
|
|
32
|
+
compositeActiveId: string | null;
|
|
33
|
+
setCompositeActiveId: ( id: string | null ) => void;
|
|
34
|
+
today: Date;
|
|
35
|
+
todayStr: string;
|
|
36
|
+
mobileLabelId: string;
|
|
37
|
+
desktopLabelId: string;
|
|
38
|
+
disableFuture?: boolean;
|
|
39
|
+
disabledBefore?: Date;
|
|
40
|
+
defaultFallbackPreset?: PresetId;
|
|
41
|
+
hiddenPresets?: PresetId[];
|
|
42
|
+
inputsProps?: {
|
|
43
|
+
onStartFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
44
|
+
onEndFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
45
|
+
onStartBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
46
|
+
onEndBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function DateRangeContent( props: DateRangeContentProps ) {
|
|
51
|
+
const {
|
|
52
|
+
isSmall,
|
|
53
|
+
showTwoMonths = false,
|
|
54
|
+
fromDraft,
|
|
55
|
+
toDraft,
|
|
56
|
+
fromStr,
|
|
57
|
+
toStr,
|
|
58
|
+
setFromDraft,
|
|
59
|
+
setToDraft,
|
|
60
|
+
setFromStr,
|
|
61
|
+
setToStr,
|
|
62
|
+
timezoneString,
|
|
63
|
+
gmtOffset,
|
|
64
|
+
onChange,
|
|
65
|
+
onClose,
|
|
66
|
+
compositeActiveId,
|
|
67
|
+
setCompositeActiveId,
|
|
68
|
+
today,
|
|
69
|
+
todayStr,
|
|
70
|
+
mobileLabelId,
|
|
71
|
+
desktopLabelId,
|
|
72
|
+
disableFuture = true,
|
|
73
|
+
disabledBefore,
|
|
74
|
+
defaultFallbackPreset = 'last-7-days',
|
|
75
|
+
hiddenPresets,
|
|
76
|
+
inputsProps,
|
|
77
|
+
} = props;
|
|
78
|
+
|
|
79
|
+
// Avoid passing invalid or empty time zones to Intl consumers
|
|
80
|
+
const isValidIanaTimeZone = ( timeZone?: string ): timeZone is string => {
|
|
81
|
+
if ( ! timeZone ) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
// Will throw for invalid IANA identifiers (including empty strings)
|
|
86
|
+
Intl.DateTimeFormat( 'en-US', { timeZone: timeZone } );
|
|
87
|
+
return true;
|
|
88
|
+
} catch ( _e ) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const timeZoneForCalendar = isValidIanaTimeZone( timezoneString ) ? timezoneString : undefined;
|
|
94
|
+
const [ isTyping, setIsTyping ] = useState( false );
|
|
95
|
+
const [ inputsVersion, setInputsVersion ] = useState( 0 );
|
|
96
|
+
|
|
97
|
+
const clear = () => {
|
|
98
|
+
setFromDraft( undefined );
|
|
99
|
+
setToDraft( undefined );
|
|
100
|
+
setFromStr( '' );
|
|
101
|
+
setToStr( '' );
|
|
102
|
+
setIsTyping( false );
|
|
103
|
+
// Force controlled inputs to remount so any internal buffers are reset
|
|
104
|
+
setInputsVersion( ( version ) => version + 1 );
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const canDefaultApply = ! fromDraft && ! toDraft && ! fromStr && ! toStr && ! isTyping;
|
|
108
|
+
const defaultPresetLabel =
|
|
109
|
+
presetDefs.find( ( p ) => p.id === defaultFallbackPreset )?.label || __( 'default range' );
|
|
110
|
+
|
|
111
|
+
const apply = () => {
|
|
112
|
+
if ( fromDraft && toDraft ) {
|
|
113
|
+
const [ startPoint, endPoint ] =
|
|
114
|
+
fromDraft <= toDraft ? [ fromDraft, toDraft ] : [ toDraft, fromDraft ];
|
|
115
|
+
onChange( { start: startPoint, end: endPoint } );
|
|
116
|
+
onClose?.();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if ( canDefaultApply ) {
|
|
120
|
+
const range = computePresetRange( defaultFallbackPreset, today );
|
|
121
|
+
if ( range ) {
|
|
122
|
+
onChange( { start: range.from, end: range.to } );
|
|
123
|
+
onClose?.();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const setPreset = ( id: PresetId ) => {
|
|
129
|
+
const range = computePresetRange( id, today );
|
|
130
|
+
if ( ! range ) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
setFromDraft( range.from );
|
|
134
|
+
setToDraft( range.to );
|
|
135
|
+
setFromStr( formatYmd( range.from, timezoneString, gmtOffset ) );
|
|
136
|
+
setToStr( formatYmd( range.to, timezoneString, gmtOffset ) );
|
|
137
|
+
onChange( { start: range.from, end: range.to } );
|
|
138
|
+
onClose?.();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const activePresetId: PresetId | undefined = ( () => {
|
|
142
|
+
const preset = getActivePresetId( fromDraft, toDraft, today );
|
|
143
|
+
if ( preset ) {
|
|
144
|
+
return preset;
|
|
145
|
+
}
|
|
146
|
+
// Only mark "custom" when both dates are present and do not match a known preset
|
|
147
|
+
if ( fromDraft && toDraft ) {
|
|
148
|
+
return 'custom';
|
|
149
|
+
}
|
|
150
|
+
// When cleared or incomplete, highlight nothing
|
|
151
|
+
return undefined;
|
|
152
|
+
} )();
|
|
153
|
+
|
|
154
|
+
// Site “today” as a site-day Date
|
|
155
|
+
const siteToday =
|
|
156
|
+
parseYmdLocal( formatYmd( today, timezoneString, gmtOffset ) ) ??
|
|
157
|
+
new Date( today.getFullYear(), today.getMonth(), today.getDate() );
|
|
158
|
+
|
|
159
|
+
// Month anchors in site time
|
|
160
|
+
const siteMonthStart = startOfMonth( siteToday );
|
|
161
|
+
const prevMonthStart = subMonths( siteMonthStart, 1 );
|
|
162
|
+
|
|
163
|
+
// Build calendar month refs
|
|
164
|
+
const makeTZMonthFromDate = ( d: Date ) =>
|
|
165
|
+
timeZoneForCalendar
|
|
166
|
+
? new TZDate( Date.UTC( d.getFullYear(), d.getMonth(), 1, 12 ), timeZoneForCalendar )
|
|
167
|
+
: new Date( d.getFullYear(), d.getMonth(), 1 );
|
|
168
|
+
|
|
169
|
+
const defaultMonth = showTwoMonths
|
|
170
|
+
? makeTZMonthFromDate( prevMonthStart )
|
|
171
|
+
: makeTZMonthFromDate( siteMonthStart );
|
|
172
|
+
|
|
173
|
+
const endMonth = makeTZMonthFromDate( siteMonthStart );
|
|
174
|
+
|
|
175
|
+
// Use TZDate for calendar selection when a valid IANA time zone is available
|
|
176
|
+
const selected =
|
|
177
|
+
timeZoneForCalendar && ( fromDraft || toDraft )
|
|
178
|
+
? {
|
|
179
|
+
from: fromDraft ? new TZDate( +fromDraft, timeZoneForCalendar ) : undefined,
|
|
180
|
+
to: toDraft ? new TZDate( +toDraft, timeZoneForCalendar ) : undefined,
|
|
181
|
+
}
|
|
182
|
+
: { from: fromDraft ?? undefined, to: toDraft ?? undefined };
|
|
183
|
+
|
|
184
|
+
const disabledMatcher = ( () => {
|
|
185
|
+
const matchers: Array< { after: Date } | { before: Date } > = [];
|
|
186
|
+
if ( disableFuture ) {
|
|
187
|
+
matchers.push( { after: today } );
|
|
188
|
+
}
|
|
189
|
+
if ( disabledBefore ) {
|
|
190
|
+
matchers.push( { before: disabledBefore } );
|
|
191
|
+
}
|
|
192
|
+
if ( matchers.length === 0 ) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
return matchers.length === 1 ? matchers[ 0 ] : matchers;
|
|
196
|
+
} )();
|
|
197
|
+
|
|
198
|
+
const minInputStr = disabledBefore ? formatSiteYmd( disabledBefore ) : undefined;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<VStack as="div" spacing={ 3 } style={ { padding: 12 } }>
|
|
202
|
+
<Text as="div" weight={ 600 } align="center" size="smallTitle">
|
|
203
|
+
{ __( 'Date Range' ) }
|
|
204
|
+
</Text>
|
|
205
|
+
|
|
206
|
+
{ isSmall ? (
|
|
207
|
+
<VStack as="div" spacing={ 2 }>
|
|
208
|
+
<PresetsListbox
|
|
209
|
+
labelId={ mobileLabelId }
|
|
210
|
+
activePresetId={ activePresetId }
|
|
211
|
+
onSelect={ setPreset }
|
|
212
|
+
compositeActiveId={ compositeActiveId }
|
|
213
|
+
setCompositeActiveId={ setCompositeActiveId }
|
|
214
|
+
hiddenPresets={ hiddenPresets }
|
|
215
|
+
/>
|
|
216
|
+
|
|
217
|
+
<DateInputs
|
|
218
|
+
key={ `inputs-${ inputsVersion }-mobile` }
|
|
219
|
+
fromStr={ fromStr }
|
|
220
|
+
toStr={ toStr }
|
|
221
|
+
onFromChange={ ( value ) => {
|
|
222
|
+
setFromStr( value );
|
|
223
|
+
const parsed = value ? parseYmdLocal( value ) || undefined : undefined;
|
|
224
|
+
setFromDraft( parsed );
|
|
225
|
+
setIsTyping( Boolean( value || toStr ) );
|
|
226
|
+
} }
|
|
227
|
+
onToChange={ ( value ) => {
|
|
228
|
+
setToStr( value );
|
|
229
|
+
const parsed = value ? parseYmdLocal( value ) || undefined : undefined;
|
|
230
|
+
setToDraft( parsed );
|
|
231
|
+
setIsTyping( Boolean( fromStr || value ) );
|
|
232
|
+
} }
|
|
233
|
+
todayStr={ todayStr }
|
|
234
|
+
minStr={ minInputStr }
|
|
235
|
+
onFromFocus={ ( e ) => {
|
|
236
|
+
setIsTyping( true );
|
|
237
|
+
inputsProps?.onStartFocus?.( e );
|
|
238
|
+
} }
|
|
239
|
+
onToFocus={ ( e ) => {
|
|
240
|
+
setIsTyping( true );
|
|
241
|
+
inputsProps?.onEndFocus?.( e );
|
|
242
|
+
} }
|
|
243
|
+
onFromBlur={ ( e ) => {
|
|
244
|
+
if ( ! fromStr && ! toStr ) {
|
|
245
|
+
setIsTyping( false );
|
|
246
|
+
}
|
|
247
|
+
inputsProps?.onStartBlur?.( e );
|
|
248
|
+
} }
|
|
249
|
+
onToBlur={ ( e ) => {
|
|
250
|
+
if ( ! fromStr && ! toStr ) {
|
|
251
|
+
setIsTyping( false );
|
|
252
|
+
}
|
|
253
|
+
inputsProps?.onEndBlur?.( e );
|
|
254
|
+
} }
|
|
255
|
+
stack
|
|
256
|
+
fromStyle={ { minWidth: 140 } }
|
|
257
|
+
toStyle={ { minWidth: 140 } }
|
|
258
|
+
/>
|
|
259
|
+
</VStack>
|
|
260
|
+
) : (
|
|
261
|
+
<HStack
|
|
262
|
+
as="div"
|
|
263
|
+
spacing={ 4 }
|
|
264
|
+
justify="flex-end"
|
|
265
|
+
className="daterange-inputs"
|
|
266
|
+
wrap={ false }
|
|
267
|
+
style={ { width: '100%' } }
|
|
268
|
+
>
|
|
269
|
+
<DateInputs
|
|
270
|
+
key={ `inputs-${ inputsVersion }-desktop` }
|
|
271
|
+
fromStr={ fromStr }
|
|
272
|
+
toStr={ toStr }
|
|
273
|
+
onFromChange={ ( value ) => {
|
|
274
|
+
setFromStr( value );
|
|
275
|
+
const parsed = value ? parseYmdLocal( value ) || undefined : undefined;
|
|
276
|
+
setFromDraft( parsed );
|
|
277
|
+
setIsTyping( Boolean( value || toStr ) );
|
|
278
|
+
} }
|
|
279
|
+
onToChange={ ( value ) => {
|
|
280
|
+
setToStr( value );
|
|
281
|
+
const parsed = value ? parseYmdLocal( value ) || undefined : undefined;
|
|
282
|
+
setToDraft( parsed );
|
|
283
|
+
setIsTyping( Boolean( fromStr || value ) );
|
|
284
|
+
} }
|
|
285
|
+
todayStr={ todayStr }
|
|
286
|
+
minStr={ minInputStr }
|
|
287
|
+
onFromFocus={ ( e ) => {
|
|
288
|
+
setIsTyping( true );
|
|
289
|
+
inputsProps?.onStartFocus?.( e );
|
|
290
|
+
} }
|
|
291
|
+
onToFocus={ ( e ) => {
|
|
292
|
+
setIsTyping( true );
|
|
293
|
+
inputsProps?.onEndFocus?.( e );
|
|
294
|
+
} }
|
|
295
|
+
onFromBlur={ ( e ) => {
|
|
296
|
+
if ( ! fromStr && ! toStr ) {
|
|
297
|
+
setIsTyping( false );
|
|
298
|
+
}
|
|
299
|
+
inputsProps?.onStartBlur?.( e );
|
|
300
|
+
} }
|
|
301
|
+
onToBlur={ ( e ) => {
|
|
302
|
+
if ( ! fromStr && ! toStr ) {
|
|
303
|
+
setIsTyping( false );
|
|
304
|
+
}
|
|
305
|
+
inputsProps?.onEndBlur?.( e );
|
|
306
|
+
} }
|
|
307
|
+
fromStyle={ { minWidth: 220, flex: '0 0 auto' } }
|
|
308
|
+
toStyle={ { minWidth: 220, flex: '0 0 auto' } }
|
|
309
|
+
justify="flex-end"
|
|
310
|
+
containerStyle={ { width: '100%' } }
|
|
311
|
+
/>
|
|
312
|
+
</HStack>
|
|
313
|
+
) }
|
|
314
|
+
|
|
315
|
+
<HStack as="div" spacing={ 4 } justify="flex-start" className="daterange-body" wrap={ false }>
|
|
316
|
+
{ ! isSmall && (
|
|
317
|
+
<PresetsListbox
|
|
318
|
+
labelId={ desktopLabelId }
|
|
319
|
+
activePresetId={ activePresetId }
|
|
320
|
+
onSelect={ setPreset }
|
|
321
|
+
compositeActiveId={ compositeActiveId }
|
|
322
|
+
setCompositeActiveId={ setCompositeActiveId }
|
|
323
|
+
hiddenPresets={ hiddenPresets }
|
|
324
|
+
/>
|
|
325
|
+
) }
|
|
326
|
+
|
|
327
|
+
<div className="daterange-calendar">
|
|
328
|
+
<DateRangeCalendar
|
|
329
|
+
timeZone={ timeZoneForCalendar }
|
|
330
|
+
numberOfMonths={ isSmall ? 1 : 2 }
|
|
331
|
+
defaultMonth={ defaultMonth }
|
|
332
|
+
endMonth={ endMonth }
|
|
333
|
+
disabled={ disabledMatcher }
|
|
334
|
+
excludeDisabled
|
|
335
|
+
selected={ selected }
|
|
336
|
+
onSelect={ ( range ) => {
|
|
337
|
+
const toNative = ( d?: Date ) => ( d ? new Date( d.getTime() ) : undefined );
|
|
338
|
+
if ( range?.from ) {
|
|
339
|
+
const from = toNative( range.from );
|
|
340
|
+
setFromDraft( from );
|
|
341
|
+
if ( from ) {
|
|
342
|
+
setFromStr( formatYmd( from, timezoneString, gmtOffset ) );
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if ( range?.to ) {
|
|
346
|
+
const to = toNative( range.to );
|
|
347
|
+
setToDraft( to );
|
|
348
|
+
if ( to ) {
|
|
349
|
+
setToStr( formatYmd( to, timezoneString, gmtOffset ) );
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
setIsTyping( false );
|
|
353
|
+
} }
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
</HStack>
|
|
357
|
+
|
|
358
|
+
<ButtonStack as="div" justify="flex-end">
|
|
359
|
+
<Button variant="secondary" onClick={ clear }>
|
|
360
|
+
{ __( 'Clear' ) }
|
|
361
|
+
</Button>
|
|
362
|
+
<Button
|
|
363
|
+
variant="primary"
|
|
364
|
+
onClick={ apply }
|
|
365
|
+
disabled={ ( ! fromDraft || ! toDraft ) && ! canDefaultApply }
|
|
366
|
+
aria-label={
|
|
367
|
+
/* translators: %s is the preset label, e.g. 'Last 30 days' */
|
|
368
|
+
canDefaultApply ? sprintf( __( 'Apply %s' ), defaultPresetLabel ) : __( 'Apply' )
|
|
369
|
+
}
|
|
370
|
+
>
|
|
371
|
+
{
|
|
372
|
+
/* translators: %s is the preset label, e.g. 'Last 30 days' */
|
|
373
|
+
canDefaultApply ? sprintf( __( 'Apply %s' ), defaultPresetLabel ) : __( 'Apply' )
|
|
374
|
+
}
|
|
375
|
+
</Button>
|
|
376
|
+
</ButtonStack>
|
|
377
|
+
</VStack>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Dropdown, Tooltip, Button } from '@wordpress/components';
|
|
2
|
+
import { useMediaQuery, useInstanceId } from '@wordpress/compose';
|
|
3
|
+
import { __, sprintf } from '@wordpress/i18n';
|
|
4
|
+
import { calendar } from '@wordpress/icons';
|
|
5
|
+
import { useMemo, useState } from 'react';
|
|
6
|
+
import { DateRangeContent } from './date-range-content';
|
|
7
|
+
import { parseYmdLocal, formatYmd, formatSiteYmd } from './datetime';
|
|
8
|
+
import { formatLabel } from './utils';
|
|
9
|
+
import type { PresetId } from './utils';
|
|
10
|
+
import './style.scss';
|
|
11
|
+
|
|
12
|
+
export type DateRangePickerProps = {
|
|
13
|
+
start: Date;
|
|
14
|
+
end: Date;
|
|
15
|
+
onChange: ( next: { start: Date; end: Date } ) => void;
|
|
16
|
+
timezoneString?: string;
|
|
17
|
+
gmtOffset?: number;
|
|
18
|
+
locale: string;
|
|
19
|
+
disableFuture?: boolean;
|
|
20
|
+
disabledBefore?: Date;
|
|
21
|
+
defaultFallbackPreset?: PresetId; // preset to apply when inputs are empty and user presses Apply
|
|
22
|
+
hiddenPresets?: PresetId[];
|
|
23
|
+
inputsProps?: {
|
|
24
|
+
onStartFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
25
|
+
onEndFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
26
|
+
onStartBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
27
|
+
onEndBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function DateRangePicker( {
|
|
32
|
+
start,
|
|
33
|
+
end,
|
|
34
|
+
onChange,
|
|
35
|
+
gmtOffset,
|
|
36
|
+
timezoneString,
|
|
37
|
+
locale,
|
|
38
|
+
disableFuture = true,
|
|
39
|
+
disabledBefore,
|
|
40
|
+
defaultFallbackPreset = 'last-7-days',
|
|
41
|
+
hiddenPresets,
|
|
42
|
+
inputsProps,
|
|
43
|
+
}: DateRangePickerProps ) {
|
|
44
|
+
const isSmall = useMediaQuery( '(max-width: 600px)' );
|
|
45
|
+
// Use a wider breakpoint to decide when two calendars can fit comfortably
|
|
46
|
+
const showTwoMonths = useMediaQuery( '(min-width: 900px)' );
|
|
47
|
+
const instanceId = useInstanceId( DateRangePicker, 'daterange' );
|
|
48
|
+
const mobileLabelId = `presets-label-${ instanceId }-mobile`;
|
|
49
|
+
const desktopLabelId = `presets-label-${ instanceId }-desktop`;
|
|
50
|
+
|
|
51
|
+
const label = formatLabel( start, end, locale );
|
|
52
|
+
|
|
53
|
+
// Reset internal draft state when key inputs change by remounting the inner component
|
|
54
|
+
const resetKey = [
|
|
55
|
+
formatSiteYmd( start ),
|
|
56
|
+
formatSiteYmd( end ),
|
|
57
|
+
timezoneString ?? '',
|
|
58
|
+
gmtOffset ?? '',
|
|
59
|
+
].join( '|' );
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Dropdown
|
|
63
|
+
popoverProps={ { className: 'daterange-popover' } }
|
|
64
|
+
renderToggle={ ( { onToggle, isOpen } ) => (
|
|
65
|
+
<Tooltip text={ __( 'Select a date range' ) } placement="top">
|
|
66
|
+
<div className="daterange-input__toggle">
|
|
67
|
+
<Button
|
|
68
|
+
type="button"
|
|
69
|
+
variant="tertiary"
|
|
70
|
+
onClick={ onToggle }
|
|
71
|
+
aria-haspopup="dialog"
|
|
72
|
+
aria-expanded={ isOpen }
|
|
73
|
+
aria-label={ sprintf(
|
|
74
|
+
/* Translators: %s: date range label */
|
|
75
|
+
__( 'Date range: %s. Activate to open calendar.' ),
|
|
76
|
+
label
|
|
77
|
+
) }
|
|
78
|
+
className="daterange-input__field"
|
|
79
|
+
icon={ calendar }
|
|
80
|
+
iconPosition="right"
|
|
81
|
+
>
|
|
82
|
+
<span aria-hidden="true" className="daterange-input__text">
|
|
83
|
+
{ label }
|
|
84
|
+
</span>
|
|
85
|
+
</Button>
|
|
86
|
+
</div>
|
|
87
|
+
</Tooltip>
|
|
88
|
+
) }
|
|
89
|
+
renderContent={ ( { onClose } ) => (
|
|
90
|
+
<DateRangePickerInner
|
|
91
|
+
key={ resetKey }
|
|
92
|
+
isSmall={ isSmall }
|
|
93
|
+
showTwoMonths={ showTwoMonths }
|
|
94
|
+
start={ start }
|
|
95
|
+
end={ end }
|
|
96
|
+
timezoneString={ timezoneString }
|
|
97
|
+
gmtOffset={ gmtOffset }
|
|
98
|
+
onChange={ onChange }
|
|
99
|
+
onClose={ onClose }
|
|
100
|
+
mobileLabelId={ mobileLabelId }
|
|
101
|
+
desktopLabelId={ desktopLabelId }
|
|
102
|
+
disableFuture={ disableFuture }
|
|
103
|
+
disabledBefore={ disabledBefore }
|
|
104
|
+
defaultFallbackPreset={ defaultFallbackPreset }
|
|
105
|
+
hiddenPresets={ hiddenPresets }
|
|
106
|
+
inputsProps={ inputsProps }
|
|
107
|
+
/>
|
|
108
|
+
) }
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function DateRangePickerInner( {
|
|
114
|
+
isSmall,
|
|
115
|
+
showTwoMonths,
|
|
116
|
+
start,
|
|
117
|
+
end,
|
|
118
|
+
timezoneString,
|
|
119
|
+
gmtOffset,
|
|
120
|
+
onChange,
|
|
121
|
+
onClose,
|
|
122
|
+
mobileLabelId,
|
|
123
|
+
desktopLabelId,
|
|
124
|
+
disableFuture,
|
|
125
|
+
disabledBefore,
|
|
126
|
+
defaultFallbackPreset,
|
|
127
|
+
hiddenPresets,
|
|
128
|
+
inputsProps,
|
|
129
|
+
}: {
|
|
130
|
+
isSmall: boolean;
|
|
131
|
+
showTwoMonths: boolean;
|
|
132
|
+
start: Date;
|
|
133
|
+
end: Date;
|
|
134
|
+
timezoneString?: string;
|
|
135
|
+
gmtOffset?: number;
|
|
136
|
+
onChange: ( next: { start: Date; end: Date } ) => void;
|
|
137
|
+
onClose: () => void;
|
|
138
|
+
mobileLabelId: string;
|
|
139
|
+
desktopLabelId: string;
|
|
140
|
+
disableFuture: boolean;
|
|
141
|
+
disabledBefore?: Date;
|
|
142
|
+
defaultFallbackPreset: PresetId;
|
|
143
|
+
hiddenPresets?: PresetId[];
|
|
144
|
+
inputsProps?: {
|
|
145
|
+
onStartFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
146
|
+
onEndFocus?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
147
|
+
onStartBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
148
|
+
onEndBlur?: ( e: React.FocusEvent< HTMLInputElement > ) => void;
|
|
149
|
+
};
|
|
150
|
+
} ) {
|
|
151
|
+
const [ fromDraft, setFromDraft ] = useState< Date | undefined >( () => start );
|
|
152
|
+
const [ toDraft, setToDraft ] = useState< Date | undefined >( () => end );
|
|
153
|
+
const [ fromStr, setFromStr ] = useState( () => formatSiteYmd( start ) );
|
|
154
|
+
const [ toStr, setToStr ] = useState( () => formatSiteYmd( end ) );
|
|
155
|
+
// Tracks the keyboard-focused preset in the listbox (roving focus), not the selected preset.
|
|
156
|
+
const [ compositeActiveId, setCompositeActiveId ] = useState< string | null >( null );
|
|
157
|
+
|
|
158
|
+
const today = useMemo( () => {
|
|
159
|
+
const parsed = parseYmdLocal( formatYmd( new Date(), timezoneString, gmtOffset ) );
|
|
160
|
+
// Fallback to local midnight if parsing ever fails
|
|
161
|
+
return (
|
|
162
|
+
parsed ?? new Date( new Date().getFullYear(), new Date().getMonth(), new Date().getDate() )
|
|
163
|
+
);
|
|
164
|
+
}, [ timezoneString, gmtOffset ] );
|
|
165
|
+
|
|
166
|
+
const todayStr = useMemo( () => formatSiteYmd( today ), [ today ] );
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<DateRangeContent
|
|
170
|
+
isSmall={ isSmall }
|
|
171
|
+
fromDraft={ fromDraft }
|
|
172
|
+
toDraft={ toDraft }
|
|
173
|
+
fromStr={ fromStr }
|
|
174
|
+
toStr={ toStr }
|
|
175
|
+
setFromDraft={ setFromDraft }
|
|
176
|
+
setToDraft={ setToDraft }
|
|
177
|
+
setFromStr={ setFromStr }
|
|
178
|
+
setToStr={ setToStr }
|
|
179
|
+
timezoneString={ timezoneString }
|
|
180
|
+
gmtOffset={ gmtOffset }
|
|
181
|
+
onChange={ onChange }
|
|
182
|
+
onClose={ onClose }
|
|
183
|
+
compositeActiveId={ compositeActiveId }
|
|
184
|
+
setCompositeActiveId={ setCompositeActiveId }
|
|
185
|
+
today={ today }
|
|
186
|
+
todayStr={ todayStr }
|
|
187
|
+
mobileLabelId={ mobileLabelId }
|
|
188
|
+
desktopLabelId={ desktopLabelId }
|
|
189
|
+
disableFuture={ disableFuture }
|
|
190
|
+
disabledBefore={ disabledBefore }
|
|
191
|
+
showTwoMonths={ showTwoMonths }
|
|
192
|
+
defaultFallbackPreset={ defaultFallbackPreset }
|
|
193
|
+
hiddenPresets={ hiddenPresets }
|
|
194
|
+
inputsProps={ inputsProps }
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
}
|
package/src/datetime.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { dateI18n } from '@wordpress/date';
|
|
2
|
+
import { parse, isValid, format } from 'date-fns';
|
|
3
|
+
|
|
4
|
+
const YMD_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
|
5
|
+
|
|
6
|
+
export function formatDate(
|
|
7
|
+
date: Date,
|
|
8
|
+
locale: string,
|
|
9
|
+
formatOptions: Intl.DateTimeFormatOptions = { dateStyle: 'medium' }
|
|
10
|
+
) {
|
|
11
|
+
if ( isNaN( date.getTime() ) ) {
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
return new Intl.DateTimeFormat( locale, formatOptions ).format( date );
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a date string in the format "YYYY-MM-DD" (local time).
|
|
19
|
+
*/
|
|
20
|
+
export function parseYmdLocal( value: string ): Date | null {
|
|
21
|
+
if ( ! YMD_REGEX.test( value ) ) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const parsed = parse( value, 'yyyy-MM-dd', new Date() );
|
|
25
|
+
if ( ! isValid( parsed ) ) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
// Ensure strict match (reject overflows like 2023-02-31 -> 2023-03-03)
|
|
29
|
+
return format( parsed, 'yyyy-MM-dd' ) === value ? parsed : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format a date as a site calendar day (YYYY-MM-DD).
|
|
34
|
+
*/
|
|
35
|
+
export function formatYmd( date: Date, timezoneString?: string, gmtOffset?: number ) {
|
|
36
|
+
if ( timezoneString ) {
|
|
37
|
+
return dateI18n( 'Y-m-d', date, timezoneString );
|
|
38
|
+
}
|
|
39
|
+
if ( typeof gmtOffset === 'number' ) {
|
|
40
|
+
const shifted = new Date( date.getTime() + gmtOffset * 60 * 60 * 1000 );
|
|
41
|
+
const year = shifted.getUTCFullYear();
|
|
42
|
+
const month = String( shifted.getUTCMonth() + 1 ).padStart( 2, '0' );
|
|
43
|
+
const day = String( shifted.getUTCDate() ).padStart( 2, '0' );
|
|
44
|
+
return `${ year }-${ month }-${ day }`;
|
|
45
|
+
}
|
|
46
|
+
return dateI18n( 'Y-m-d', date );
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Format a Date that already represents a site calendar day.
|
|
51
|
+
* This avoids reapplying timezone math to dates coming from the picker or URL.
|
|
52
|
+
*/
|
|
53
|
+
export function formatSiteYmd( date: Date ) {
|
|
54
|
+
const year = date.getFullYear();
|
|
55
|
+
const month = String( date.getMonth() + 1 ).padStart( 2, '0' );
|
|
56
|
+
const day = String( date.getDate() ).padStart( 2, '0' );
|
|
57
|
+
return `${ year }-${ month }-${ day }`;
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { DateRangePicker } from './date-range-picker';
|
|
2
|
+
export type { DateRangePickerProps } from './date-range-picker';
|
|
3
|
+
export {
|
|
4
|
+
computePresetRange,
|
|
5
|
+
formatLabel,
|
|
6
|
+
getActivePresetId,
|
|
7
|
+
isLast7Days,
|
|
8
|
+
presetDefs,
|
|
9
|
+
} from './utils';
|
|
10
|
+
export type { PresetId } from './utils';
|
|
11
|
+
export { formatYmd, formatSiteYmd, parseYmdLocal } from './datetime';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
__experimentalVStack as VStack,
|
|
4
|
+
Composite,
|
|
5
|
+
VisuallyHidden,
|
|
6
|
+
} from '@wordpress/components';
|
|
7
|
+
import { __ } from '@wordpress/i18n';
|
|
8
|
+
import { presetDefs } from './utils';
|
|
9
|
+
import type { PresetId } from './utils';
|
|
10
|
+
|
|
11
|
+
type PresetsListboxProps = {
|
|
12
|
+
labelId: string;
|
|
13
|
+
activePresetId?: PresetId;
|
|
14
|
+
onSelect: ( id: PresetId ) => void;
|
|
15
|
+
compositeActiveId: string | null;
|
|
16
|
+
setCompositeActiveId: ( id: string | null ) => void;
|
|
17
|
+
hiddenPresets?: PresetId[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function PresetsListbox( {
|
|
21
|
+
labelId,
|
|
22
|
+
activePresetId,
|
|
23
|
+
onSelect,
|
|
24
|
+
compositeActiveId,
|
|
25
|
+
setCompositeActiveId,
|
|
26
|
+
hiddenPresets,
|
|
27
|
+
}: PresetsListboxProps ) {
|
|
28
|
+
const items: ReadonlyArray< { id: PresetId; label: string } > = [
|
|
29
|
+
...presetDefs,
|
|
30
|
+
{ id: 'custom' as const, label: __( 'Custom' ) },
|
|
31
|
+
].filter( ( item ) => ! hiddenPresets?.includes( item.id ) );
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<VStack justify="flex-start" alignment="stretch" spacing={ 1 } className="daterange-presets">
|
|
35
|
+
<VisuallyHidden id={ labelId }>{ __( 'Date range presets' ) }</VisuallyHidden>
|
|
36
|
+
<Composite
|
|
37
|
+
aria-labelledby={ labelId }
|
|
38
|
+
activeId={ compositeActiveId ?? undefined }
|
|
39
|
+
setActiveId={ ( id ) => setCompositeActiveId( id ?? null ) }
|
|
40
|
+
focusLoop
|
|
41
|
+
virtualFocus
|
|
42
|
+
role="listbox"
|
|
43
|
+
>
|
|
44
|
+
<VStack justify="flex-start" alignment="stretch" spacing={ 1 }>
|
|
45
|
+
{ items.map( ( preset ) => {
|
|
46
|
+
const isSelected = activePresetId === preset.id;
|
|
47
|
+
return (
|
|
48
|
+
<Composite.Item
|
|
49
|
+
key={ preset.id }
|
|
50
|
+
id={ preset.id }
|
|
51
|
+
render={ <Button size="compact" variant={ isSelected ? 'primary' : undefined } /> }
|
|
52
|
+
onClick={ () => onSelect( preset.id ) }
|
|
53
|
+
role="option"
|
|
54
|
+
aria-selected={ isSelected || undefined }
|
|
55
|
+
className="preset-listbox__item"
|
|
56
|
+
>
|
|
57
|
+
{ preset.label }
|
|
58
|
+
</Composite.Item>
|
|
59
|
+
);
|
|
60
|
+
} ) }
|
|
61
|
+
</VStack>
|
|
62
|
+
</Composite>
|
|
63
|
+
</VStack>
|
|
64
|
+
);
|
|
65
|
+
}
|