Skip to content

Commit

Permalink
[WNMGDS-1627] Single-input date field (#1876)
Browse files Browse the repository at this point in the history
* Pull date field changes from original branch

* Hook up the onChange function

* Working on refining the styles

* Implement grey border between that disappears when focusing

so our focus styles are 👌

* Style the border, shadow, position, and spacing for the calendar picker

* Add some default translations

* Handle picker changes

* Add some commented code borrowed from the Tooltip

It would be nicer to be able to use the NativeDialog component for this so we don't have to create a listener for clicking outside the component.

* Handle clicking outside and pressing escape with hooks

* Remove old DateField files

* Refactor useLabelMask to take input props instead of input element

for consistency and simplicity

* Validate date string before passing to DayPicker and remember the month

In order to make it open to the currently selected month, we have to set the defaultMonth to the selected date. DayPicker uses internal state to track the current month, but we clear all that state when we stop rendering the picker (because we hide it)

* Fix focus bug in useLabelMask

* Don't export this new component yet

* Add doc comments for the `useClickOutsideHandler`

* Make `onChange` required because we treat it thusly
  • Loading branch information
pwolfert authored Jun 1, 2022
1 parent 7b8af0e commit eb357d9
Show file tree
Hide file tree
Showing 22 changed files with 775 additions and 81 deletions.
2 changes: 2 additions & 0 deletions packages/design-system/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@
"@types/react-dom": "^17.0.10",
"classnames": "^2.2.5",
"core-js": "^3.6.5",
"date-fns": "^2.28.0",
"downshift": "^3.2.10",
"ev-emitter": "^2.1.2",
"focus-trap-react": "^6.0.0",
"lodash": "^4.17.21",
"prop-types": "^15.7.2",
"react-aria-modal": "^2.11.1",
"react-day-picker": "^8.0.5",
"react-transition-group": "^4.4.2"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import DateField from './DateField';
import MultiInputDateField from './MultiInputDateField';
import DateInput from './DateInput';

export default {
title: 'Components/DateField',
component: DateField,
title: 'Components/MultiInputDateField',
component: MultiInputDateField,
argTypes: {
label: { control: false },
errorMessage: {
Expand All @@ -15,12 +15,12 @@ export default {
subcomponents: { DateInput },
};

const Template = ({ ...args }) => <DateField {...args} />;
const Template = ({ ...args }) => <MultiInputDateField {...args} />;
const ControlledTemplate = ({ ...args }) => {
const [dateState, setDateState] = useState({ month: '10', day: '30', year: '1980' });

return (
<DateField
<MultiInputDateField
{...args}
label={
<span>
Expand All @@ -36,26 +36,26 @@ const ControlledTemplate = ({ ...args }) => {
);
};

export const DateFieldDefault = Template.bind({});
DateFieldDefault.args = {
export const MultiInputDateFieldDefault = Template.bind({});
MultiInputDateFieldDefault.args = {
errorMessage: 'Please enter a year in the past',
monthDefaultValue: '10',
dayDefaultValue: '31',
yearDefaultValue: '2050',
yearInvalid: true,
};

export const ControlledDateField = ControlledTemplate.bind({});
export const ControlledMultiInputDateField = ControlledTemplate.bind({});

export const InvertedDateField = Template.bind({});
InvertedDateField.args = {
export const InvertedMultiInputDateField = Template.bind({});
InvertedMultiInputDateField.args = {
errorMessage: 'Please enter a year in the past',
monthDefaultValue: '10',
dayDefaultValue: '31',
yearDefaultValue: '2050',
yearInvalid: true,
inversed: true,
};
InvertedDateField.parameters = {
InvertedMultiInputDateField.parameters = {
backgrounds: { default: process.env.STORYBOOK_DS === 'medicare' ? 'Mgov dark' : 'Hcgov dark' },
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
jest.mock('lodash/uniqueId', () => (str) => `${str}snapshot`);
import { DateField } from './DateField';
import { MultiInputDateField } from './MultiInputDateField';
import React from 'react';
import defaultDateFormatter from './defaultDateFormatter';
import renderer from 'react-test-renderer';

describe('DateField', () => {
describe('MultiInputDateField', () => {
it('renders', () => {
expect(renderer.create(<DateField />)).toMatchSnapshot();
expect(renderer.create(<MultiInputDateField />)).toMatchSnapshot();
});

describe('defaultDateFormatter', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { FormControl, FormControlPropKeys } from '../FormControl/FormControl';
import { FormControl, FormControlProps, FormControlPropKeys } from '../FormControl/FormControl';
import DateInput from './DateInput';
import React from 'react';
import defaultDateFormatter from './defaultDateFormatter';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import { FormFieldProps, FormLabel, useFormLabel } from '../FormLabel';
import { t } from '../i18n';

export type DateFieldDayDefaultValue = string | number;
Expand All @@ -14,15 +15,11 @@ export type DateFieldYearDefaultValue = string | number;
export type DateFieldYearValue = string | number;
export type DateFieldErrorPlacement = 'top' | 'bottom';

export interface DateFieldProps {
export interface DateFieldProps extends Omit<FormFieldProps, 'label'> {
/**
* Adds `autocomplete` attributes `bday-day`, `bday-month` and `bday-year` to the corresponding `<DateField>` inputs
* Adds `autocomplete` attributes `bday-day`, `bday-month` and `bday-year` to the corresponding `<MultiInputDateField>` inputs
*/
autoComplete?: boolean;
/**
* Additional classes to be added to the root fieldset element
*/
className?: string;
/**
* Optional method to format the `input` field values. If this
* method is provided, the returned value will be passed as a second argument
Expand All @@ -32,30 +29,12 @@ export interface DateFieldProps {
* By default `dateFormatter` will be set to the `defaultDateFormatter` function, which prevents days/months more than 2 digits & years more than 4 digits.
*/
dateFormatter?: (...args: any[]) => any;
disabled?: boolean;
errorMessage?: React.ReactNode;
/**
* Additional classes to be added to the error message
*/
errorMessageClassName?: string;
/**
* Location of the error message relative to the field input
*/
errorPlacement?: DateFieldErrorPlacement;
/**
* Additional hint text to display above the individual month/day/year fields
*/
hint?: React.ReactNode;
/**
* Applies the "inverse" UI theme
*/
inversed?: boolean;
/**
* The primary label, rendered above the individual month/day/year fields
*/
label?: React.ReactNode;
/**
* A unique ID to be used for the DateField label. If one isn't provided, a unique ID will be generated.
* A unique ID to be used for the MultiInputDateField label. If one isn't provided, a unique ID will be generated.
*/
labelId?: string;
/**
Expand Down Expand Up @@ -156,29 +135,29 @@ export interface DateFieldProps {
yearValue?: DateFieldYearValue;
}

export function DateField(props: DateFieldProps): React.ReactElement {
const containerProps = pick(props, FormControlPropKeys);
const inputOnlyProps = omit(props, FormControlPropKeys);
export function MultiInputDateField(props: DateFieldProps): React.ReactElement {
const { labelProps, fieldProps, wrapperProps, bottomError } = useFormLabel({
label: t('dateField.label'),
hint: t('dateField.hint'),
dayName: 'day',
monthName: 'month',
yearName: 'year',
dateFormatter: defaultDateFormatter,
...props,
labelComponent: 'legend',
wrapperIsFieldset: true,
});

// Throw away the properties we don't need by destructuring
const { id, errorId, ...inputProps } = fieldProps;

return (
<FormControl
label={t('dateField.label')}
hint={t('dateField.hint')}
{...containerProps}
component="fieldset"
labelComponent="legend"
render={({ labelId }) => (
<DateInput {...inputOnlyProps} {...{ labelId }} inversed={props.inversed} />
)}
/>
<fieldset {...wrapperProps}>
<FormLabel {...labelProps} />
<DateInput {...inputProps} labelId={labelProps.id} />
{bottomError}
</fieldset>
);
}

DateField.defaultProps = {
dayName: 'day',
monthName: 'month',
yearName: 'year',
dateFormatter: defaultDateFormatter,
};

export default DateField;
export default MultiInputDateField;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { useState } from 'react';
import SingleInputDateField from './SingleInputDateField';

export default {
title: 'Components/SingleInputDateField',
component: SingleInputDateField,
argTypes: {
errorMessage: {
control: { type: 'text' },
},
hint: {
control: { type: 'text' },
},
label: {
control: { type: 'text' },
},
requirementLabel: {
control: { type: 'text' },
},
},
args: {
label: 'Birthday',
hint: 'Please enter your birthday',
name: 'single-input-date-field',
},
};

const Template = ({ ...args }) => {
const [dateString, setDateString] = useState('');
return <SingleInputDateField {...args} value={dateString} onChange={setDateString} />;
};

export const Default = Template.bind({});

export const WithPicker = Template.bind({});
WithPicker.args = {
fromYear: new Date().getFullYear(),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React, { useRef, useState } from 'react';
import CalendarIcon from '../Icons/CalendarIcon';
import classNames from 'classnames';
import isMatch from 'date-fns/isMatch';
import useLabelMask from '../TextField/useLabelMask';
import useClickOutsideHandler from '../utilities/useClickOutsideHandler';
import usePressEscapeHandler from '../utilities/usePressEscapeHandler';
import { DayPicker } from 'react-day-picker';
import { DATE_MASK, RE_DATE } from '../TextField/useLabelMask';
import { FormFieldProps, FormLabel, useFormLabel } from '../FormLabel';
import { TextInput } from '../TextField';

export interface SingleInputDateFieldProps extends FormFieldProps {
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => any;
onChange: (updatedValue: string, maskedValue: string) => any;
value?: string;
name: string;

// From DayPicker
// -------------------------
defaultMonth?: Date;
fromDate?: Date;
fromMonth?: Date;
fromYear?: number;
toDate?: Date;
toMonth?: Date;
toYear?: number;
}

const SingleInputDateField = (props: SingleInputDateFieldProps) => {
const {
className,
onChange,
defaultMonth,
fromDate,
fromMonth,
fromYear,
toDate,
toMonth,
toYear,
...remainingProps
} = props;
const withPicker = fromDate || fromMonth || fromYear;
const [pickerVisible, setPickerVisible] = useState(false);

function handleInputChange(event) {
const updatedValue = event.currentTarget.value;
onChange(updatedValue, DATE_MASK(updatedValue, true));
}

const { labelProps, fieldProps, wrapperProps, bottomError } = useFormLabel({
...remainingProps,
className: classNames(
'ds-c-single-input-date-field',
{ 'ds-c-single-input-date-field--with-picker': withPicker },
className
),
labelComponent: 'label',
wrapperIsFieldset: false,
});
const { labelMask, inputProps } = useLabelMask(DATE_MASK, {
...fieldProps,
onChange: handleInputChange,
type: 'text',
});

function handlePickerChange(date: Date) {
const updatedValue = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
onChange(DATE_MASK(updatedValue), DATE_MASK(updatedValue, true));
setPickerVisible(false);
}

const dayPickerRef = useRef();
const calendarButtonRef = useRef();
useClickOutsideHandler([dayPickerRef, calendarButtonRef], () => setPickerVisible(false));
usePressEscapeHandler(dayPickerRef, () => setPickerVisible(false));

// Validate the date string (value) and make date null if it's invalid. We don't want to pass
// a bizarre date to DayPicker like `new Date('01/02')`, which is interpreted as `Jan 02, 2001`
const dateString = DATE_MASK(props.value, true);
const validDateString = isMatch(dateString, 'MM/dd/yyyy');
const date = validDateString ? new Date(dateString) : null;

return (
<div {...wrapperProps}>
<FormLabel {...labelProps} />
{labelMask}
<div className="ds-c-single-input-date-field__field-wrapper">
<TextInput {...inputProps} />
{withPicker && (
<button
className="ds-c-single-input-date-field__button"
onClick={() => setPickerVisible(!pickerVisible)}
ref={calendarButtonRef}
>
<CalendarIcon ariaHidden={false} />
</button>
)}
</div>
{pickerVisible && (
<div ref={dayPickerRef}>
<DayPicker
mode="single"
selected={date}
defaultMonth={date}
onSelect={handlePickerChange}
/>
</div>
)}
{bottomError}
</div>
);
};

export default SingleInputDateField;
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`DateField renders 1`] = `
exports[`MultiInputDateField renders 1`] = `
<fieldset
className="ds-c-fieldset"
>
Expand Down
5 changes: 4 additions & 1 deletion packages/design-system/src/components/DateField/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export { default as DateField } from './DateField';
export * from './MultiInputDateField';
// export * from './SingleInputDateField';
// Alias the MultiInputDateField as its old name
export { default as DateField } from './MultiInputDateField';
export { default as DateInput } from './DateInput';
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export const FormControl = (props: FormControlProps) => {
// TODO: Use React Context to provide shared form props like `errorPlacement`, `inversed`, `fieldId`
const fieldInputProps = {
...fieldProps,
labelId: labelProps.id,
setRef,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ export function useFormLabel<T extends UseFormLabelProps>(props: T) {
const fieldProps = {
...remainingProps,
id,
labelId,
errorId,
inversed,
};
Expand Down
Loading

0 comments on commit eb357d9

Please sign in to comment.