Skip to the code
cf.

Image carousel

The tea shop's home page shows photos of its teas in a carousel. Build App: it takes slides, a list of { src, alt }, and autoplay (default false). When slides isn't given, as in the Preview, use a few of your own (the images don't need to exist).

Render a <section aria-roledescription="carousel" aria-label="Tea gallery">. Inside, show only the current slide's <img> (with its alt), a Previous and a Next button, and one dot per slide: a button with aria-label like Go to slide 2. The current slide's dot has aria-current="true"; the others don't. The first slide shows at first.

Next shows the next slide, and on the last slide goes back to the first. Previous shows the one before, and on the first goes to the last. Clicking a dot shows its slide.

With autoplay, move to the next slide every 3 seconds, wrapping around like Next. Pause while the pointer is over the carousel or the focus is anywhere inside it. When it resumes, the next move comes a full 3 seconds later. Without autoplay, run no timer at all, and leave no timer running when the carousel is removed.

<App autoplay slides={[
  { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
  { src: "/teas/assam.jpg", alt: "Assam in a cup" },
]} />
 
// renders:
<section aria-roledescription="carousel" aria-label="Tea gallery">
  <img src="/teas/sencha.jpg" alt="Sencha leaves" />
  <button>Previous</button>
  <button>Next</button>
  <button aria-label="Go to slide 1" aria-current="true"></button>
  <button aria-label="Go to slide 2"></button>
</section>

What the tests check

  1. shows the first slide, with its dot current
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    render(<App slides={slides} />);
    expect(screen.getByRole("region", { name: "Tea gallery" })).toHaveAttribute("aria-roledescription", "carousel");
    expect(screen.getAllByRole("img")).toHaveLength(1);
    expect(shown()).toBe("Sencha leaves");
    expect(screen.getByRole("button", { name: "Go to slide 1" })).toHaveAttribute("aria-current", "true");
    for (const n of [2, 3, 4]) {
      expect(screen.getByRole("button", { name: "Go to slide " + n })).not.toHaveAttribute("aria-current", "true");
    }
    
  2. Next and Previous move one slide
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    render(<App slides={slides} />);
    await user.click(screen.getByRole("button", { name: "Next" }));
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(shown()).toBe("Rolled oolong");
    await user.click(screen.getByRole("button", { name: "Previous" }));
    expect(shown()).toBe("Assam in a cup");
    
  3. wraps around at both ends
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    render(<App slides={slides} />);
    await user.click(screen.getByRole("button", { name: "Previous" }));
    expect(shown()).toBe("Rooibos tea");
    await user.click(screen.getByRole("button", { name: "Next" }));
    expect(shown()).toBe("Sencha leaves");
    
  4. a dot jumps to its slide and becomes current
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    render(<App slides={slides} />);
    await user.click(screen.getByRole("button", { name: "Go to slide 3" }));
    expect(shown()).toBe("Rolled oolong");
    expect(screen.getByRole("button", { name: "Go to slide 3" })).toHaveAttribute("aria-current", "true");
    expect(screen.getByRole("button", { name: "Go to slide 1" })).not.toHaveAttribute("aria-current", "true");
    
  5. autoplay moves on every 3 seconds and wraps around
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    const clock = useFakeTimers();
    render(<App slides={slides} autoplay />);
    await clock.advance(2999);
    expect(shown()).toBe("Sencha leaves");
    await clock.advance(1);
    expect(shown()).toBe("Assam in a cup");
    await clock.advance(9000);
    expect(shown()).toBe("Sencha leaves");
    expect(screen.getByRole("button", { name: "Go to slide 1" })).toHaveAttribute("aria-current", "true");
    
  6. without autoplay nothing moves and no timer runs
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    const clock = useFakeTimers();
    render(<App slides={slides} />);
    expect(clock.pending()).toBe(0);
    await clock.advance(10000);
    expect(shown()).toBe("Sencha leaves");
    
  7. pauses while the pointer is over it
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    const clock = useFakeTimers();
    render(<App slides={slides} autoplay />);
    await user.hover(screen.getByRole("img"));
    await clock.advance(7000);
    expect(shown()).toBe("Sencha leaves");
    await user.unhover(screen.getByRole("img"));
    await clock.advance(2999);
    expect(shown()).toBe("Sencha leaves");
    await clock.advance(1);
    expect(shown()).toBe("Assam in a cup");
    
  8. pauses while the focus is inside it
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    const clock = useFakeTimers();
    render(
      <>
        <App slides={slides} autoplay />
        <button>Outside</button>
      </>,
    );
    await user.tab();
    await clock.advance(4000);
    await user.tab();
    await clock.advance(4000);
    expect(shown()).toBe("Sencha leaves");
    await user.click(screen.getByRole("button", { name: "Outside" }));
    await clock.advance(3000);
    expect(shown()).toBe("Assam in a cup");
    
  9. clears its timer when removed
    const slides = [
      { src: "/teas/sencha.jpg", alt: "Sencha leaves" },
      { src: "/teas/assam.jpg", alt: "Assam in a cup" },
      { src: "/teas/oolong.jpg", alt: "Rolled oolong" },
      { src: "/teas/rooibos.jpg", alt: "Rooibos tea" },
    ];
    const shown = () => screen.getByRole("img").getAttribute("alt");
    const clock = useFakeTimers();
    const { unmount } = render(<App slides={slides} autoplay />);
    await clock.advance(1000);
    unmount();
    expect(clock.pending()).toBe(0);
    

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