Skip to the code
cf.

Character counter

Build App: the box for posting a short note on the tea shop's page. A textarea labelled Message takes notes of up to 140 characters, counted with text.length.

Under it, a counter says how many characters are left: 140 characters left at first, then 1 character left and 0 characters left near the end. Past the limit it says how many to cut instead: 1 character too many, 5 characters too many.

The counter has a data-status the stylesheet uses to color it: "ok" normally, "warning" when fewer than 20 characters are left (19 down to 0), and "over" past the limit. The textarea points at the counter with aria-describedby, so a screen reader reads the count with the box.

A Post button calls the onPost prop with the text as typed, then empties the box. It's disabled when the note is past the limit, and when it's empty or only spaces. onPost may be missing (the Preview doesn't pass it).

<App onPost={(text) => publish(text)} />
 
// renders, with 128 characters typed:
<label>Message <textarea aria-describedby="note-count" /></label>
<p id="note-count" data-status="warning">12 characters left</p>
<button>Post</button>

What the tests check

  1. starts with 140 characters left and Post off
    render(<App />);
    expect(screen.getByText("140 characters left")).toHaveAttribute("data-status", "ok");
    expect(screen.getByRole("button", { name: "Post" })).toBeDisabled();
    
  2. counts down as you type
    render(<App />);
    await user.type(screen.getByLabelText("Message"), "Fresh oolong");
    expect(screen.getByText("128 characters left")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Post" })).toBeEnabled();
    
  3. says 1 character left, not 1 characters
    render(<App />);
    await user.click(screen.getByLabelText("Message"));
    await user.paste("a".repeat(139));
    expect(screen.getByText("1 character left")).toBeInTheDocument();
    
  4. warns once fewer than 20 are left
    render(<App />);
    await user.click(screen.getByLabelText("Message"));
    await user.paste("a".repeat(120));
    expect(screen.getByText("20 characters left")).toHaveAttribute("data-status", "ok");
    await user.type(screen.getByLabelText("Message"), "a");
    expect(screen.getByText("19 characters left")).toHaveAttribute("data-status", "warning");
    
  5. exactly 140 characters can still be posted
    render(<App />);
    await user.click(screen.getByLabelText("Message"));
    await user.paste("a".repeat(140));
    expect(screen.getByText("0 characters left")).toHaveAttribute("data-status", "warning");
    expect(screen.getByRole("button", { name: "Post" })).toBeEnabled();
    
  6. past the limit, it counts what to cut and turns Post off
    render(<App />);
    await user.click(screen.getByLabelText("Message"));
    await user.paste("a".repeat(141));
    expect(screen.getByText("1 character too many")).toHaveAttribute("data-status", "over");
    expect(screen.getByRole("button", { name: "Post" })).toBeDisabled();
    await user.paste("aaaa");
    expect(screen.getByText("5 characters too many")).toHaveAttribute("data-status", "over");
    
  7. the textarea is described by the counter
    render(<App />);
    const box = screen.getByLabelText("Message");
    const id = box.getAttribute("aria-describedby");
    expect(id).toBeTruthy();
    expect(document.getElementById(id)).toHaveTextContent("140 characters left");
    await user.type(box, "Chai");
    expect(document.getElementById(id)).toHaveTextContent("136 characters left");
    
  8. Post sends the text and empties the box
    const onPost = spy();
    render(<App onPost={onPost} />);
    await user.type(screen.getByLabelText("Message"), "Fresh oolong today");
    await user.click(screen.getByRole("button", { name: "Post" }));
    expect(onPost).toHaveBeenCalledTimes(1);
    expect(onPost).toHaveBeenCalledWith("Fresh oolong today");
    expect(screen.getByLabelText("Message")).toHaveValue("");
    expect(screen.getByText("140 characters left")).toBeInTheDocument();
    
  9. a note of only spaces can't be posted
    render(<App />);
    await user.type(screen.getByLabelText("Message"), "   ");
    expect(screen.getByText("137 characters left")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Post" })).toBeDisabled();
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).