import React, { forwardRef, useRef, useState, Fragment, useEffect } from 'react'; import styled from 'styled-components'; import leadingZeroes from 'leading-zeroes'; import toDate from 'date-fns/toDate'; import getDate from 'date-fns/getDate'; import getMonth from 'date-fns/getMonth'; import getYear from 'date-fns/getYear'; import dateIsValid from 'date-fns/isValid'; import isBefore from 'date-fns/isBefore'; import isAfter from 'date-fns/isAfter'; import dateParse from 'date-fns/parse'; import { getFocusRing, getBorderRadius } from '@lendi-ui/commons/borders'; import cuid from 'cuid'; import isChromatic from 'chromatic/isChromatic'; import VisuallyHidden from '@reach/visually-hidden'; import { filterGlobalProps, pxToRem } from '@lendi-ui/commons/utils'; import { Spacer } from '../../foundations/Layout'; import { ErrorText, FieldLabel, HelpText, InputBaseComponent, InputFieldBaseProps, InputProps } from '../TextField'; type Variant = 'daymonthyear' | 'monthyear'; interface DateFieldProps extends Omit { variant?: Variant; minDate?: Date; maxDate?: Date; } const Input = styled(InputBaseComponent as any)` --border-radius: ${({ theme }) => getBorderRadius({ theme: theme.name })}; border-radius: 0; &:first-of-type { border-radius: var(--border-radius) 0 0 var(--border-radius); } &:nth-of-type(2) { border-left: none; } &:last-of-type { border-left: none; border-radius: 0 var(--border-radius) var(--border-radius) 0; } &:focus-visible { box-shadow: none; } `; export type DateFieldComponentProps = InputProps; const Wrapper = styled(Spacer)` position: relative; `; const DateFieldsWrapper = styled.div<{ isFocused: boolean; isDisabled?: boolean; validationState?: 'valid' | 'invalid'; }>` display: flex; border-radius: ${({ theme }) => getBorderRadius({ theme: theme.name })}; box-shadow: ${(props) => props.isFocused && getFocusRing()}; &:hover { ${Input} { border-color: ${({ isDisabled, validationState, theme }) => { if (isDisabled) { return theme.color.border.container; } if (validationState === 'invalid') { return theme.color.interaction.error; } else { return theme.color.border.interaction.hover; } }}; } } `; // Worth moving this when something else needs a fieldset/legend // but for now its only one :) const Fieldset = styled.fieldset` margin: 0; padding: 0; border: none; legend { margin-bottom: ${pxToRem(8)}; } `; const filterAlphabet = (value: string) => value.replace(/\D*/g, ''); function DateFieldComponent( { inputId, name, variant = 'daymonthyear', helpText, errorText, validationState, ariaLabel, ariaLabelledBy, label, labelSuffix, isRequired, isDisabled, defaultValue, value, onChange, width = '100%', minWidth, maxWidth, minDate, maxDate, ...props }: DateFieldComponentProps, ref: React.Ref ) { const globalProps = filterGlobalProps(props); const [isFocused, setIsFocused] = useState(false); const [day, setDay] = useState(''); const [month, setMonth] = useState(''); const [year, setYear] = useState(''); const [fullDate, setFullDate] = useState(''); const inputElId = isChromatic() ? '1' : inputId ?? cuid(); const helpTextId = isChromatic() ? '2' : cuid(); const errorTextId = isChromatic() ? '3' : cuid(); const hasError = errorText && validationState === 'invalid'; const hasFullDate = fullDate.length > 0; const inputRef = ref as React.MutableRefObject; const yearRef = useRef(null); const ariaDescribedBy = []; if (helpText) { ariaDescribedBy.push(helpTextId); } if (hasError) { ariaDescribedBy.push(errorTextId); } const getRealMonth = (dateObject: Date) => getMonth(dateObject) + 1; const applyDateToState = (date: Date) => { const dateObject = toDate(date); if (dateIsValid(dateObject)) { setDay(leadingZeroes(getDate(dateObject).toString(), 2)); setMonth(leadingZeroes(getRealMonth(dateObject).toString(), 2)); setYear(getYear(dateObject).toString()); } }; // Used only to initialise the fields with default values useEffect(() => { const defaultDate = value ?? defaultValue; if (defaultDate) { applyDateToState(defaultDate); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Manage when to update the full date useEffect(() => { if (day && month && year) { setFullDate(`${year}-${month}-${day}`); } }, [day, month, year]); // Manage reporting the full date string & other details back to the consumer: // This effectively is a fake onChange handler in place of the lack of a real onChange given the management of 3 different input fields. useEffect(() => { if (onChange && hasFullDate) { // Because I'm doing this outside of a real change event, I have created a fakeEvent to send back to consumer // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const fakeEvent = { target: { value: fullDate, validity: getValidity(fullDate), }, // using `as` here to bypass typescript's requirements to add missing values in the real event object } as React.ChangeEvent; onChange(fakeEvent); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasFullDate, fullDate]); const getValidity = (fullDate: string) => { const validity = { patternMismatch: false, rangeOverflow: false, rangeUnderflow: false, }; if (day.length !== 2 && month.length !== 2 && year.length !== 4) { validity.patternMismatch = true; return validity; } const dateInput = toDate(new Date(fullDate)); // dateIsValid handles dates which exceed days in a month etc. if (dateIsValid(dateInput)) { const minDateObject = minDate && toDate(minDate); const maxDateObject = maxDate && toDate(maxDate); const dateIsBeforeIsBeforeMinDate = minDateObject && isBefore(dateInput, minDateObject); const dateIsAfterMaxDate = maxDateObject && isAfter(dateInput, maxDateObject); if (dateIsBeforeIsBeforeMinDate) { validity.rangeUnderflow = true; inputRef?.current.setCustomValidity('You have entered a date before the accepted minimum date'); } if (dateIsAfterMaxDate) { validity.rangeOverflow = true; inputRef?.current.setCustomValidity('You have exceeded the number of days in this month'); } } else { validity.patternMismatch = true; } return validity; }; const handleChange = (type: 'day' | 'month' | 'year') => (e: React.ChangeEvent) => { // Ensures there's no alphabet in the inputs if (e.target.value !== '') { e.target.value = filterAlphabet(e.target.value); } if (type === 'day') setDay(e.target.value); if (type === 'month') setMonth(e.target.value); if (type === 'year') setYear(e.target.value); }; const handleOnPaste = (e: React.ClipboardEvent) => { e.clipboardData.items[0].getAsString((text: string) => { // Only really supporting the Queen's date format here const parsedDate = text && dateParse(text, 'dd/MM/yyyy', new Date()); if (parsedDate && dateIsValid(parsedDate)) { applyDateToState(parsedDate); // Once we apply the date to the state, we shift the user to the year input // to create the affect of having entered the date manually, and to provide // a better flow yearRef?.current?.focus(); } }); }; const onFocusHandler = () => !isFocused && setIsFocused(true); const onBlurHandler = () => isFocused && setIsFocused(false); const showDayField = variant === 'daymonthyear'; const showMonthField = variant === 'daymonthyear' || variant === 'monthyear'; const showYearField = variant === 'daymonthyear' || variant === 'monthyear'; return (
{label && ( {helpText && {helpText}} {hasError && errorText && {errorText}} )} {/** * Holds the full date value for ref users * @todo - replace this input with a hidden input - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/hidden */} {/* Customer interaction is handled by separate controlled inputs */} {showDayField && ( )} {showMonthField && ( )} {showYearField && ( )} {helpText && ( )} {hasError && errorText && ( )}
); } export const DateField = forwardRef(DateFieldComponent);