Skip to content

Commit

Permalink
46 Date and time picker inputs (#77)
Browse files Browse the repository at this point in the history
* input error text

* initial DatetimeInput component

* user form field attrs

* calendar icon

* datetimeinput internal event logic

* profile dateOfBirth

* datetime time format

* date format

* datetime disabled

* clean up

* format datetime

* DateInput component

* tests

* DatetimeInput refactored

* format IonDatetime value

* tests

* tests

* docs
  • Loading branch information
mwarman authored Sep 18, 2024
1 parent 89a4d23 commit b3401b2
Show file tree
Hide file tree
Showing 13 changed files with 486 additions and 8 deletions.
1 change: 1 addition & 0 deletions src/__fixtures__/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export const profileFixture1: Profile = {
name: 'Test User',
email: '[email protected]',
bio: 'My name is Test User.',
dateOfBirth: '2002-05-07',
};
3 changes: 3 additions & 0 deletions src/common/components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import {
faBuilding,
faCalendar,
faCircleInfo,
faEnvelope,
faHouse,
Expand Down Expand Up @@ -42,6 +43,7 @@ export interface IconProps
*/
export enum IconName {
Building = 'building',
Calendar = 'calendar',
CircleInfo = 'circle_info',
Envelope = 'envelope',
House = 'house',
Expand All @@ -65,6 +67,7 @@ export enum IconName {
*/
const icons: Record<IconName, IconProp> = {
building: faBuilding,
calendar: faCalendar,
circle_info: faCircleInfo,
envelope: faEnvelope,
house: faHouse,
Expand Down
12 changes: 12 additions & 0 deletions src/common/components/Input/DateInput.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ion-input.ls-date-input {
input {
cursor: pointer;
}
}

ion-modal.ls-date-modal {
--height: fit-content;
--width: fit-content;

--border-radius: 0.5rem;
}
163 changes: 163 additions & 0 deletions src/common/components/Input/DateInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { ComponentPropsWithoutRef, useMemo, useState } from 'react';
import { ModalCustomEvent } from '@ionic/core';
import { DatetimeCustomEvent, IonButton, IonDatetime, IonInput, IonModal } from '@ionic/react';
import { useField } from 'formik';
import classNames from 'classnames';
import dayjs from 'dayjs';

import './DateInput.scss';
import { PropsWithTestId } from '../types';
import Icon, { IconName } from '../Icon/Icon';

/**
* Default `IonDatetime` `formatOptions` for the date. May be overridden by
* supplying a `formatOptions` property. Controls how the date is displayed
* in the form input.
* @see {@link IonDatetime}
*/
const DEFAULT_FORMAT_DATE: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};

/**
* `DateValue` describes the possible types of an `IonDatetime` whose `presentation`
* is 'date'.
*/
type DateValue = string | null;

/**
* Properties for the `DateInput` component.
* @see {@link PropsWithTestId}
* @see {@link IonDatetime}
* @see {@link IonInput}
* @see {@link IonModal}
*/
interface DateInputProps
extends PropsWithTestId,
Pick<ComponentPropsWithoutRef<typeof IonInput>, 'label' | 'labelPlacement'>,
Pick<ComponentPropsWithoutRef<typeof IonModal>, 'onIonModalDidDismiss'>,
Omit<ComponentPropsWithoutRef<typeof IonDatetime>, 'multiple' | 'name' | 'presentation'>,
Required<Pick<ComponentPropsWithoutRef<typeof IonDatetime>, 'name'>> {}

/**
* The `DateInput` component renders an `IonDatetime` which is integrated with
* Formik. The form field value is displayed in an `IonInput`. When that input
* is clicked, an `IonDatetime` is presented within an `IonModal`.
*
* Use this component when you need to collect a date value within a form. The
* date value will be set as an ISO8601 date, e.g. YYYY-MM-DD
*
* @param {DateInputProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const DateInput = ({
className,
label,
labelPlacement,
onIonModalDidDismiss,
testid = 'input-date',
...datetimeProps
}: DateInputProps): JSX.Element => {
const [field, meta, helpers] = useField<DateValue>(datetimeProps.name);
const [isOpen, setIsOpen] = useState<boolean>(false);

// populate error text only if the field has been touched and has an error
const errorText: string | undefined = meta.touched ? meta.error : undefined;

/**
* Handle change events emitted by `IonDatetime`.
* @param {DatetimeCustomEvent} e - The event.
*/
const onChange = async (e: DatetimeCustomEvent): Promise<void> => {
const value = e.detail.value as DateValue;
if (value) {
const isoDate = dayjs(value).format('YYYY-MM-DD');
await helpers.setValue(isoDate, true);
} else {
await helpers.setValue(null, true);
}
datetimeProps.onIonChange?.(e);
};

/**
* Handle 'did dismiss' events emitted by `IonModal`.
*/
const onDidDismiss = async (e: ModalCustomEvent): Promise<void> => {
await helpers.setTouched(true, true);
setIsOpen(false);
onIonModalDidDismiss?.(e);
};

// format the value to display in the IonInput
// use UTC so that local timezone offset does not change the date
const inputValue = useMemo(() => {
if (field.value) {
const dateOptions: Intl.DateTimeFormatOptions = {
...(datetimeProps.formatOptions?.date ?? DEFAULT_FORMAT_DATE),
timeZone: 'UTC',
};

return Intl.DateTimeFormat(undefined, dateOptions).format(new Date(field.value));
} else {
return '';
}
}, [datetimeProps.formatOptions, field.value]);

// format the value for the IonDatetime. it must be a local ISO date or null/undefined
const datetimeValue = useMemo(() => {
return field.value ? dayjs(field.value).format('YYYY-MM-DD[T]HH:mm') : null;
}, [field.value]);

return (
<>
<IonInput
className={classNames(
'ls-date-input',
className,
{ 'ion-touched': meta.touched },
{ 'ion-invalid': meta.error },
{ 'ion-valid': meta.touched && !meta.error },
)}
data-testid={testid}
disabled={datetimeProps.disabled}
errorText={errorText}
label={label}
labelPlacement={labelPlacement}
onFocus={() => setIsOpen(true)}
readonly
value={inputValue}
>
<IonButton
aria-hidden="true"
data-testid={`${testid}-button-calendar`}
disabled={datetimeProps.disabled}
fill="clear"
onClick={() => setIsOpen(true)}
slot="end"
>
<Icon icon={IconName.Calendar} />
</IonButton>
</IonInput>

<IonModal
className="ls-date-modal"
data-testid={`${testid}-modal`}
isOpen={isOpen}
onIonModalDidDismiss={onDidDismiss}
>
<IonDatetime
{...datetimeProps}
data-testid={`${testid}-datetime`}
multiple={false}
onIonChange={onChange}
presentation="date"
value={datetimeValue}
></IonDatetime>
</IonModal>
</>
);
};

export default DateInput;
12 changes: 12 additions & 0 deletions src/common/components/Input/DatetimeInput.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ion-input.ls-datetime-input {
input {
cursor: pointer;
}
}

ion-modal.ls-datetime-modal {
--height: fit-content;
--width: fit-content;

--border-radius: 0.5rem;
}
177 changes: 177 additions & 0 deletions src/common/components/Input/DatetimeInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { ComponentPropsWithoutRef, useMemo, useState } from 'react';
import { ModalCustomEvent } from '@ionic/core';
import { DatetimeCustomEvent, IonButton, IonDatetime, IonInput, IonModal } from '@ionic/react';
import { useField } from 'formik';
import classNames from 'classnames';
import dayjs from 'dayjs';

import './DatetimeInput.scss';
import { PropsWithTestId } from '../types';
import Icon, { IconName } from '../Icon/Icon';

/**
* Default `IonDatetime` `formatOptions` for the date. May be overridden by
* supplying a `formatOptions` property. Controls how the date is displayed
* in the form input.
* @see {@link IonDatetime}
*/
const DEFAULT_FORMAT_DATE: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
};

/**
* Default `IonDatetime` `formatOptions` for the time. May be overridden by
* supplying a `formatOptions` property. Controls how the time is displayed
* in the form input.
* @see {@link IonDatetime}
*/
const DEFAULT_FORMAT_TIME: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: '2-digit',
};

/**
* `DatetimeValue` describes the possible types of an `IonDatetime` whose `presentation`
* is 'date-time'.
*/
type DatetimeValue = string | null;

/**
* Properties for the `DatetimeInput` component.
* @see {@link PropsWithTestId}
* @see {@link IonDatetime}
* @see {@link IonInput}
* @see {@link IonModal}
*/
interface DatetimeInputProps
extends PropsWithTestId,
Pick<ComponentPropsWithoutRef<typeof IonInput>, 'label' | 'labelPlacement'>,
Pick<ComponentPropsWithoutRef<typeof IonModal>, 'onIonModalDidDismiss'>,
Omit<ComponentPropsWithoutRef<typeof IonDatetime>, 'multiple' | 'name' | 'presentation'>,
Required<Pick<ComponentPropsWithoutRef<typeof IonDatetime>, 'name'>> {}

/**
* The `DatetimeInput` component renders an `IonDatetime` which is integrated with
* Formik. The form field value is displayed in an `IonInput`. When that input
* is clicked, an `IonDatetime` is presented within an `IonModal`.
*
* Use this component when you need to collect a date and time, a timestamp,
* value within a form. The value will be set as an ISO8601 timestamp.
*
* @param {DateInputProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const DatetimeInput = ({
className,
label,
labelPlacement,
onIonModalDidDismiss,
testid = 'input-datetime',
...datetimeProps
}: DatetimeInputProps): JSX.Element => {
const [field, meta, helpers] = useField<DatetimeValue>(datetimeProps.name);
const [isOpen, setIsOpen] = useState<boolean>(false);

// populate error text only if the field has been touched and has an error
const errorText: string | undefined = meta.touched ? meta.error : undefined;

/**
* Handle change events emitted by `IonDatetime`.
* @param {DatetimeCustomEvent} e - The event.
*/
const onChange = async (e: DatetimeCustomEvent): Promise<void> => {
const value = e.detail.value as DatetimeValue;
if (value) {
const isoDate = dayjs(value).toISOString();
await helpers.setValue(isoDate, true);
} else {
await helpers.setValue(null, true);
}
datetimeProps.onIonChange?.(e);
};

/**
* Handle 'did dismiss' events emitted by `IonModal`.
*/
const onDidDismiss = async (e: ModalCustomEvent): Promise<void> => {
await helpers.setTouched(true, true);
setIsOpen(false);
onIonModalDidDismiss?.(e);
};

// format the value to display in the IonInput
const inputValue = useMemo(() => {
if (field.value) {
const date = new Intl.DateTimeFormat(
undefined,
datetimeProps.formatOptions?.date ?? DEFAULT_FORMAT_DATE,
).format(new Date(field.value));
const time = new Intl.DateTimeFormat(
undefined,
datetimeProps.formatOptions?.time ?? DEFAULT_FORMAT_TIME,
).format(new Date(field.value));

return `${date} ${time}`;
} else {
return '';
}
}, [datetimeProps.formatOptions, field.value]);

// format the value for the IonDatetime. it must be a local ISO date or null/undefined
const datetimeValue = useMemo(() => {
return field.value ? dayjs(field.value).format('YYYY-MM-DD[T]HH:mm') : null;
}, [field.value]);

return (
<>
<IonInput
className={classNames(
'ls-datetime-input',
className,
{ 'ion-touched': meta.touched },
{ 'ion-invalid': meta.error },
{ 'ion-valid': meta.touched && !meta.error },
)}
data-testid={testid}
disabled={datetimeProps.disabled}
errorText={errorText}
label={label}
labelPlacement={labelPlacement}
onFocus={() => setIsOpen(true)}
readonly
value={inputValue}
>
<IonButton
aria-hidden="true"
data-testid={`${testid}-button-calendar`}
disabled={datetimeProps.disabled}
fill="clear"
onClick={() => setIsOpen(true)}
slot="end"
>
<Icon icon={IconName.Calendar} />
</IonButton>
</IonInput>

<IonModal
className="ls-datetime-modal"
data-testid={`${testid}-modal`}
isOpen={isOpen}
onIonModalDidDismiss={onDidDismiss}
>
<IonDatetime
{...datetimeProps}
data-testid={`${testid}-datetime`}
multiple={false}
onIonChange={onChange}
presentation="date-time"
value={datetimeValue}
></IonDatetime>
</IonModal>
</>
);
};

export default DatetimeInput;
Loading

0 comments on commit b3401b2

Please sign in to comment.