Skip to content

Commit

Permalink
Fixing several issues with the new Datepicker and making behavior clo…
Browse files Browse the repository at this point in the history
…ser to the old one (#2625)

* different approach to parsing and saving from dateinput

* improve formatting display

* make locales work with pattern-format

* fixes for #2637

* fix scrolling logic for the case where there are no scroll bars
  • Loading branch information
bjosttveit authored Oct 28, 2024
1 parent 399708e commit f49a523
Show file tree
Hide file tree
Showing 10 changed files with 290 additions and 200 deletions.
2 changes: 1 addition & 1 deletion src/layout/Datepicker/DatePickerCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const DatePickerCalendar = ({
}}
locale={currentLocale}
today={new Date()}
month={selectedDate}
defaultMonth={selectedDate}
disabled={[{ before: minDate, after: maxDate }]}
weekStartsOn={1}
mode='single'
Expand Down
122 changes: 55 additions & 67 deletions src/layout/Datepicker/DatePickerInput.tsx
Original file line number Diff line number Diff line change
@@ -1,81 +1,69 @@
import React, { forwardRef, useEffect, useState } from 'react';
import type { FocusEventHandler, RefObject } from 'react';
import React, { useEffect, useState } from 'react';
import { PatternFormat } from 'react-number-format';

import { Button, Textfield } from '@digdir/designsystemet-react';
import { CalendarIcon } from '@navikt/aksel-icons';
import { format, isMatch, isValid } from 'date-fns';
import { Textfield } from '@digdir/designsystemet-react';
import { format, isValid } from 'date-fns';

import { useLanguage } from 'src/features/language/useLanguage';
import styles from 'src/layout/Datepicker/Calendar.module.css';
import { DatepickerSaveFormatNoTimestamp, DatepickerSaveFormatTimestamp } from 'src/utils/dateHelpers';
import { getSaveFormattedDateString, strictParseFormat, strictParseISO } from 'src/utils/dateHelpers';
import { getFormatPattern } from 'src/utils/formatDateLocale';

export interface DatePickerInputProps {
id: string;
datepickerFormat: string;
timeStamp: boolean;
value?: string;
formatString?: string;
onBlur?: FocusEventHandler<HTMLInputElement>;
onClick?: () => void;
isDialogOpen?: boolean;
onValueChange?: (value: string) => void;
readOnly?: boolean;
}

export const DatePickerInput = forwardRef(
(
{ id, value, formatString, onBlur, isDialogOpen, readOnly, onClick }: DatePickerInputProps,
ref: RefObject<HTMLButtonElement>,
) => {
const [input, setInput] = useState(value ?? '');
export function DatePickerInput({
id,
value,
datepickerFormat,
timeStamp,
onValueChange,
readOnly,
}: DatePickerInputProps) {
const formatPattern = getFormatPattern(datepickerFormat);
const dateValue = strictParseISO(value);
const formattedDateValue = dateValue ? format(dateValue, datepickerFormat) : value;
const [inputValue, setInputValue] = useState(formattedDateValue ?? '');

const { langAsString } = useLanguage();
useEffect(() => {
setInputValue(formattedDateValue ?? '');
}, [formattedDateValue]);

useEffect(() => {
if (value) {
if (formatString && isMatch(value, formatString)) {
setInput(isValid(new Date(value)) ? format(value, formatString) : value);
} else if (isMatch(value, DatepickerSaveFormatNoTimestamp)) {
setInput(isValid(new Date(value)) ? format(value, formatString ?? 'dd.MM.yyyy') : value);
} else if (isMatch(value, DatepickerSaveFormatTimestamp)) {
setInput(isValid(new Date(value)) ? format(value, formatString ?? 'dd.MM.yyyy') : value);
}
}
}, [value, formatString]);
const saveValue = (e: React.ChangeEvent<HTMLInputElement>) => {
const stringValue = e.target.value;
const date = strictParseFormat(stringValue, datepickerFormat);
const valueToSave = getSaveFormattedDateString(date, timeStamp) ?? stringValue;
onValueChange && onValueChange(valueToSave);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const stringValue = e.target.value;
setInputValue(stringValue);
// If the date is valid, save immediately
if (stringValue.length == 0 || isValid(strictParseFormat(stringValue, datepickerFormat))) {
saveValue(e);
}
};

return (
<div className={styles.calendarInputWrapper}>
<Textfield
className={styles.calendarInput}
type='text'
id={id}
value={input}
placeholder={formatString}
onChange={handleInputChange}
onBlur={onBlur}
readOnly={readOnly}
aria-readonly={readOnly}
/>
<Button
id={`${id}-button`}
variant='tertiary'
icon={true}
aria-controls='dialog'
aria-haspopup='dialog'
onClick={onClick}
aria-expanded={isDialogOpen}
aria-label={langAsString('date_picker.aria_label_icon')}
ref={ref}
disabled={readOnly}
color='first'
size='small'
>
<CalendarIcon title={langAsString('date_picker.aria_label_icon')} />
</Button>
</div>
);
},
);

DatePickerInput.displayName = 'DatePickerInput';
return (
<PatternFormat
format={formatPattern}
customInput={Textfield}
mask='_'
className={styles.calendarInput}
type='text'
id={id}
value={inputValue}
placeholder={datepickerFormat.toUpperCase()}
onChange={handleChange}
onBlur={saveValue}
readOnly={readOnly}
aria-readonly={readOnly}
/>
);
}
4 changes: 2 additions & 2 deletions src/layout/Datepicker/DatepickerComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,12 +179,12 @@ describe('DatepickerComponent', () => {
it('should call setLeafValue if not finished filling out the date', async () => {
const { formDataMethods } = await render();

await userEvent.type(screen.getByRole('textbox'), `12.34`);
await userEvent.type(screen.getByRole('textbox'), `1234`);
await userEvent.tab();

expect(formDataMethods.setLeafValue).toHaveBeenCalledWith({
reference: { field: 'myDate', dataType: defaultDataTypeMock },
newValue: '12.34',
newValue: '12.34.____',
});
});

Expand Down
126 changes: 52 additions & 74 deletions src/layout/Datepicker/DatepickerComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { useRef, useState } from 'react';
import type { ReactNode } from 'react';
import React, { useState } from 'react';

import { Modal, Popover } from '@digdir/designsystemet-react';
import { Button } from '@digdir/designsystemet-react';
import { Grid } from '@material-ui/core';
import { formatDate, isValid as isValidDate, parse, parseISO } from 'date-fns';
import { CalendarIcon } from '@navikt/aksel-icons';
import { formatDate, isValid as isValidDate } from 'date-fns';

import { useDataModelBindings } from 'src/features/formData/useDataModelBindings';
import { useCurrentLanguage } from 'src/features/language/LanguageProvider';
Expand All @@ -12,8 +12,10 @@ import { useIsMobile } from 'src/hooks/useDeviceWidths';
import { ComponentStructureWrapper } from 'src/layout/ComponentStructureWrapper';
import styles from 'src/layout/Datepicker/Calendar.module.css';
import { DatePickerCalendar } from 'src/layout/Datepicker/DatePickerCalendar';
import { DatePickerDialog } from 'src/layout/Datepicker/DatepickerDialog';
import { DatePickerInput } from 'src/layout/Datepicker/DatePickerInput';
import { getDateConstraint, getDateFormat, getLocale, getSaveFormattedDateString } from 'src/utils/dateHelpers';
import { getDateConstraint, getDateFormat, getSaveFormattedDateString, strictParseISO } from 'src/utils/dateHelpers';
import { getDatepickerFormat } from 'src/utils/formatDateLocale';
import { useNodeItem } from 'src/utils/layout/useNodeItem';
import type { PropsFromGenericComponent } from 'src/layout';

Expand All @@ -24,75 +26,30 @@ export type IDatepickerProps = PropsFromGenericComponent<'Datepicker'>;
export function DatepickerComponent({ node }: IDatepickerProps) {
const { langAsString } = useLanguage();
const languageLocale = useCurrentLanguage();
const currentLocale = getLocale(languageLocale ?? 'nb');
const { minDate, maxDate, format, timeStamp = true, readOnly, required, id, dataModelBindings } = useNodeItem(node);

const [isDialogOpen, setIsDialogOpen] = useState(false);
const modalRef = useRef<HTMLDialogElement>(null);

const calculatedMinDate = getDateConstraint(minDate, 'min');
const calculatedMaxDate = getDateConstraint(maxDate, 'max');
const dateFormat = getDateFormat(format || 'dd.MM.yyyy', languageLocale);
const dateFormat = getDatepickerFormat(getDateFormat(format, languageLocale));
const isMobile = useIsMobile();

const { setValue, formData } = useDataModelBindings(dataModelBindings);
const value = formData.simpleBinding;
const selectedDate = isValidDate(parseISO(value)) ? parseISO(value) : new Date();
const dateValue = strictParseISO(value);
const dayPickerDate = dateValue ? dateValue : new Date();

const handleDayPickerSelect = (date: Date) => {
if (date && isValidDate(date)) {
setValue('simpleBinding', getSaveFormattedDateString(date, timeStamp));
}
modalRef.current?.close();
setIsDialogOpen(false);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const parsed = parse(e.target.value, dateFormat, new Date());
if (isValidDate(parsed)) {
setValue('simpleBinding', getSaveFormattedDateString(parsed, timeStamp));
} else {
setValue('simpleBinding', e.target.value ?? '');
}
const handleInputValueChange = (isoDateString: string) => {
setValue('simpleBinding', isoDateString);
};

const renderModal = (trigger: ReactNode, content: ReactNode) =>
isMobile ? (
<>
{trigger}
<Modal
role='dialog'
ref={modalRef}
onInteractOutside={() => modalRef.current?.close()}
style={{ width: 'fit-content', minWidth: 'fit-content' }}
>
<Modal.Content>{content}</Modal.Content>
</Modal>
</>
) : (
<Popover
portal={false}
open={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
size='lg'
placement='top'
>
<Popover.Trigger
onClick={() => setIsDialogOpen(!isDialogOpen)}
asChild={true}
>
{trigger}
</Popover.Trigger>
<Popover.Content
className={styles.calendarWrapper}
aria-modal
autoFocus={true}
>
{content}
</Popover.Content>
</Popover>
);

return (
<ComponentStructureWrapper
node={node}
Expand All @@ -104,31 +61,52 @@ export function DatepickerComponent({ node }: IDatepickerProps) {
item
xs={12}
>
{renderModal(
<div className={styles.calendarInputWrapper}>
<DatePickerInput
id={id}
value={value}
isDialogOpen={isMobile ? modalRef.current?.open : isDialogOpen}
formatString={dateFormat}
onBlur={handleInputChange}
onClick={() => (isMobile ? modalRef.current?.showModal() : setIsDialogOpen(!isDialogOpen))}
datepickerFormat={dateFormat}
timeStamp={timeStamp}
onValueChange={handleInputValueChange}
readOnly={readOnly}
/>,
<DatePickerCalendar
id={id}
locale={languageLocale}
selectedDate={selectedDate}
isOpen={isDialogOpen}
onSelect={handleDayPickerSelect}
minDate={calculatedMinDate}
maxDate={calculatedMaxDate}
required={required}
autoFocus={isMobile}
/>,
)}
/>
<DatePickerDialog
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
trigger={
<Button
id={`${id}-button`}
variant='tertiary'
icon={true}
aria-controls='dialog'
aria-haspopup='dialog'
onClick={() => setIsDialogOpen(!isDialogOpen)}
aria-expanded={isDialogOpen}
aria-label={langAsString('date_picker.aria_label_icon')}
disabled={readOnly}
color='first'
size='small'
>
<CalendarIcon title={langAsString('date_picker.aria_label_icon')} />
</Button>
}
>
<DatePickerCalendar
id={id}
locale={languageLocale}
selectedDate={dayPickerDate}
isOpen={isDialogOpen}
onSelect={handleDayPickerSelect}
minDate={calculatedMinDate}
maxDate={calculatedMaxDate}
required={required}
autoFocus={isMobile}
/>
</DatePickerDialog>
</div>
</Grid>
<span className={`${styles.formatText} no-visual-testing`}>
{langAsString('date_picker.format_text', [formatDate(new Date(), dateFormat, { locale: currentLocale })])}
{langAsString('date_picker.format_text', [formatDate(new Date(), dateFormat)])}
</span>
</div>
</ComponentStructureWrapper>
Expand Down
Loading

0 comments on commit f49a523

Please sign in to comment.