@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.
Files changed (64) hide show
  1. package/README.md +80 -0
  2. package/dist/cjs/button-stack.js +9 -0
  3. package/dist/cjs/button-stack.js.map +1 -0
  4. package/dist/cjs/date-inputs.js +37 -0
  5. package/dist/cjs/date-inputs.js.map +1 -0
  6. package/dist/cjs/date-range-content.js +194 -0
  7. package/dist/cjs/date-range-content.js.map +1 -0
  8. package/dist/cjs/date-range-picker.js +48 -0
  9. package/dist/cjs/date-range-picker.js.map +1 -0
  10. package/dist/cjs/datetime.js +56 -0
  11. package/dist/cjs/datetime.js.map +1 -0
  12. package/dist/cjs/index.js +16 -0
  13. package/dist/cjs/index.js.map +1 -0
  14. package/dist/cjs/presets-listbox.js +18 -0
  15. package/dist/cjs/presets-listbox.js.map +1 -0
  16. package/dist/cjs/utils.js +137 -0
  17. package/dist/cjs/utils.js.map +1 -0
  18. package/dist/esm/button-stack.js +6 -0
  19. package/dist/esm/button-stack.js.map +1 -0
  20. package/dist/esm/date-inputs.js +34 -0
  21. package/dist/esm/date-inputs.js.map +1 -0
  22. package/dist/esm/date-range-content.js +191 -0
  23. package/dist/esm/date-range-content.js.map +1 -0
  24. package/dist/esm/date-range-picker.js +45 -0
  25. package/dist/esm/date-range-picker.js.map +1 -0
  26. package/dist/esm/datetime.js +50 -0
  27. package/dist/esm/datetime.js.map +1 -0
  28. package/dist/esm/index.js +4 -0
  29. package/dist/esm/index.js.map +1 -0
  30. package/dist/esm/presets-listbox.js +15 -0
  31. package/dist/esm/presets-listbox.js.map +1 -0
  32. package/dist/esm/utils.js +130 -0
  33. package/dist/esm/utils.js.map +1 -0
  34. package/dist/tsconfig-cjs.tsbuildinfo +1 -0
  35. package/dist/tsconfig.tsbuildinfo +1 -0
  36. package/dist/types/button-stack.d.ts +8 -0
  37. package/dist/types/button-stack.d.ts.map +1 -0
  38. package/dist/types/date-inputs.d.ts +21 -0
  39. package/dist/types/date-inputs.d.ts.map +1 -0
  40. package/dist/types/date-range-content.d.ts +39 -0
  41. package/dist/types/date-range-content.d.ts.map +1 -0
  42. package/dist/types/date-range-picker.d.ts +25 -0
  43. package/dist/types/date-range-picker.d.ts.map +1 -0
  44. package/dist/types/datetime.d.ts +15 -0
  45. package/dist/types/datetime.d.ts.map +1 -0
  46. package/dist/types/index.d.ts +6 -0
  47. package/dist/types/index.d.ts.map +1 -0
  48. package/dist/types/presets-listbox.d.ts +12 -0
  49. package/dist/types/presets-listbox.d.ts.map +1 -0
  50. package/dist/types/utils.d.ts +40 -0
  51. package/dist/types/utils.d.ts.map +1 -0
  52. package/package.json +71 -0
  53. package/src/button-stack.tsx +17 -0
  54. package/src/date-inputs.tsx +142 -0
  55. package/src/date-range-content.tsx +379 -0
  56. package/src/date-range-picker.tsx +197 -0
  57. package/src/datetime.ts +58 -0
  58. package/src/index.ts +11 -0
  59. package/src/presets-listbox.tsx +65 -0
  60. package/src/style.scss +72 -0
  61. package/src/test/date-range-picker.test.tsx +334 -0
  62. package/src/test/utils.test.ts +58 -0
  63. package/src/types.d.ts +1 -0
  64. 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
+ }
@@ -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
+ }