Creating a DayInput Component with React and TypeScript for Date Selection

July 12, 2024

10 min read

Creating a DayInput Component with React and TypeScript for Date Selection
Watch on YouTube

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

Selecting date of birth
Selecting date of birth

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()
}