diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 39d7cfb81d..68e38c0b84 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -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": { diff --git a/packages/design-system/src/components/DateField/DateField.a11y.test.tsx b/packages/design-system/src/components/DateField/MultiInputDateField.a11y.test.tsx similarity index 100% rename from packages/design-system/src/components/DateField/DateField.a11y.test.tsx rename to packages/design-system/src/components/DateField/MultiInputDateField.a11y.test.tsx diff --git a/packages/design-system/src/components/DateField/DateField.stories.jsx b/packages/design-system/src/components/DateField/MultiInputDateField.stories.jsx similarity index 69% rename from packages/design-system/src/components/DateField/DateField.stories.jsx rename to packages/design-system/src/components/DateField/MultiInputDateField.stories.jsx index dd981674d1..a8318835c1 100644 --- a/packages/design-system/src/components/DateField/DateField.stories.jsx +++ b/packages/design-system/src/components/DateField/MultiInputDateField.stories.jsx @@ -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: { @@ -15,12 +15,12 @@ export default { subcomponents: { DateInput }, }; -const Template = ({ ...args }) => ; +const Template = ({ ...args }) => ; const ControlledTemplate = ({ ...args }) => { const [dateState, setDateState] = useState({ month: '10', day: '30', year: '1980' }); return ( - @@ -36,8 +36,8 @@ 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', @@ -45,10 +45,10 @@ DateFieldDefault.args = { 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', @@ -56,6 +56,6 @@ InvertedDateField.args = { yearInvalid: true, inversed: true, }; -InvertedDateField.parameters = { +InvertedMultiInputDateField.parameters = { backgrounds: { default: process.env.STORYBOOK_DS === 'medicare' ? 'Mgov dark' : 'Hcgov dark' }, }; diff --git a/packages/design-system/src/components/DateField/DateField.test.tsx b/packages/design-system/src/components/DateField/MultiInputDateField.test.tsx similarity index 87% rename from packages/design-system/src/components/DateField/DateField.test.tsx rename to packages/design-system/src/components/DateField/MultiInputDateField.test.tsx index 5faacc05ce..535b6e46aa 100644 --- a/packages/design-system/src/components/DateField/DateField.test.tsx +++ b/packages/design-system/src/components/DateField/MultiInputDateField.test.tsx @@ -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()).toMatchSnapshot(); + expect(renderer.create()).toMatchSnapshot(); }); describe('defaultDateFormatter', () => { diff --git a/packages/design-system/src/components/DateField/DateField.tsx b/packages/design-system/src/components/DateField/MultiInputDateField.tsx similarity index 73% rename from packages/design-system/src/components/DateField/DateField.tsx rename to packages/design-system/src/components/DateField/MultiInputDateField.tsx index 0507612370..b91147f14a 100644 --- a/packages/design-system/src/components/DateField/DateField.tsx +++ b/packages/design-system/src/components/DateField/MultiInputDateField.tsx @@ -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; @@ -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 { /** - * Adds `autocomplete` attributes `bday-day`, `bday-month` and `bday-year` to the corresponding `` inputs + * Adds `autocomplete` attributes `bday-day`, `bday-month` and `bday-year` to the corresponding `` 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 @@ -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; /** @@ -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 ( - ( - - )} - /> +
+ + + {bottomError} +
); } -DateField.defaultProps = { - dayName: 'day', - monthName: 'month', - yearName: 'year', - dateFormatter: defaultDateFormatter, -}; - -export default DateField; +export default MultiInputDateField; diff --git a/packages/design-system/src/components/DateField/SingleInputDateField.stories.jsx b/packages/design-system/src/components/DateField/SingleInputDateField.stories.jsx new file mode 100644 index 0000000000..e645ca05a6 --- /dev/null +++ b/packages/design-system/src/components/DateField/SingleInputDateField.stories.jsx @@ -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 ; +}; + +export const Default = Template.bind({}); + +export const WithPicker = Template.bind({}); +WithPicker.args = { + fromYear: new Date().getFullYear(), +}; diff --git a/packages/design-system/src/components/DateField/SingleInputDateField.tsx b/packages/design-system/src/components/DateField/SingleInputDateField.tsx new file mode 100644 index 0000000000..ad00741645 --- /dev/null +++ b/packages/design-system/src/components/DateField/SingleInputDateField.tsx @@ -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) => 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 ( +
+ + {labelMask} +
+ + {withPicker && ( + + )} +
+ {pickerVisible && ( +
+ +
+ )} + {bottomError} +
+ ); +}; + +export default SingleInputDateField; diff --git a/packages/design-system/src/components/DateField/__snapshots__/DateField.test.tsx.snap b/packages/design-system/src/components/DateField/__snapshots__/MultiInputDateField.test.tsx.snap similarity index 98% rename from packages/design-system/src/components/DateField/__snapshots__/DateField.test.tsx.snap rename to packages/design-system/src/components/DateField/__snapshots__/MultiInputDateField.test.tsx.snap index 5d02b8f01e..44ead8bbc9 100644 --- a/packages/design-system/src/components/DateField/__snapshots__/DateField.test.tsx.snap +++ b/packages/design-system/src/components/DateField/__snapshots__/MultiInputDateField.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`DateField renders 1`] = ` +exports[`MultiInputDateField renders 1`] = `
diff --git a/packages/design-system/src/components/DateField/index.ts b/packages/design-system/src/components/DateField/index.ts index f783a08bf3..26e179068f 100644 --- a/packages/design-system/src/components/DateField/index.ts +++ b/packages/design-system/src/components/DateField/index.ts @@ -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'; diff --git a/packages/design-system/src/components/FormControl/FormControl.tsx b/packages/design-system/src/components/FormControl/FormControl.tsx index 489ac8fbe0..0f02b0a492 100644 --- a/packages/design-system/src/components/FormControl/FormControl.tsx +++ b/packages/design-system/src/components/FormControl/FormControl.tsx @@ -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, }; diff --git a/packages/design-system/src/components/FormLabel/useFormLabel.tsx b/packages/design-system/src/components/FormLabel/useFormLabel.tsx index 5cbf929495..3899220d38 100644 --- a/packages/design-system/src/components/FormLabel/useFormLabel.tsx +++ b/packages/design-system/src/components/FormLabel/useFormLabel.tsx @@ -120,7 +120,6 @@ export function useFormLabel(props: T) { const fieldProps = { ...remainingProps, id, - labelId, errorId, inversed, }; diff --git a/packages/design-system/src/components/Icons/CalendarIcon.tsx b/packages/design-system/src/components/Icons/CalendarIcon.tsx new file mode 100644 index 0000000000..df4b82816d --- /dev/null +++ b/packages/design-system/src/components/Icons/CalendarIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { t } from '../i18n'; +import SvgIcon, { IconCommonProps } from './SvgIcon'; + +const defaultProps = { + className: '', + viewBox: '0 0 448 512', +}; + +function CalendarIcon(props: IconCommonProps): React.ReactElement { + const iconCssClasses = `ds-c-icon--calendar ${props.className || ''}`; + + return ( + + + + ); +} + +export default CalendarIcon; diff --git a/packages/design-system/src/components/TextField/LabelMask.tsx b/packages/design-system/src/components/TextField/LabelMask.tsx index 18a5a3d467..6aeb7078d7 100644 --- a/packages/design-system/src/components/TextField/LabelMask.tsx +++ b/packages/design-system/src/components/TextField/LabelMask.tsx @@ -18,7 +18,8 @@ export interface LabelMaskProps { const LabelMask = (props: LabelMaskProps) => { const field = React.Children.only(props.children as React.ReactElement); - const { labelMask, input } = useLabelMask(props.labelMask, field); + const { labelMask, inputProps } = useLabelMask(props.labelMask, field.props); + const input = React.cloneElement(field, inputProps); return ( <> diff --git a/packages/design-system/src/components/TextField/useLabelMask.test.tsx b/packages/design-system/src/components/TextField/useLabelMask.test.tsx index cc72b6be7e..a68cf38d09 100644 --- a/packages/design-system/src/components/TextField/useLabelMask.test.tsx +++ b/packages/design-system/src/components/TextField/useLabelMask.test.tsx @@ -47,31 +47,31 @@ describe('DATE_MASK', () => { describe('useLabelMask', () => { const defaultInputProps = { type: 'text', value: '' }; - function getInput(props = {}) { - return ; + function renderInput(props = {}) { + return render(); } function renderUseLabelMask(inputProps = {}) { - return renderHook(() => useLabelMask(DATE_MASK, getInput(inputProps))); + return renderHook(() => useLabelMask(DATE_MASK, { ...defaultInputProps, ...inputProps })); } it('returns labelMask and input elements', () => { const { result } = renderUseLabelMask(); - const { labelMask, input } = result.current; + const { labelMask, inputProps } = result.current; expect(render(labelMask).asFragment()).toMatchSnapshot(); - expect(render(input).asFragment()).toMatchSnapshot(); + expect(renderInput(inputProps).asFragment()).toMatchSnapshot(); }); it('masks the value when focused', () => { const { result } = renderUseLabelMask({ value: '12' }); - render(result.current.input); + renderInput(result.current.inputProps); userEvent.click(screen.getByRole('textbox')); expect(render(result.current.labelMask).container.textContent).toMatchSnapshot(); }); it('shows unfilled mask when not focused', () => { const { result } = renderUseLabelMask({ value: '12250001' }); - render(result.current.input); + renderInput(result.current.inputProps); userEvent.click(screen.getByRole('textbox')); userEvent.tab(); expect(render(result.current.labelMask).container.textContent).toMatchSnapshot(); diff --git a/packages/design-system/src/components/TextField/useLabelMask.tsx b/packages/design-system/src/components/TextField/useLabelMask.tsx index 654e2f8a04..f93af1dee8 100644 --- a/packages/design-system/src/components/TextField/useLabelMask.tsx +++ b/packages/design-system/src/components/TextField/useLabelMask.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { TextInputProps } from './TextInput'; export type MaskFunction = (rawInput: string, valueOnly?: boolean) => string; @@ -36,11 +37,13 @@ export const DATE_MASK: MaskFunction = (rawInput = '', valueOnly = false) => { return formattedDate + hintSub; }; -export function useLabelMask(maskFn: MaskFunction, inputEl: React.ReactElement) { +export function useLabelMask(maskFn: MaskFunction, originalInputProps: TextInputProps) { const [focused, setFocused] = useState(false); - const { onFocus, onBlur, onChange, value } = inputEl.props; + const { onFocus, onBlur, onChange } = originalInputProps; + const value = originalInputProps.value?.toString() ?? ''; - const modifiedInputEl = React.cloneElement(inputEl, { + const inputProps = { + ...originalInputProps, defaultValue: undefined, onFocus: (e) => { if (onFocus) { @@ -50,23 +53,23 @@ export function useLabelMask(maskFn: MaskFunction, inputEl: React.ReactElement) setFocused(true); }, onBlur: (e) => { - const maskedValue = maskFn(value, true); + const maskedValue = maskFn(value.toString(), true); e.currentTarget.value = maskedValue; e.target.value = maskedValue; if (onChange) { - onChange(e); + (onChange as any)(e); } if (onBlur) { - return onBlur(e); + (onBlur as any)(e); } setFocused(false); }, type: 'text', - inputMode: 'numeric', - }); + inputMode: 'numeric' as const, + }; return { labelMask: ( @@ -74,7 +77,7 @@ export function useLabelMask(maskFn: MaskFunction, inputEl: React.ReactElement) {maskFn(focused ? value : '')} ), - input: modifiedInputEl, + inputProps, }; } diff --git a/packages/design-system/src/components/locale/en.json b/packages/design-system/src/components/locale/en.json index 6f86d180e7..457e638868 100644 --- a/packages/design-system/src/components/locale/en.json +++ b/packages/design-system/src/components/locale/en.json @@ -44,6 +44,7 @@ "add": "Add", "alertCircle": "Alert", "arrowsStacked": "Sort", + "calendar": "Calendar", "upArrow": "up arrow", "downArrow": "down arrow", "leftArrow": "left arrow", diff --git a/packages/design-system/src/components/locale/es.json b/packages/design-system/src/components/locale/es.json index 2eed904143..526d460e54 100644 --- a/packages/design-system/src/components/locale/es.json +++ b/packages/design-system/src/components/locale/es.json @@ -44,6 +44,7 @@ "add": "Añadir", "alertCircle": "Alerta", "arrowsStacked": "Ordenar", + "calendar": "Calendario", "upArrow": "flecha hacía arriba", "downArrow": "flecha hacía abajo", "leftArrow": "flecha a la izquierda", diff --git a/packages/design-system/src/components/utilities/useClickOutsideHandler.ts b/packages/design-system/src/components/utilities/useClickOutsideHandler.ts new file mode 100644 index 0000000000..9139d23cd4 --- /dev/null +++ b/packages/design-system/src/components/utilities/useClickOutsideHandler.ts @@ -0,0 +1,36 @@ +import { RefObject, useEffect } from 'react'; + +type ClickOutsideEvent = MouseEvent | TouchEvent; + +/** + * Listens for mouse and touch events on the document and calls the provided + * handler function as long as the event did not originate in one of the + * elements corresponding to the React Refs in the `insideRefs` array. For + * example, if you want to know if the user clicked outside of a dialog or + * its trigger, you can pass it an array containing the refs to the dialog + * and the trigger. + * + * @param insideRefs - Refs to elements that are considered "inside" + * @param handler - called when the event target was outside the "inside" elements + */ +export function useClickOutsideHandler( + insideRefs: RefObject[], + handler: (e: ClickOutsideEvent) => any +) { + function handleClickOutside(event: ClickOutsideEvent) { + if (!insideRefs.some((ref) => ref.current?.contains(event.target as HTMLElement))) { + handler(event); + } + } + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + }; + }, [handleClickOutside]); +} + +export default useClickOutsideHandler; diff --git a/packages/design-system/src/components/utilities/usePressEscapeHandler.ts b/packages/design-system/src/components/utilities/usePressEscapeHandler.ts new file mode 100644 index 0000000000..3c524529fc --- /dev/null +++ b/packages/design-system/src/components/utilities/usePressEscapeHandler.ts @@ -0,0 +1,32 @@ +import { RefObject, useEffect } from 'react'; + +/** + * Calls a handler whenever the Escape key is pressed within an element. To capture + * on the entire document, pass null or undefined for the ref. + * + * @param ref - Ref of the element to capture keystrokes in. Defaults to the document + * @param handler - Function called if the escape key is pressed + */ +export function usePressEscapeHandler( + ref: RefObject | null | undefined, + handler: (e: KeyboardEvent) => any +) { + function handleEscapeKey(event: KeyboardEvent) { + const ESCAPE_KEY = 27; + if (event.keyCode === ESCAPE_KEY || event.key === 'Escape') { + handler(event); + } + } + + useEffect(() => { + const node = ref ? ref.current : document; + if (!node) return; + + node.addEventListener('keydown', handleEscapeKey); + return () => { + node.removeEventListener('keydown', handleEscapeKey); + }; + }, [handleEscapeKey]); +} + +export default usePressEscapeHandler; diff --git a/packages/design-system/src/styles/components/_SingleInputDateField.scss b/packages/design-system/src/styles/components/_SingleInputDateField.scss new file mode 100644 index 0000000000..70c32b64fc --- /dev/null +++ b/packages/design-system/src/styles/components/_SingleInputDateField.scss @@ -0,0 +1,429 @@ +@import '../settings/index.scss'; + +// Because import of stylesheets in node_modules would have to be supported by +// downstream projects--and we have no guarantee that it will--it is necessary +// for us to define the react-day-picker styles in our own stylesheets. The +// risk is that if we make changes, they are not guaranteed to work for future +// versions of this library. To mitigate this risk, we should avoid modifying +// the source rules. +// @import 'react-day-picker/dist/style.css'; + +:root { + --rdp-cell-size: 40px; + --rdp-accent-color: #{$color-primary}; + --rdp-background-color: #e6f1f8; // TODO: use the token ocean-50 + /* Added this variable for consistency */ + --rdp-active-color: #{$color-primary-darkest}; + /* Outline border for focused elements */ + --rdp-outline: none; + /* Outline border for focused and selected elements */ + --rdp-outline-selected: none; +} + +/******************************************************************************* + * * + * Start of react-day-picker source styles * + * (Avoid modifying. See comment above.) * + * * + *******************************************************************************/ + +.rdp { + margin: 1em; +} + +/* Hide elements for devices that are not screen readers */ +.rdp-vhidden { + box-sizing: border-box; + padding: 0; + margin: 0; + background: transparent; + border: 0; + -moz-appearance: none; + -webkit-appearance: none; + appearance: none; + position: absolute !important; + top: 0; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(1px, 1px, 1px, 1px) !important; + border: 0 !important; +} + +/* Buttons */ +.rdp-button_reset { + appearance: none; + position: relative; + margin: 0; + padding: 0; + cursor: default; + color: inherit; + outline: none; + background: none; + font: inherit; + + -moz-appearance: none; + -webkit-appearance: none; +} + +.rdp-button { + border: 2px solid transparent; +} + +.rdp-button[aria-disabled='true'] { + opacity: 0.25; + pointer-events: none; +} + +.rdp-button:not([aria-disabled='true']) { + cursor: pointer; +} + +.rdp-button:focus, +.rdp-button:active { + color: inherit; + border: var(--rdp-outline); + background-color: var(--rdp-background-color); +} + +.rdp-button:hover:not([aria-disabled='true']) { + background-color: var(--rdp-background-color); +} + +.rdp-months { + display: flex; +} + +.rdp-month { + margin: 0 1em; +} + +.rdp-month:first-child { + margin-left: 0; +} + +.rdp-month:last-child { + margin-right: 0; +} + +.rdp-table { + margin: 0; + max-width: calc(var(--rdp-cell-size) * 7); + border-collapse: collapse; +} + +.rdp-with_weeknumber .rdp-table { + max-width: calc(var(--rdp-cell-size) * 8); + border-collapse: collapse; +} + +.rdp-caption { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0; + text-align: left; +} + +.rdp-multiple_months .rdp-caption { + position: relative; + display: block; + text-align: center; +} + +.rdp-caption_dropdowns { + position: relative; + display: inline-flex; +} + +.rdp-caption_label { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + margin: 0; + padding: 0 0.25em; + white-space: nowrap; + color: currentColor; + border: 0; + border: 2px solid transparent; + font-family: inherit; + font-size: 140%; + font-weight: bold; +} + +.rdp-nav { + white-space: nowrap; +} + +.rdp-multiple_months .rdp-caption_start .rdp-nav { + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); +} + +.rdp-multiple_months .rdp-caption_end .rdp-nav { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); +} + +.rdp-nav_button { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--rdp-cell-size); + height: var(--rdp-cell-size); + padding: 0.25em; + border-radius: 100%; +} + +/* ---------- */ +/* Dropdowns */ +/* ---------- */ + +.rdp-dropdown_year, +.rdp-dropdown_month { + position: relative; + display: inline-flex; + align-items: center; +} + +.rdp-dropdown { + appearance: none; + position: absolute; + z-index: 2; + top: 0; + bottom: 0; + left: 0; + width: 100%; + margin: 0; + padding: 0; + cursor: inherit; + opacity: 0; + border: none; + background-color: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +.rdp-dropdown[disabled] { + opacity: unset; + color: unset; +} + +.rdp-dropdown:focus:not([disabled]) + .rdp-caption_label, +.rdp-dropdown:active:not([disabled]) + .rdp-caption_label { + border: var(--rdp-outline); + border-radius: 6px; + background-color: var(--rdp-background-color); +} + +.rdp-dropdown_icon { + margin: 0 0 0 5px; +} + +.rdp-head { + border: 0; +} + +.rdp-head_row, +.rdp-row { + height: 100%; +} + +.rdp-head_cell { + vertical-align: middle; + text-transform: uppercase; + font-size: 0.75em; + font-weight: 700; + text-align: center; + height: 100%; + height: var(--rdp-cell-size); + padding: 0; +} + +.rdp-tbody { + border: 0; +} + +.rdp-tfoot { + margin: 0.5em; +} + +.rdp-cell { + width: var(--rdp-cell-size); + height: 100%; + height: var(--rdp-cell-size); + padding: 0; + text-align: center; +} + +.rdp-weeknumber { + font-size: 0.75em; +} + +.rdp-weeknumber, +.rdp-day { + display: flex; + overflow: hidden; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: var(--rdp-cell-size); + max-width: var(--rdp-cell-size); + height: var(--rdp-cell-size); + margin: 0; + border: 2px solid transparent; + border-radius: 100%; +} + +.rdp-day_today:not(.rdp-day_outside) { + font-weight: bold; +} + +.rdp-day_selected:not([aria-disabled='true']), +.rdp-day_selected:focus:not([aria-disabled='true']), +.rdp-day_selected:active:not([aria-disabled='true']), +.rdp-day_selected:hover:not([aria-disabled='true']) { + color: white; + background-color: var(--rdp-accent-color); +} + +.rdp-day_selected:focus:not([aria-disabled='true']) { + border: var(--rdp-outline-selected); +} + +.rdp:not([dir='rtl']) .rdp-day_range_start:not(.rdp-day_range_end) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.rdp:not([dir='rtl']) .rdp-day_range_end:not(.rdp-day_range_start) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.rdp[dir='rtl'] .rdp-day_range_start:not(.rdp-day_range_end) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.rdp[dir='rtl'] .rdp-day_range_end:not(.rdp-day_range_start) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.rdp-day_range_end.rdp-day_range_start { + border-radius: 100%; +} + +.rdp-day_range_middle { + border-radius: 0; +} + +/******************************************************************************* + * * + * End of react-day-picker source styles * + * (Avoid modifying. See comment above.) * + * * + *******************************************************************************/ + +.rdp-button:focus, +.rdp-button:active { + @include focus-styles; +} + +.rdp-button:active, +.rdp-button:active:hover:not([aria-disabled='true']), +.rdp-day_selected:active:not([aria-disabled='true']), +.rdp-day_selected:active:hover:not([aria-disabled='true']) { + color: white; + background-color: var(--rdp-active-color); +} + +.ds-c-single-input-date-field { + position: relative; + + .rdp { + position: absolute; + top: 100%; + left: 0; + border: 1px solid $color-gray-dark; + border-radius: 8px; + box-shadow: 0 0 17px 0 $color-gray-light; + margin: 0; + margin-top: $spacer-1; + padding: $spacer-2; + } +} + +.ds-c-single-input-date-field__field-wrapper { + .ds-c-single-input-date-field--with-picker & { + display: flex; + justify-content: start; + align-items: stretch; + margin-bottom: $spacer-half; + margin-top: $spacer-half; + + .ds-c-field { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + margin-top: 0; + margin-bottom: 0; + margin-right: -#{$input-border-width}; + + &:focus, + &:active { + z-index: 1; + } + } + } + + .ds-c-field { + max-width: 108px; + } + + .ds-c-single-input-date-field__button { + appearance: none; + background-color: #e6f1f8; // TODO: use the token ocean-50 + border: $input-border-width solid $input-border-color; + border-radius: $input-border-radius; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + color: $color-primary; + cursor: pointer; + padding: $spacer-half $spacer-1; + text-align: center; + text-decoration: none; + position: relative; + + &:after { + content: ''; + display: block; + position: absolute; + left: -#{$input-border-width}; + top: 0; + bottom: 0; + width: $input-border-width; + background: $color-gray-light; + } + + &:focus, + &:active { + @include focus-styles; + + &:after { + display: none; + } + } + } + + .ds-c-icon--calendar { + height: 75%; + } +} diff --git a/packages/design-system/src/styles/components/index.scss b/packages/design-system/src/styles/components/index.scss index 604f120a91..fb5a3fbd4d 100644 --- a/packages/design-system/src/styles/components/index.scss +++ b/packages/design-system/src/styles/components/index.scss @@ -14,6 +14,7 @@ @import 'MonthPicker.scss'; @import 'Pagination.scss'; @import 'Review.scss'; +@import 'SingleInputDateField.scss'; @import 'SkipNav.scss'; @import 'Spinner.scss'; @import 'StepList.scss'; diff --git a/yarn.lock b/yarn.lock index 2e369f5b8b..e0e9555b7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4229,6 +4229,14 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== +"@reach/auto-id@0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.16.0.tgz#dfabc3227844e8c04f8e6e45203a8e14a8edbaed" + integrity sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg== + dependencies: + "@reach/utils" "0.16.0" + tslib "^2.3.0" + "@reach/router@^1.3.4": version "1.3.4" resolved "https://registry.yarnpkg.com/@reach/router/-/router-1.3.4.tgz#d2574b19370a70c80480ed91f3da840136d10f8c" @@ -4239,6 +4247,14 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" +"@reach/utils@0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.16.0.tgz#5b0777cf16a7cab1ddd4728d5d02762df0ba84ce" + integrity sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q== + dependencies: + tiny-warning "^1.0.3" + tslib "^2.3.0" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -9581,6 +9597,11 @@ date-fns@^1.27.2: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== +date-fns@^2.28.0: + version "2.28.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" + integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== + dateformat@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-2.2.0.tgz#4065e2013cf9fb916ddfd82efb506ad4c6769062" @@ -18996,6 +19017,13 @@ react-colorful@^5.1.2: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.0.tgz#8359f218984a927095477a190ab9927eaf865c0c" integrity sha512-BuzrlrM0ylg7coPkXOrRqlf2BgHLw5L44sybbr9Lg4xy7w9e5N7fGYbojOO0s8J0nvrM3PERN2rVFkvSa24lnQ== +react-day-picker@^8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.0.5.tgz#079f21ebdc353b66a97ef4258062f27c10b55145" + integrity sha512-qw5t2zJB1ob7p0yhbuVY0kzu08qePrpYW1kldrhTJ0nHN/z5Jb90MeCAi2889z8h2NJ/Flh5gLR9XcJIsr4ijQ== + dependencies: + "@reach/auto-id" "0.16.0" + react-deep-force-update@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-2.1.3.tgz#740612322e617bcced38f61794a4af75dc3d98e7" @@ -21840,6 +21868,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-warning@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" + integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"