Skip to the code
cf.

Card with slots

The shop uses the same card frame for specials, news, and reviews, with different content inside. Build Card, a named export of App.jsx, that draws the frame and leaves the content to whoever uses it. The default export, App, is only for the Preview.

Card takes three props: title (a string), children (whatever JSX you put between <Card> and </Card>), and footer (more JSX, for the bottom of the card). Each is optional.

The card is an <article>. Inside it, in this order: the title in an <h2>, then a <div> holding the children, then a <footer> holding footer.

Leave out the parts that have nothing to show: no <h2> when there's no title, and no <footer> element when footer is missing, null, or false (so footer={isOpen && <a href="/menu">Menu</a>} works).

<Card title="Today's special" footer={<a href="/menu">See the full menu</a>}>
  <p>Hojicha, a roasted green tea.</p>
  <button>Add to order</button>
</Card>
 
// draws:
<article>
  <h2>Today's special</h2>
  <div>
    <p>Hojicha, a roasted green tea.</p>
    <button>Add to order</button>
  </div>
  <footer><a href="/menu">See the full menu</a></footer>
</article>

What the tests check

  1. shows the title as a level 2 heading
    render(<Card title="Today's special">Hojicha</Card>);
    expect(within(screen.getByRole("article")).getByRole("heading", { level: 2, name: "Today's special" })).toBeInTheDocument();
    
  2. shows the children inside the card
    render(
      <Card title="Today's special">
        <p>Hojicha, a roasted green tea.</p>
        <button>Add to order</button>
      </Card>,
    );
    const card = screen.getByRole("article");
    expect(within(card).getByText("Hojicha, a roasted green tea.")).toBeInTheDocument();
    expect(within(card).getByRole("button", { name: "Add to order" })).toBeInTheDocument();
    
  3. children keep their own event handlers
    const onAdd = spy();
    render(
      <Card title="Today's special">
        <button onClick={onAdd}>Add to order</button>
      </Card>,
    );
    await user.click(screen.getByRole("button", { name: "Add to order" }));
    expect(onAdd).toHaveBeenCalledTimes(1);
    
  4. shows the footer JSX in a footer element
    render(
      <Card title="News" footer={<a href="/menu">See the full menu</a>}>
        <p>New teas every Friday.</p>
      </Card>,
    );
    const footer = screen.getByRole("article").querySelector("footer");
    expect(footer).not.toBeNull();
    expect(within(footer).getByRole("link", { name: "See the full menu" })).toHaveAttribute("href", "/menu");
    
  5. puts the heading first, then the children, then the footer
    render(
      <Card title="News" footer={<a href="/menu">See the full menu</a>}>
        <p>New teas every Friday.</p>
      </Card>,
    );
    const parts = [...screen.getByRole("article").children];
    expect(parts.map((part) => part.tagName)).toEqual(["H2", "DIV", "FOOTER"]);
    expect(parts[1]).toHaveTextContent("New teas every Friday.");
    
  6. has no footer element when there's no footer
    render(
      <Card title="News">
        <p>New teas every Friday.</p>
      </Card>,
    );
    expect(screen.getByRole("article").querySelector("footer")).toBeNull();
    
  7. has no footer element when footer is null or false
    const { rerender } = render(<Card title="News" footer={null}>Hello</Card>);
    expect(screen.getByRole("article").querySelector("footer")).toBeNull();
    rerender(<Card title="News" footer={false}>Hello</Card>);
    expect(screen.getByRole("article").querySelector("footer")).toBeNull();
    
  8. has no heading when there's no title
    render(
      <Card footer={<small>Posted on Monday</small>}>
        <p>New teas every Friday.</p>
      </Card>,
    );
    expect(screen.queryByRole("heading")).not.toBeInTheDocument();
    expect(screen.getByText("Posted on Monday")).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).