Increaser has this beautiful checklist for today's tasks, and believe it or not, the first version took me just an hour or so to implement. That's the power of abstract reusable components. Let me share its implementation, so you can find useful bits to finish your front-end tasks faster.
import type { NextPage } from "next"
import { DemoPage } from "components/DemoPage"
import { useState } from "react"
import { VStack } from "lib/ui/Stack"
import { ChecklistItem } from "lib/ui/checklist/ChecklistItem"
import { Opener } from "lib/ui/Opener"
import { AddChecklistItemPrompt } from "lib/ui/checklist/AddChecklistItemPrompt"
import { ChecklistItemForm } from "lib/ui/checklist/ChecklistItemForm"
import { updateAtIndex } from "lib/shared/utils/updateAtIndex"
interface Task {
name: string
isComplete: boolean
}
const defaultTasks: Task[] = [
{ name: "Go to the gym", isComplete: false },
{ name: "Buy groceries", isComplete: false },
{ name: "Walk the dog", isComplete: false },
]
const ChecklistPage: NextPage = () => {
const [tasks, setTasks] = useState(defaultTasks)
return (
<DemoPage title="Checklist">
<VStack gap={16}>
{tasks.map(({ name, isComplete }, index) => (
<ChecklistItem
key={index}
value={isComplete}
onChange={(value) => {
setTasks(
updateAtIndex(tasks, index, (task) => ({
...task,
isComplete: value,
}))
)
}}
name={name}
/>
))}
<Opener
renderOpener={({ isOpen, onOpen }) =>
isOpen ? null : (
<AddChecklistItemPrompt onClick={onOpen}>
Add task
</AddChecklistItemPrompt>
)
}
renderContent={({ onClose }) => (
<ChecklistItemForm
namePlaceholder="Enter task name"
onSubmit={({ name }) => {
setTasks([...tasks, { name, isComplete: false }])
onClose()
}}
onCancel={onClose}
/>
)}
/>
</VStack>
</DemoPage>
)
}
export default ChecklistPage
In this demo, we have a list of tasks in the useState
hook. We map through the items and render a ChecklistItem
for each task. We also render an Opener
component that renders a button to add a new task. When the button is clicked, the Opener renders a ChecklistItemForm
component to add a new task.
import { ReactNode } from "react"
import styled, { css } from "styled-components"
import { Hoverable } from "../Hoverable"
import { defaultTransitionCSS } from "../animations/transitions"
import { CheckIcon } from "../icons/CheckIcon"
import {
InvisibleHTMLCheckboxProps,
InvisibleHTMLCheckbox,
} from "../inputs/Checkbox/InvisibleHTMLCheckbox"
import { centerContentCSS } from "../utils/centerContentCSS"
import { Text } from "../Text"
import { ChecklistItemFrame } from "./ChecklistItemFrame"
interface ChecklistItemProps extends InvisibleHTMLCheckboxProps {
name: ReactNode
style?: React.CSSProperties
}
export const Box = styled.div<{ isChecked: boolean }>`
width: 100%;
aspect-ratio: 1/1;
${centerContentCSS};
border-radius: 4px;
border: 2px solid ${({ theme }) => theme.colors.textSupporting3.toCssValue()};
color: ${({ theme }) => theme.colors.background.toCssValue()};
${defaultTransitionCSS}
${({ isChecked }) =>
isChecked &&
css`
background: ${({ theme }) => theme.colors.primary.toCssValue()};
border-color: ${({ theme }) => theme.colors.primary.toCssValue()};
`};
`
const Content = styled(Text)<{ isChecked: boolean }>`
max-width: 100%;
position: relative;
color: ${({ theme, isChecked }) =>
(isChecked ? theme.colors.textSupporting : theme.colors.text).toCssValue()};
`
const Line = styled.span<{ isChecked: boolean }>`
position: absolute;
${defaultTransitionCSS};
left: 0;
border-top: 2px solid;
bottom: 10px;
width: ${({ isChecked }) => (isChecked ? "100%" : "0%")};
`
export const ChecklistItem = ({
value,
onChange,
name,
style,
}: ChecklistItemProps) => {
return (
<Hoverable style={style} as="label">
<ChecklistItemFrame>
<Box isChecked={value}>{value && <CheckIcon />}</Box>
<Content isChecked={value} cropped>
{name}
<Line isChecked={value} />
</Content>
<InvisibleHTMLCheckbox value={value} onChange={onChange} />
</ChecklistItemFrame>
</Hoverable>
)
}
Let's start with the ChecklistItem
component. It receives the same properties as the InvisibleHTMLCheckbox
component, but also a name
property that is rendered as a label and a style
property that is passed to the root element. The component renders a Hoverable
component as a label. You can read more about the Hoverable
component in this article. Inside we place a ChecklistItemFrame
that serve as a frame we'll use for both a prompt to add a task, and the form to add a task. By using the same component to define shape of an interactive area we make the interface more consistent and therefore more intuitive and pleasing to the user.
import styled from "styled-components"
export const ChecklistItemFrame = styled.div`
display: grid;
width: 100%;
grid-template-columns: 24px 1fr;
align-items: center;
justify-items: start;
gap: 12px;
font-weight: 500;
`
To make the Box
component square we use combination of a 100% width
and aspect-ratio
of one to one. When the isChecked
property is true, we change the background and border color to the primary color. We also render a CheckIcon
component when the isChecked
property is true.
Next goes the Content
component that renders the name of the task. We also use a Line
component to animate the line crossing the name when the task is completed. We use the isChecked
property to animate the line from zero to full width.
Finally, we render the InvisibleHTMLCheckbox
component that is used to toggle the isChecked
property. Notice we have a label element as a root element, so we don't have to add onClick
anywhere, and solely rely on native checkbox input to do its job even so it's invisible.
To start the flow of adding a new task we have AddChecklistItemPrompt
component. It receives the onClick
and children
properties. Here we also use the Hoverable
and ChecklistItemFrame
components to define the shape of the interactive area.
import {
ClickableComponentProps,
ComponentWithChildrenProps,
} from "lib/shared/props"
import { Center } from "../Center"
import { Hoverable } from "../Hoverable"
import { ChecklistItemFrame } from "./ChecklistItemFrame"
import { PlusIcon } from "../icons/PlusIcon"
type AddChecklistItemPromptProps = ClickableComponentProps &
ComponentWithChildrenProps
export const AddChecklistItemPrompt = ({
onClick,
children,
}: AddChecklistItemPromptProps) => {
return (
<Hoverable onClick={onClick}>
<ChecklistItemFrame>
<Center>
<PlusIcon />
</Center>
{children}
</ChecklistItemFrame>
</Hoverable>
)
}
Finally we have the ChecklistItemForm
component that receives the onSubmit
and onCancel
callbacks togehter with the namePlaceholder
property that is used as a placeholder for the name input. We use the useForm
hook from the react-hook-form
library to handle the form state. We also use the useKey
hook from the react-use
library to handle the Escape
key press to cancel the form. Here we also leverage the ChecklistItemFrame
component to make the shape similar to the other items in the checklist.
import { useForm } from "react-hook-form"
import { useKey } from "react-use"
import styled from "styled-components"
import { Box } from "./ChecklistItem"
import { ChecklistItemFrame } from "./ChecklistItemFrame"
interface ChecklistItemFormShape {
name: string
}
const Input = styled.input`
background: transparent;
border: none;
height: 100%;
width: 100%;
color: ${({ theme }) => theme.colors.text.toCssValue()};
outline: none;
&::placeholder {
color: ${({ theme }) => theme.colors.textSupporting3.toCssValue()};
}
`
interface ChecklistItemFormProps {
onSubmit: (value: ChecklistItemFormShape) => void
onCancel: () => void
namePlaceholder?: string
}
export const ChecklistItemForm = ({
onSubmit,
onCancel,
namePlaceholder = "Name",
}: ChecklistItemFormProps) => {
const { register, handleSubmit } = useForm<ChecklistItemFormShape>({
mode: "all",
defaultValues: {
name: "",
},
})
useKey("Escape", onCancel)
return (
<ChecklistItemFrame
as="form"
style={{ width: "100%" }}
onBlur={handleSubmit(onSubmit, onCancel)}
onSubmit={handleSubmit(onSubmit)}
>
<Box isChecked={false} />
<Input
placeholder={namePlaceholder}
autoFocus
{...register("name", { required: true })}
/>
</ChecklistItemFrame>
)
}