Skip to content

Commit 34c19c1

Browse files
lpsingerwerdnanoslenabbyoungbrandonlenz
authored
feat: add DateSelector dateFormat prop (#2726)
Co-authored-by: Andrew Nelson <andy@andyhub.com> Co-authored-by: Abigail Young <6007259+abbyoung@users.noreply.github.com> Co-authored-by: Brandon Lenz <15805554+brandonlenz@users.noreply.github.com>
1 parent 1321aac commit 34c19c1

File tree

5 files changed

+112
-44
lines changed

5 files changed

+112
-44
lines changed

src/components/forms/DatePicker/DatePicker.stories.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,23 @@ import { FormGroup } from '../FormGroup/FormGroup'
77
import { Label } from '../Label/Label'
88
import { TextInput } from '../TextInput/TextInput'
99
import { ValidationStatus } from '../../../types/validationStatus'
10+
import {
11+
DEFAULT_EXTERNAL_DATE_FORMAT,
12+
DateFormat,
13+
INTERNAL_DATE_FORMAT,
14+
} from './constants'
1015

1116
export default {
1217
title: 'Components/Date picker',
1318
component: DatePicker,
1419
argTypes: {
20+
dateFormat: {
21+
control: 'radio',
22+
options: [
23+
DEFAULT_EXTERNAL_DATE_FORMAT as DateFormat,
24+
INTERNAL_DATE_FORMAT as DateFormat,
25+
],
26+
},
1527
onSubmit: { action: 'submitted' },
1628
disabled: { control: { type: 'boolean' } },
1729
validationStatus: {
@@ -50,6 +62,7 @@ We may find that we want to expose props for custom event handlers or even a ref
5062
}
5163

5264
type StorybookArguments = {
65+
dateFormat: DateFormat
5366
onSubmit: React.FormEventHandler<HTMLFormElement>
5467
disabled?: boolean
5568
validationStatus?: ValidationStatus
@@ -66,7 +79,7 @@ export const CompleteDatePicker = {
6679
Appointment date
6780
</Label>
6881
<div className="usa-hint" id="appointment-date-hint">
69-
mm/dd/yyyy
82+
{(argTypes.dateFormat ?? DEFAULT_EXTERNAL_DATE_FORMAT).toLowerCase()}
7083
</div>
7184
<DatePicker
7285
id="appointment-date"
@@ -75,6 +88,7 @@ export const CompleteDatePicker = {
7588
aria-labelledby="appointment-date-label"
7689
disabled={argTypes.disabled}
7790
validationStatus={argTypes.validationStatus}
91+
dateFormat={argTypes.dateFormat}
7892
/>
7993
</FormGroup>
8094
<Label htmlFor="otherInput">Another unrelated input</Label>

src/components/forms/DatePicker/DatePicker.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
DEFAULT_EXTERNAL_DATE_FORMAT,
1414
VALIDATION_MESSAGE,
1515
DEFAULT_MIN_DATE,
16+
type DateFormat,
1617
} from './constants'
1718
import { DatePickerLocalization, EN_US } from './i18n'
1819
import {
@@ -34,6 +35,7 @@ type BaseDatePickerProps = {
3435
validationStatus?: ValidationStatus
3536
disabled?: boolean
3637
required?: boolean
38+
dateFormat?: DateFormat
3739
defaultValue?: string
3840
minDate?: string
3941
maxDate?: string
@@ -58,6 +60,7 @@ export const DatePicker = ({
5860
name,
5961
className,
6062
validationStatus,
63+
dateFormat = DEFAULT_EXTERNAL_DATE_FORMAT,
6164
defaultValue,
6265
disabled,
6366
required,
@@ -93,7 +96,12 @@ export const DatePicker = ({
9396
const parsedRangeDate = rangeDate ? parseDateString(rangeDate) : undefined
9497

9598
const validateInput = (): void => {
96-
const isInvalid = isDateInvalid(externalValue, parsedMinDate, parsedMaxDate)
99+
const isInvalid = isDateInvalid(
100+
externalValue,
101+
dateFormat,
102+
parsedMinDate,
103+
parsedMaxDate
104+
)
97105

98106
if (isInvalid && !externalInputEl?.current?.validationMessage) {
99107
externalInputEl?.current?.setCustomValidity(VALIDATION_MESSAGE)
@@ -109,8 +117,7 @@ export const DatePicker = ({
109117

110118
const handleSelectDate = (dateString: string, closeCalendar = true): void => {
111119
const parsedValue = parseDateString(dateString)
112-
const formattedValue =
113-
parsedValue && formatDate(parsedValue, DEFAULT_EXTERNAL_DATE_FORMAT)
120+
const formattedValue = parsedValue && formatDate(parsedValue, dateFormat)
114121

115122
if (parsedValue) setInternalValue(dateString)
116123
if (formattedValue) setExternalValue(formattedValue)
@@ -129,9 +136,12 @@ export const DatePicker = ({
129136
setExternalValue(value)
130137
if (onChange) onChange(value)
131138

132-
const inputDate = parseDateString(value, DEFAULT_EXTERNAL_DATE_FORMAT, true)
139+
const inputDate = parseDateString(value, dateFormat, true)
133140
let newValue = ''
134-
if (inputDate && !isDateInvalid(value, parsedMinDate, parsedMaxDate)) {
141+
if (
142+
inputDate &&
143+
!isDateInvalid(value, dateFormat, parsedMinDate, parsedMaxDate)
144+
) {
135145
newValue = formatDate(inputDate)
136146
}
137147

@@ -180,11 +190,7 @@ export const DatePicker = ({
180190
setStatuses([])
181191
} else {
182192
// calendar is closed, show it
183-
const inputDate = parseDateString(
184-
externalValue,
185-
DEFAULT_EXTERNAL_DATE_FORMAT,
186-
true
187-
)
193+
const inputDate = parseDateString(externalValue, dateFormat, true)
188194

189195
const displayDate = keepDateBetweenMinAndMax(
190196
inputDate || (defaultValue && parseDateString(defaultValue)) || today(),

src/components/forms/DatePicker/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ export const YEAR_CHUNK = 12
3434
export const DEFAULT_MIN_DATE = '0000-01-01'
3535
export const DEFAULT_EXTERNAL_DATE_FORMAT = 'MM/DD/YYYY'
3636
export const INTERNAL_DATE_FORMAT = 'YYYY-MM-DD'
37+
export type DateFormat =
38+
| typeof INTERNAL_DATE_FORMAT
39+
| typeof DEFAULT_EXTERNAL_DATE_FORMAT

src/components/forms/DatePicker/utils.test.ts

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -81,34 +81,63 @@ describe('formatDate', () => {
8181
})
8282

8383
describe('isDateInvalid', () => {
84-
it('returns false if the date is within the min & max', () => {
85-
const testMin = new Date('May 1, 1988')
86-
const testMax = new Date('June 1, 1988')
87-
expect(isDateInvalid('05/16/1988', testMin, testMax)).toEqual(false)
88-
})
89-
90-
it('returns true if the date is not within the min & max', () => {
91-
const testMin = new Date('May 1, 1988')
92-
const testMax = new Date('June 1, 1988')
93-
expect(isDateInvalid('08/16/1988', testMin, testMax)).toEqual(true)
94-
})
95-
96-
it('returns true if the date is not valid', () => {
97-
const testMin = new Date('May 1, 1988')
98-
const testMax = new Date('June 1, 1988')
99-
expect(isDateInvalid('not a date', testMin, testMax)).toEqual(true)
100-
})
84+
it.each([
85+
['05/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
86+
['1988-05-16', INTERNAL_DATE_FORMAT],
87+
] as const)(
88+
'returns false if the date is within the min & max',
89+
(date, format) => {
90+
const testMin = new Date('May 1, 1988')
91+
const testMax = new Date('June 1, 1988')
92+
expect(isDateInvalid(date, format, testMin, testMax)).toEqual(false)
93+
}
94+
)
95+
96+
it.each([
97+
['08/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
98+
['1988-08-16', INTERNAL_DATE_FORMAT],
99+
] as const)(
100+
'returns true if the date is not within the min & max',
101+
(date, format) => {
102+
const testMin = new Date('May 1, 1988')
103+
const testMax = new Date('June 1, 1988')
104+
expect(isDateInvalid(date, format, testMin, testMax)).toEqual(true)
105+
}
106+
)
107+
108+
it.each([DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT] as const)(
109+
'returns true if the date is not valid',
110+
(format) => {
111+
const testMin = new Date('May 1, 1988')
112+
const testMax = new Date('June 1, 1988')
113+
expect(isDateInvalid('not a date', format, testMin, testMax)).toEqual(
114+
true
115+
)
116+
}
117+
)
101118

102119
describe('with no max date', () => {
103-
it('returns false if the date is after the min', () => {
104-
const testMin = new Date('May 1, 1988')
105-
expect(isDateInvalid('05/16/1988', testMin)).toEqual(false)
106-
})
120+
it.each([
121+
['05/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
122+
['1988-05-16', INTERNAL_DATE_FORMAT],
123+
] as const)(
124+
'returns false if the date is after the min',
125+
(date, format) => {
126+
const testMin = new Date('May 1, 1988')
127+
expect(isDateInvalid(date, format, testMin)).toEqual(false)
128+
}
129+
)
107130

108-
it('returns true if the date is not after the min', () => {
109-
const testMin = new Date('May 1, 1988')
110-
expect(isDateInvalid('02/16/1988', testMin)).toEqual(true)
111-
})
131+
it.each([
132+
['02/16/1988', DEFAULT_EXTERNAL_DATE_FORMAT],
133+
['1988-02-16', INTERNAL_DATE_FORMAT],
134+
] as const)(
135+
'returns true if the date is not after the min',
136+
(date, format) => {
137+
const testMin = new Date('May 1, 1988')
138+
expect(isDateInvalid(date, format, testMin)).toEqual(true)
139+
}
140+
)
112141
})
113142
})
114143

src/components/forms/DatePicker/utils.tsx

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import React, { KeyboardEvent } from 'react'
22

3-
import { DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT } from './constants'
3+
import {
4+
type DateFormat,
5+
DEFAULT_EXTERNAL_DATE_FORMAT,
6+
INTERNAL_DATE_FORMAT,
7+
} from './constants'
48

59
/**
610
* This file contains the USWDS DatePicker date manipulation functions converted to TypeScript
@@ -355,13 +359,13 @@ export const isDatesYearOutsideMinOrMax = (
355359
* Parse a date with format M-D-YY
356360
*
357361
* @param {string} dateString the date string to parse
358-
* @param {string} dateFormat the format of the date string
362+
* @param {DateFormat} dateFormat the format of the date string
359363
* @param {boolean} adjustDate should the date be adjusted
360364
* @returns {Date} the parsed date
361365
*/
362366
export const parseDateString = (
363367
dateString: string,
364-
dateFormat: string = INTERNAL_DATE_FORMAT,
368+
dateFormat: DateFormat = INTERNAL_DATE_FORMAT,
365369
adjustDate = false
366370
): Date | undefined => {
367371
let date
@@ -430,12 +434,12 @@ export const parseDateString = (
430434
* Format a date to format YYYY-MM-DD
431435
*
432436
* @param {Date} date the date to format
433-
* @param {string} dateFormat the format of the date string
437+
* @param {DateFormat} dateFormat the format of the date string
434438
* @returns {string} the formatted date string
435439
*/
436440
export const formatDate = (
437441
date: Date,
438-
dateFormat: string = INTERNAL_DATE_FORMAT
442+
dateFormat: DateFormat = INTERNAL_DATE_FORMAT
439443
): string => {
440444
const padZeros = (value: number, length: number): string => {
441445
return `0000${value}`.slice(-length)
@@ -456,6 +460,7 @@ export const formatDate = (
456460

457461
export const isDateInvalid = (
458462
dateString: string,
463+
dateFormat: DateFormat,
459464
minDate: Date,
460465
maxDate?: Date
461466
): boolean => {
@@ -464,22 +469,33 @@ export const isDateInvalid = (
464469
if (dateString) {
465470
isInvalid = true
466471

467-
const dateStringParts = dateString.split('/')
468-
const [month, day, year] = dateStringParts.map((str) => {
472+
const dateStringParts = dateString.split(
473+
dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT ? '/' : '-'
474+
)
475+
const dateParts = dateStringParts.map((str) => {
469476
let value
470477
const parsed = parseInt(str, 10)
471478
if (!Number.isNaN(parsed)) value = parsed
472479
return value
473480
})
474481

482+
let month, day, year, yearStringPart
483+
if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) {
484+
yearStringPart = dateStringParts[2]
485+
;[month, day, year] = dateParts
486+
} else {
487+
yearStringPart = dateStringParts[0]
488+
;[year, month, day] = dateParts
489+
}
490+
475491
if (month && day && year != null) {
476492
const checkDate = setDate(year, month - 1, day)
477493

478494
if (
479495
checkDate.getMonth() === month - 1 &&
480496
checkDate.getDate() === day &&
481497
checkDate.getFullYear() === year &&
482-
dateStringParts[2].length === 4 &&
498+
yearStringPart.length === 4 &&
483499
isDateWithinMinAndMax(checkDate, minDate, maxDate)
484500
) {
485501
isInvalid = false

0 commit comments

Comments
 (0)