Skip to the code
cf.

Poll widget

The tea shop is asking customers which tea to stock next. Build Poll, the default export: a question, one button per option, and the results once you've voted.

It takes question, a string, and options, an array of { id, label, votes } where votes is how many people voted before you. Both are optional, with a tea question and three teas as defaults. Don't change the options array or its objects.

Show the question as an <h2>, then the options as a list: one <li> per option, in order, each with a button whose text is the option's label. Below the list, show the total number of votes: 9 votes, or 1 vote when there's exactly one.

Clicking an option votes for it. Its button gets aria-pressed="true" (the others have aria-pressed="false"), your vote is added to its count, and the total goes up by one. Once you've voted, each <li> also shows the option's share of all the votes as a whole percentage, like 50%, with a bar as wide as that share (the tests check the text, not the bar). Before you vote, no percentages show.

You get one vote. Clicking your choice again changes nothing. Clicking another option moves your vote there. Round each share with Math.round, so the shares may not add up to exactly 100.

<Poll
  question="Which tea should we stock next?"
  options={[
    { id: "matcha", label: "Matcha", votes: 4 },
    { id: "hojicha", label: "Hojicha", votes: 3 },
    { id: "genmaicha", label: "Genmaicha", votes: 2 },
  ]}
/>
 
// after voting for Matcha:
<li><button aria-pressed="true">Matcha</button> 50% <div style="width: 50%"></div></li>
<li><button aria-pressed="false">Hojicha</button> 30% ...</li>
<li><button aria-pressed="false">Genmaicha</button> 20% ...</li>
<p>10 votes</p>

What the tests check

  1. shows the question, a button per option, and the total
    const options = [
      { id: "matcha", label: "Matcha", votes: 4 },
      { id: "hojicha", label: "Hojicha", votes: 3 },
      { id: "genmaicha", label: "Genmaicha", votes: 2 },
    ];
    render(<App question="Which tea should we stock next?" options={options} />);
    expect(screen.getByRole("heading", { level: 2, name: "Which tea should we stock next?" })).toBeInTheDocument();
    const items = screen.getAllByRole("listitem");
    expect(items.length).toBe(3);
    expect(within(items[0]).getByRole("button", { name: "Matcha" })).toHaveAttribute("aria-pressed", "false");
    expect(within(items[2]).getByRole("button", { name: "Genmaicha" })).toHaveAttribute("aria-pressed", "false");
    expect(screen.getByText("9 votes")).toBeInTheDocument();
    expect(screen.queryByText(/%/)).not.toBeInTheDocument();
    
  2. voting shows each option's share, counting your vote
    const options = [
      { id: "matcha", label: "Matcha", votes: 4 },
      { id: "hojicha", label: "Hojicha", votes: 3 },
      { id: "genmaicha", label: "Genmaicha", votes: 2 },
    ];
    render(<App options={options} />);
    await user.click(screen.getByRole("button", { name: "Matcha" }));
    const items = screen.getAllByRole("listitem");
    expect(items[0]).toHaveTextContent("50%");
    expect(items[1]).toHaveTextContent("30%");
    expect(items[2]).toHaveTextContent("20%");
    expect(screen.getByText("10 votes")).toBeInTheDocument();
    
  3. marks the option you voted for
    const options = [
      { id: "matcha", label: "Matcha", votes: 4 },
      { id: "hojicha", label: "Hojicha", votes: 3 },
    ];
    render(<App options={options} />);
    await user.click(screen.getByRole("button", { name: "Hojicha" }));
    expect(screen.getByRole("button", { name: "Hojicha" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByRole("button", { name: "Matcha" })).toHaveAttribute("aria-pressed", "false");
    
  4. clicking your choice again doesn't vote twice
    const options = [
      { id: "matcha", label: "Matcha", votes: 4 },
      { id: "hojicha", label: "Hojicha", votes: 3 },
      { id: "genmaicha", label: "Genmaicha", votes: 2 },
    ];
    render(<App options={options} />);
    await user.click(screen.getByRole("button", { name: "Matcha" }));
    await user.click(screen.getByRole("button", { name: "Matcha" }));
    expect(screen.getByText("10 votes")).toBeInTheDocument();
    expect(screen.getAllByRole("listitem")[0]).toHaveTextContent("50%");
    expect(screen.getByRole("button", { name: "Matcha" })).toHaveAttribute("aria-pressed", "true");
    
  5. clicking another option moves your vote
    const options = [
      { id: "matcha", label: "Matcha", votes: 4 },
      { id: "hojicha", label: "Hojicha", votes: 3 },
      { id: "genmaicha", label: "Genmaicha", votes: 2 },
    ];
    render(<App options={options} />);
    await user.click(screen.getByRole("button", { name: "Matcha" }));
    await user.click(screen.getByRole("button", { name: "Hojicha" }));
    const items = screen.getAllByRole("listitem");
    expect(items[0]).toHaveTextContent("40%");
    expect(items[1]).toHaveTextContent("40%");
    expect(items[2]).toHaveTextContent("20%");
    expect(screen.getByText("10 votes")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Hojicha" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByRole("button", { name: "Matcha" })).toHaveAttribute("aria-pressed", "false");
    
  6. rounds shares to whole percentages
    const options = [
      { id: "matcha", label: "Matcha", votes: 1 },
      { id: "hojicha", label: "Hojicha", votes: 1 },
      { id: "genmaicha", label: "Genmaicha", votes: 0 },
    ];
    render(<App options={options} />);
    await user.click(screen.getByRole("button", { name: "Matcha" }));
    const items = screen.getAllByRole("listitem");
    expect(items[0]).toHaveTextContent("67%");
    expect(items[1]).toHaveTextContent("33%");
    expect(items[2]).toHaveTextContent(/(^|[^0-9])0%/);
    expect(screen.getByText("3 votes")).toBeInTheDocument();
    
  7. says 1 vote, not 1 votes
    const options = [
      { id: "matcha", label: "Matcha", votes: 0 },
      { id: "hojicha", label: "Hojicha", votes: 0 },
    ];
    render(<App options={options} />);
    expect(screen.getByText("0 votes")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Hojicha" }));
    expect(screen.getByText("1 vote")).toBeInTheDocument();
    expect(screen.getAllByRole("listitem")[1]).toHaveTextContent("100%");
    
  8. doesn't change the options it's given
    const options = [
      { id: "matcha", label: "Matcha", votes: 4 },
      { id: "hojicha", label: "Hojicha", votes: 3 },
    ];
    const before = JSON.stringify(options);
    render(<App options={options} />);
    await user.click(screen.getByRole("button", { name: "Matcha" }));
    await user.click(screen.getByRole("button", { name: "Hojicha" }));
    expect(JSON.stringify(options)).toBe(before);
    expect(screen.getByText("8 votes")).toBeInTheDocument();
    

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).