Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

46 Date and time picker inputs #77

Merged
merged 18 commits into from
Sep 18, 2024
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