Creating a DayInput Component with React and TypeScript
In this article, we will create a component for selecting a date using React and TypeScript. We'll call it DayInput
to be more specific. You can find both the demo and the source code in RadzionKit.
Initial Purpose
The initial reason for making this component was to add support for age-based goals in the productivity app Increaser. Therefore, we'll use a date of birth form as an example in this article.
import { HStack } from "@lib/ui/layout/Stack"
import { Button } from "@lib/ui/buttons/Button"
import styled from "styled-components"
import { InputContainer } from "@lib/ui/inputs/InputContainer"
import { DayInput } from "@lib/ui/time/day/DayInput"
import { stringToDay, dayToString, Day } from "@lib/utils/time/Day"
import { FinishableComponentProps } from "@lib/ui/props"
import { useState } from "react"
import { LabelText } from "@lib/ui/inputs/LabelText"
import { useUpdateUserMutation } from "../../user/mutations/useUpdateUserMutation"
import { getFormProps } from "@lib/ui/form/utils/getFormProps"
import { useAssertUserState } from "../../user/UserStateContext"
import { Panel } from "@lib/ui/panel/Panel"
import { useDobBoundaries } from "./useDobBoundaries"
import { getDefaultDob } from "./getDefaultDob"
const Container = styled(HStack)`
width: 100%;
justify-content: space-between;
align-items: end;
gap: 20px;
`
export const SetDobForm = ({ onFinish }: FinishableComponentProps) => {
const { dob } = useAssertUserState()
const { mutate: updateUser } = useUpdateUserMutation()
const [value, setValue] = useState<Day>(() => {
if (dob) {
return stringToDay(dob)
}
return getDefaultDob()
})
const [min, max] = useDobBoundaries()
return (
<InputContainer as="div" style={{ gap: 8 }}>
<LabelText>Your date of birth</LabelText>
<Panel kind="secondary">
<Container
as="form"
{...getFormProps({
onClose: onFinish,
onSubmit: () => {
updateUser({ dob: dayToString(value) })
onFinish()
},
})}
>
<DayInput min={min} max={max} value={value} onChange={setValue} />
<Button>Submit</Button>
</Container>
</Panel>
</InputContainer>
)
}
Component Props
Our DayInput
component receives four props:
-
value
: The selected date. -
onChange
: A function to call when the date changes. -
min
: The minimum date. -
max
: The maximum date.
import { useMemo } from "react"
import { InputProps } from "../../props"
import { ExpandableSelector } from "../../select/ExpandableSelector"
import { Day, fromDay, toDay } from "@lib/utils/time/Day"
import { monthNames } from "@lib/utils/time/Month"
import {
dayInputParts,
fromDayInputParts,
toDayInputParts,
} from "./DayInputParts"
import styled from "styled-components"
import { getDayInputPartInterval } from "./getDayInputPartInterval"
import { match } from "@lib/utils/match"
import { enforceRange } from "@lib/utils/enforceRange"
import { intervalRange } from "@lib/utils/interval/intervalRange"
type DayInputProps = InputProps<Day> & {
min: Day
max: Day
}
const Container = styled.div`
display: grid;
grid-template-columns: 68px 120px 80px;
gap: 8px;
`
export const DayInput = ({ value, onChange, min, max }: DayInputProps) => {
const parts = useMemo(() => toDayInputParts(fromDay(value)), [value])
return (
<Container>
{dayInputParts.map((part) => {
const interval = getDayInputPartInterval({
min,
max,
part,
value: parts,
})
return (
<ExpandableSelector
key={part}
value={parts[part]}
onChange={(value) => {
const newParts = { ...parts, [part]: value }
const lowerParts = dayInputParts.slice(
0,
dayInputParts.indexOf(part)
)
lowerParts.toReversed().forEach((part) => {
const { start, end } = getDayInputPartInterval({
min,
max,
part,
value: newParts,
})
newParts[part] = enforceRange(newParts[part], start, end)
})
const newValue = toDay(fromDayInputParts(newParts))
onChange(newValue)
}}
options={intervalRange(interval)}
renderOption={(option) =>
match(part, {
day: () => option.toString(),
month: () => monthNames[option - 1],
year: () => option.toString(),
})
}
getOptionKey={(option) => option.toString()}
/>
)
})}
</Container>
)
}
Using Day as the Type
As you can see, we use Day
as the type for the value, which is an object with year
and dayIndex
fields. This might seem a bit unusual, but it makes sense when you consider representing a day. These two fields are sufficient to represent a day, and including the month doesn't add significant value.
import { haveEqualFields } from "../record/haveEqualFields"
import { convertDuration } from "./convertDuration"
import { addDays, format, startOfYear } from "date-fns"
import { inTimeZone } from "./inTimeZone"
export type Day = {
year: number
dayIndex: number
}
export const toDay = (timestamp: number): Day => {
const date = new Date(timestamp)
const dateOffset = date.getTimezoneOffset()
const yearStartedAt = inTimeZone(startOfYear(timestamp).getTime(), dateOffset)
const diff = timestamp - yearStartedAt
const diffInDays = diff / convertDuration(1, "d", "ms")
const day = {
year: new Date(timestamp).getFullYear(),
dayIndex: Math.floor(diffInDays),
}
return day
}
export const dayToString = ({ year, dayIndex }: Day): string =>
[dayIndex, year].join("-")
export const stringToDay = (str: string): Day => {
const [dayIndex, year] = str.split("-").map(Number)
return { dayIndex, year }
}
export const fromDay = ({ year, dayIndex }: Day): number => {
const startOfYearDate = startOfYear(new Date(year, 0, 1))
return addDays(startOfYearDate, dayIndex).getTime()
}
export const areSameDay = <T extends Day>(a: T, b: T): boolean =>
haveEqualFields(["year", "dayIndex"], a, b)
export const formatDay = (timestamp: number) => format(timestamp, "EEEE, d MMM")
Storing and Converting Dates
We can store the date in the database as a string by using the dayToString
function. Additionally, we have several helper functions to convert between Day
, timestamp, and string. In the toDay
function, we perform an interesting calculation to get the day index. It's important to be aware of timezone offsets, even within the same timezone. Due to daylight saving time, the offset can change depending on the date. Even if your location doesn't observe daylight saving time currently, it might have in the past, resulting in different offsets for previous dates. By using the inTimeZone
function, we can ensure that the date is converted to the correct timezone before calculating the day index.
import { convertDuration } from "./convertDuration"
export const inTimeZone = (timestamp: number, targetTimeZoneOffset: number) => {
const offsetAtTimestamp = new Date(timestamp).getTimezoneOffset()
const offsetDiff = targetTimeZoneOffset - offsetAtTimestamp
return timestamp + convertDuration(offsetDiff, "min", "ms")
}
Storing and Converting Dates
Our component consists of three dropdown inputs, which we place within a grid container. Each item in the grid has a custom width that matches the expected width of its respective input content. To render each dropdown, we use the ExpandableSelector
component, which is covered in detail in this article.
import { FloatingOptionsContainer } from "../floating/FloatingOptionsContainer"
import { useFloatingOptions } from "../floating/useFloatingOptions"
import { UIComponentProps } from "../props"
import { OptionItem } from "./OptionItem"
import { ExpandableSelectorToggle } from "./ExpandableSelectorToggle"
import { FloatingFocusManager } from "@floating-ui/react"
import { OptionContent } from "./OptionContent"
import { OptionOutline } from "./OptionOutline"
import { ExpandableSelectorContainer } from "./ExpandableSelectorContainer"
export type ExpandableSelectorProp<T> = UIComponentProps & {
value: T | null
onChange: (value: T) => void
isDisabled?: boolean
options: readonly T[]
getOptionKey: (option: T) => string
renderOption: (option: T) => React.ReactNode
openerContent?: React.ReactNode
floatingOptionsWidthSameAsOpener?: boolean
showToggle?: boolean
returnFocus?: boolean
}
export function ExpandableSelector<T>({
value,
onChange,
options,
isDisabled,
renderOption,
getOptionKey,
openerContent,
floatingOptionsWidthSameAsOpener,
showToggle = true,
returnFocus = false,
...rest
}: ExpandableSelectorProp<T>) {
const {
getReferenceProps,
getFloatingProps,
getOptionProps,
isOpen,
setIsOpen,
activeIndex,
context,
} = useFloatingOptions({
floatingOptionsWidthSameAsOpener,
selectedIndex: value === null ? null : options.indexOf(value),
})
const referenceProps = isDisabled ? {} : getReferenceProps()
return (
<>
<ExpandableSelectorContainer
isDisabled={isDisabled}
isActive={isOpen}
{...referenceProps}
{...rest}
>
<OptionContent>
{openerContent ?? renderOption(value as T)}
</OptionContent>
{showToggle && <ExpandableSelectorToggle isOpen={isOpen} />}
</ExpandableSelectorContainer>
{isOpen && !isDisabled && (
<FloatingFocusManager context={context} modal returnFocus={returnFocus}>
<FloatingOptionsContainer {...getFloatingProps()}>
{options.map((option, index) => (
<OptionItem
key={getOptionKey(option)}
isActive={activeIndex === index}
{...getOptionProps({
index,
onSelect: () => {
onChange(option)
setIsOpen(false)
},
})}
>
<OptionContent>{renderOption(option)}</OptionContent>
{option === value && <OptionOutline />}
</OptionItem>
))}
</FloatingOptionsContainer>
</FloatingFocusManager>
)}
</>
)
}
Input Parts and Conversion Functions
We display the input parts in the following order: day, month, year. We extract the DayInputPart
type from the dayInputParts
array. Additionally, we define the DayInputParts
type as a record with DayInputPart
keys and number
values. The toDayInputParts
function converts a timestamp to a DayInputParts
object, while the fromDayInputParts
function converts a DayInputParts
object back to a timestamp.
export const dayInputParts = ["day", "month", "year"] as const
export type DayInputPart = (typeof dayInputParts)[number]
export type DayInputParts = Record<DayInputPart, number>
export const toDayInputParts = (timestamp: number): DayInputParts => {
const date = new Date(timestamp)
return {
day: date.getDate(),
month: date.getMonth() + 1,
year: date.getFullYear(),
}
}
export const fromDayInputParts = ({
day,
month,
year,
}: DayInputParts): number => new Date(year, month - 1, day).getTime()
Dynamic Range Calculation
The getDayInputPartInterval
function is a crucial utility for dynamically determining the valid range of each part of a date input. It takes an Input
object that includes minimum and maximum Day
objects, the current DayInputPart
, and the current date value as DayInputParts
.
The function begins by converting the minimum and maximum days into their respective parts using toDayInputParts
. It then checks if all higher parts (e.g., month and year for a day
part, or year for a month
part) are fixed, meaning they are equal for both the minimum and maximum date. If the higher parts are fixed, or if it's a year
(which doesn't have higher parts), the function returns the range between the min and max parts.
For the month part, the range is adjusted based on whether the year matches the min or max year. Similarly, for the day part, it considers both the year and month to ensure the correct number of days in that month. This approach ensures that each dropdown in the date input only shows valid options, preventing users from selecting an invalid date.
import { Day, fromDay } from "@lib/utils/time/Day"
import {
DayInputPart,
dayInputParts,
DayInputParts,
toDayInputParts,
} from "./DayInputParts"
import { Interval } from "@lib/utils/interval/Interval"
import { MONTHS_IN_YEAR } from "@lib/utils/time"
import { getDaysInMonth } from "@lib/utils/time/getDaysInMonth"
type Input = {
min: Day
max: Day
part: DayInputPart
value: DayInputParts
}
export const getDayInputPartInterval = ({
min,
max,
part,
value,
}: Input): Interval => {
const minParts = toDayInputParts(fromDay(min))
const maxParts = toDayInputParts(fromDay(max))
const higherParts = dayInputParts.slice(dayInputParts.indexOf(part) + 1)
const areHigherPartsFixed = higherParts.every(
(part) => value[part] === minParts[part] && value[part] === maxParts[part]
)
if (areHigherPartsFixed) {
return {
start: minParts[part],
end: maxParts[part],
}
}
if (part === "month") {
if (value.year === minParts.year) {
return {
start: minParts[part],
end: MONTHS_IN_YEAR,
}
}
if (value.year === maxParts.year) {
return {
start: 1,
end: maxParts[part],
}
}
return {
start: 1,
end: MONTHS_IN_YEAR,
}
}
if (value.year === minParts.year && value.month === minParts.month) {
return {
start: minParts[part],
end: getDaysInMonth({
year: value.year,
monthIndex: value.month - 1,
}),
}
}
if (value.year === maxParts.year && value.month === maxParts.month) {
return {
start: 1,
end: maxParts[part],
}
}
return {
start: 1,
end: getDaysInMonth({
year: value.year,
monthIndex: value.month - 1,
}),
}
}
Handling Date Changes
On change, we construct new parts and go over each lower part to enforce the range. For example, if the date was January 31st and the month was changed to February, we would need to adjust the day to the 28th. We then convert the new parts to a Day
object and call the onChange
function with the new value.
Generating Options
To generate a list of options from the interval, we use the intervalRange
function. This function generates a range of numbers from the start to the end of the interval.
import { range } from "../array/range"
import { Interval } from "./Interval"
export const intervalRange = ({ start, end }: Interval): number[] =>
range(end - start + 1).map((value) => value + start)
Formatting Options
To format the options, we use the match
function, which is a better alternative to a switch statement. It allows us to define a function for each case and call the appropriate function based on the input. We will display numbers for the day and year, and month names for the month.
export function match<T extends string | number | symbol, V>(
value: T,
handlers: { [key in T]: () => V }
): V {
const handler = handlers[value]
return handler()
}