Skip to the code
cf.

Color swatches

Build App: the color picker for a tea tin's label. It takes colors, a list of { name, hex } with unique names, and shows them as a group of radio buttons: a <fieldset> with the legend Color, and one radio per color, labelled by its name, with a small swatch of the color beside it. When colors isn't given, as in the Preview, use a few colors of your own.

The first color is chosen at first. Clicking a color, or its name, chooses it, and only one color is ever chosen.

A preview, <section aria-label="Preview">, shows a large box filled with the chosen color and, under it, the color's name and hex code, like Matcha #7A9A3A.

The keyboard works as in any radio group: Tab lands on the chosen color, and the arrow keys move the choice to the next or previous color, wrapping round at the ends. Real <input type="radio"> elements do this for you when the radios in one group share a name.

A page can show more than one picker, so each picker's radios need a name of their own. Otherwise the arrow keys in one picker would walk into the other.

<fieldset>
  <legend>Color</legend>
  <label><input type="radio" name="tin-color" checked /> <span style="background: #7A9A3A"></span> Matcha</label>
  <label><input type="radio" name="tin-color" /> <span style="background: #B5651D"></span> Oolong</label>
</fieldset>
<section aria-label="Preview">
  <div style="background: #7A9A3A"></div>
  <p>Matcha <code>#7A9A3A</code></p>
</section>

What the tests check

  1. shows a radio for each color in a group called Color
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Butterfly pea", hex: "#3F51B5" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(<App colors={colors} />);
    const radios = within(screen.getByRole("group", { name: "Color" })).getAllByRole("radio");
    expect(radios).toHaveLength(4);
    colors.forEach((color) => expect(screen.getByRole("radio", { name: color.name })).toBeInTheDocument());
    
  2. the first color is chosen at first
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Butterfly pea", hex: "#3F51B5" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(<App colors={colors} />);
    expect(screen.getByRole("radio", { name: "Matcha" })).toBeChecked();
    expect(screen.getByRole("radio", { name: "Oolong" })).not.toBeChecked();
    const preview = screen.getByRole("region", { name: "Preview" });
    expect(preview).toHaveTextContent("Matcha");
    expect(preview).toHaveTextContent("#7A9A3A");
    
  3. clicking a color chooses it and updates the preview
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Butterfly pea", hex: "#3F51B5" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(<App colors={colors} />);
    await user.click(screen.getByRole("radio", { name: "Oolong" }));
    expect(screen.getByRole("radio", { name: "Oolong" })).toBeChecked();
    expect(screen.getByRole("radio", { name: "Matcha" })).not.toBeChecked();
    const preview = screen.getByRole("region", { name: "Preview" });
    expect(preview).toHaveTextContent("Oolong");
    expect(preview).toHaveTextContent("#B5651D");
    expect(preview).not.toHaveTextContent("Matcha");
    
  4. clicking a color's name chooses it
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Butterfly pea", hex: "#3F51B5" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(<App colors={colors} />);
    await user.click(within(screen.getByRole("group", { name: "Color" })).getByText("Rooibos"));
    expect(screen.getByRole("radio", { name: "Rooibos" })).toBeChecked();
    expect(screen.getByRole("region", { name: "Preview" })).toHaveTextContent("#A0522D");
    
  5. the arrow keys move the choice
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Butterfly pea", hex: "#3F51B5" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(<App colors={colors} />);
    await user.tab();
    expect(screen.getByRole("radio", { name: "Matcha" })).toHaveFocus();
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("radio", { name: "Oolong" })).toBeChecked();
    expect(screen.getByRole("radio", { name: "Oolong" })).toHaveFocus();
    await user.keyboard("{ArrowDown}");
    expect(screen.getByRole("radio", { name: "Butterfly pea" })).toBeChecked();
    expect(screen.getByRole("region", { name: "Preview" })).toHaveTextContent("Butterfly pea");
    await user.keyboard("{ArrowUp}");
    expect(screen.getByRole("radio", { name: "Oolong" })).toBeChecked();
    expect(screen.getByRole("region", { name: "Preview" })).toHaveTextContent("Oolong");
    
  6. the arrow keys wrap round at the ends
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Butterfly pea", hex: "#3F51B5" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(<App colors={colors} />);
    await user.tab();
    await user.keyboard("{ArrowLeft}");
    expect(screen.getByRole("radio", { name: "Rooibos" })).toBeChecked();
    expect(screen.getByRole("region", { name: "Preview" })).toHaveTextContent("Rooibos");
    await user.keyboard("{ArrowRight}");
    expect(screen.getByRole("radio", { name: "Matcha" })).toBeChecked();
    expect(screen.getByRole("region", { name: "Preview" })).toHaveTextContent("Matcha");
    
  7. Tab lands on the chosen color
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Butterfly pea", hex: "#3F51B5" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(
      <>
        <button>Before</button>
        <App colors={colors} />
        <button>After</button>
      </>
    );
    await user.click(screen.getByRole("radio", { name: "Butterfly pea" }));
    await user.click(screen.getByRole("button", { name: "Before" }));
    await user.tab();
    expect(screen.getByRole("radio", { name: "Butterfly pea" })).toHaveFocus();
    await user.tab();
    expect(screen.getByRole("button", { name: "After" })).toHaveFocus();
    
  8. two pickers on one page keep to themselves
    const colors = [
      { name: "Matcha", hex: "#7A9A3A" },
      { name: "Oolong", hex: "#B5651D" },
      { name: "Rooibos", hex: "#A0522D" },
    ];
    render(
      <>
        <App colors={colors} />
        <App colors={colors} />
      </>
    );
    const [first, second] = screen.getAllByRole("group", { name: "Color" }).map((group) => within(group));
    await user.click(second.getByRole("radio", { name: "Oolong" }));
    await user.click(first.getByRole("radio", { name: "Matcha" }));
    await user.keyboard("{ArrowLeft}");
    expect(first.getByRole("radio", { name: "Rooibos" })).toBeChecked();
    expect(first.getByRole("radio", { name: "Rooibos" })).toHaveFocus();
    expect(second.getByRole("radio", { name: "Oolong" })).toBeChecked();
    expect(second.getByRole("radio", { name: "Rooibos" })).not.toBeChecked();
    

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