How To Make Time Input with React

November 28, 2022

4 min read

How To Make Time Input with React
Watch on YouTube

Let's make an interactive component for editing time with React.

editor

I've made the component initially for the productivity app Increaser, where you might open the app while working and realize that you forgot to start a focus session, so you can begin one and edit the start time of the session.

import { enforceRange } from "lib/shared/utils/enforceRange"
import { MS_IN_HOUR, MS_IN_MIN } from "lib/shared/utils/time"
import { MouseEventHandler, useEffect, useRef, useState } from "react"
import { useEvent } from "react-use"
import styled from "styled-components"

import { HSLA } from "../colors/HSLA"
import { ChevronDownIcon } from "../icons/ChevronDownIcon"
import { ChevronUpIcon } from "../icons/ChevronUpIcon"
import { SeparatedBy, dotSeparator } from "../SeparatedBy"
import { VStack } from "../Stack"
import { Text } from "../Text"
import { formatDuration } from "../utils/formatDuration"
import { getVerticalMarginCSS } from "../utils/getVerticalMarginCSS"
import { HourSpace } from "./HourSpace"
import { InteractiveBoundaryArea } from "./InteractiveBoundaryArea"
import { MaxIntervalEndBoundary } from "./MaxIntervalEndBoundary"

export interface TimeInputProps {
  color: HSLA
  value: number
  onChange: (value: number) => void
  startOfDay: number
  startHour: number
  endHour: number
  max?: number

  intialValue: number

  pxInHour?: number
}

const Container = styled.div`
  ${getVerticalMarginCSS(8)}
  user-select: none;
`

const currentLineHeightInPx = 2

const CurrentLine = styled.div`
  position: absolute;
  width: 100%;
  border-radius: 100px;
  height: ${currentLineHeightInPx}px;
`

const TimeValue = styled(Text)`
  position: absolute;
  width: 100%;
  font-size: 14px;
  transition: none;
`

const Session = styled.div`
  width: 100%;
  position: absolute;
`

export const TimeInput = ({
  color,
  value,
  onChange,
  startOfDay,
  endHour,
  startHour,
  pxInHour = 60,
  max: optionalMax,
  intialValue,
}: TimeInputProps) => {
  const hoursCount = endHour - startHour

  const max = optionalMax ?? startOfDay + MS_IN_HOUR * endHour

  const mintimeStart = startOfDay + MS_IN_HOUR * startHour
  const timelineStart = mintimeStart

  const height = hoursCount * pxInHour
  const pxInMs = height / (hoursCount * MS_IN_HOUR)

  const [isActive, setIsActive] = useState(false)

  useEvent("mouseup", () => setIsActive(false))

  const containerElement = useRef<HTMLDivElement | null>(null)
  const timeElement = useRef<HTMLDivElement | null>(null)
  useEffect(() => {
    timeElement.current?.scrollIntoView()
  }, [timeElement])

  const handleMouseMove: MouseEventHandler<HTMLDivElement> = ({ clientY }) => {
    if (!isActive) return

    const containerRect = containerElement?.current?.getBoundingClientRect()
    if (!containerRect) return

    const y =
      enforceRange(clientY, containerRect.top, containerRect.bottom) -
      containerRect.top

    const timestamp = timelineStart + y / pxInMs

    onChange(enforceRange(timestamp, mintimeStart, max))
  }

  const cursor = isActive ? "row-resize" : undefined

  const valueInPx = pxInMs * (value - timelineStart)

  const minDiff = Math.round(intialValue - value) / MS_IN_MIN

  return (
    <Container
      ref={containerElement}
      style={{ height: height, cursor }}
      onMouseMove={handleMouseMove}
    >
      <HourSpace
        formatHour={(hour) => {
          const date = new Date(startOfDay + hour * MS_IN_HOUR)
          return date.toLocaleString(undefined, { hour: "numeric" })
        }}
        start={startHour}
        end={endHour}
        hourLabelWidthInPx={20}
      >
        {optionalMax && (
          <MaxIntervalEndBoundary
            timestamp={optionalMax}
            y={pxInMs * (optionalMax - timelineStart)}
            isActive={isActive}
          />
        )}

        <Session
          style={{
            top: valueInPx,
            height: pxInMs * (max - timelineStart) - valueInPx,
            background: color.getVariant({ a: () => 0.2 }).toCssValue(),
          }}
        />

        <CurrentLine
          ref={timeElement}
          style={{
            background: color.toCssValue(),
            top: valueInPx - currentLineHeightInPx / 2,
          }}
        />

        <TimeValue
          style={{
            top: valueInPx - 20,
          }}
        >
          <SeparatedBy separator={dotSeparator}>
            <Text>
              {new Date(value).toLocaleTimeString(undefined, {
                hour: "2-digit",
                minute: "2-digit",
              })}
            </Text>
            {minDiff !== 0 && (
              <Text as="span" color="supporting">
                {formatDuration(Math.abs(minDiff), "min")}{" "}
                {minDiff < 0 ? "later" : "earlier"}
              </Text>
            )}
          </SeparatedBy>
        </TimeValue>

        {!isActive && (
          <InteractiveBoundaryArea
            y={valueInPx}
            onMouseDown={() => setIsActive(true)}
          >
            <VStack style={{ color: color.toCssValue() }} alignItems="center">
              <ChevronUpIcon />
              <ChevronDownIcon />
            </VStack>
          </InteractiveBoundaryArea>
        )}
      </HourSpace>
    </Container>
  )
}

The component receives the color for the timeline, timestamp for value, on change handler, timestamp of day start, start and end hours of the editor, and optional max value that could be before the end hour, initial value to show the difference, and optional amount of pixels in an hour.

First, we render the fixed-height container that we keep ref of for later usage of its bounding box. Inside we show the hour space component that will render lines with hours and display its children inside of an absolutely positioned element overlaying the lines. If we have the max property, we display a dashed line. At Increaser, it would be the current time because you can start a session in the future. Also, we can highlight the interval from the value to the max timestamp with an almost transparent section. Next, we render the current line representing the value. To make it more friendly for the user, we also want to display the current value time together with the duration relative to the initial value.

Finally, we render an interactive area that will listen to the mouse-down event. Once the user pressed on that area, we no longer need to display it, and we can start tracking position with the container's handleMouseMove function. Here we rely on clientY from the event payload and calculate the new value based on its relation to the bounding box. To stop listening for mouse move, we add the mouse-up event listener to the window and set the isActive flag to false.