Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 44 additions & 31 deletions src/__snapshots__/App.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -136,37 +136,50 @@ exports[`App > matches the snapshot 1`] = `
<div
class="book-card-content"
>
<h2>
A Wizard of Earthsea
</h2>
<p
class="book-card-authors"
>
Ursula K. Le Guin
</p>
<p>
First published:
1968
</p>
<p
class="book-card-rating"
>
Rating:
4.8
/ 5
(20 ratings)
</p>
<ul
aria-label="Subjects, showing 2 of 2"
class="book-card-subjects"
>
<li>
fantasy
</li>
<li>
magic
</li>
</ul>
<div
class="book-card-metadata"
>
<h2>
A Wizard of Earthsea
</h2>
<p
class="book-card-authors"
title="Ursula K. Le Guin"
>
Ursula K. Le Guin
</p>
<p
class="book-card-publication"
>
First published: 1968
</p>
<p
class="book-card-rating"
>
Rating:
4.8
/ 5
(20 ratings)
</p>
</div>
<div
class="book-card-subjects-section"
>
<ul
aria-label="Subjects, showing 2 of 2"
class="book-card-subjects"
>
<li>
fantasy
</li>
<li>
magic
</li>
</ul>
<div
class="book-card-subjects-controls"
/>
</div>
<button
aria-label="Add A Wizard of Earthsea to favorites"
aria-pressed="false"
Expand Down
101 changes: 78 additions & 23 deletions src/components/BookCard.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,40 +11,60 @@

.book-card-cover {
display: grid;
align-self: stretch;
min-height: 12rem;
align-self: center;
width: 100%;
height: 18rem;
min-width: 0;
place-items: center;
background-color: #edf2f7;
color: #52606d;
text-align: center;
}

.book-card-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}

.book-card-cover p {
margin: 1rem;
}

.book-card-content {
min-width: 0;
padding: 1.25rem 1.25rem 1.25rem 0;
}

.book-card-metadata {
height: 7.5rem;
overflow: visible;
}

.book-card-content h2 {
margin: 0;
max-height: 2.4em;
color: #243b53;
line-height: 1.2;
overflow-wrap: anywhere;
overflow: hidden;
}

.book-card-content p {
margin: 0.5rem 0 0;
margin: 0.25rem 0 0;
line-height: 1.2;
color: #52606d;
}

.book-card-authors {
height: 1.2em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
}

Expand All @@ -53,13 +73,45 @@
font-weight: 600;
}

.book-card-publication {
min-height: 1.2em;
overflow: visible;
}

.book-card-subjects-section {
position: relative;
display: flex;
flex-direction: column;
height: 9rem;
min-height: 9rem;
margin-top: 1.5rem;
}

.book-card-subjects-section.is-expanded {
height: 9rem;
min-height: 9rem;
}

.book-card-subjects {
display: flex;
flex: 0 0 7rem;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1rem 0 0;
align-content: flex-start;
height: 7rem;
min-height: 7rem;
margin: 0;
padding: 0;
list-style: none;
overflow-x: hidden;
overflow-y: hidden;
}

.book-card-subjects.is-expanded {
flex: 1 1 auto;
height: auto;
min-height: 0;
overflow-y: auto;
}

.book-card-subjects li {
Expand All @@ -70,31 +122,34 @@
color: #243b53;
padding: 0.25rem 0.625rem;
font-size: 0.875rem;
overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.book-card-subjects .book-card-subjects-more {
padding: 0;
background-color: #edf2f7;
color: #52606d;
font-weight: 600;
.book-card-subjects-controls {
display: flex;
flex: 0 0 2rem;
align-items: flex-start;
padding-top: 0.5rem;
}

.book-card-subjects-more button {
.book-card-subjects-controls button {
max-width: 100%;
border: 0;
border-radius: inherit;
background: transparent;
color: inherit;
border-radius: 999px;
background-color: #edf2f7;
color: #52606d;
padding: 0.25rem 0.625rem;
font: inherit;
font-weight: 600;
overflow-wrap: anywhere;
cursor: pointer;
}

.book-card-subjects-more button:focus-visible {
outline: 2px solid #243b53;
outline-offset: 2px;
.book-card-subjects-controls button:focus-visible {
outline: 0.1875rem solid #2c3e50;
outline-offset: 0.125rem;
}

@media (max-width: 32rem) {
Expand All @@ -103,14 +158,14 @@
}

.book-card-cover {
min-height: 0;
aspect-ratio: 4 / 3;
width: 100%;
height: 16rem;
}

.book-card-content {
position: relative;
z-index: 1;
padding: 0 1.25rem 1.25rem;
padding: 1rem 1.25rem 1.25rem;
border-top: 1px solid #d5dde5;
background-color: #ffffff;
}
Expand Down
67 changes: 66 additions & 1 deletion src/components/BookCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import type { Book } from "../types";
Expand Down Expand Up @@ -48,6 +48,7 @@ describe("BookCard", () => {
expect(screen.queryByText(/^Rating:/)).not.toBeInTheDocument();
expect(screen.getByText("No cover available")).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(screen.getByText("No cover available").closest(".book-card-cover")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});

Expand All @@ -66,6 +67,61 @@ describe("BookCard", () => {
expect(screen.queryByRole("button", { name: /subjects/ })).not.toBeInTheDocument();
});

it("shows the fallback when a cover image fails to load", () => {
render(<BookCard book={fullBook} />);

const image = screen.getByRole("img", { name: "Cover of A Wizard of Earthsea" });
fireEvent.error(image);

expect(screen.queryByRole("img")).not.toBeInTheDocument();
const fallback = screen.getByText("No cover available");
expect(fallback).toBeInTheDocument();
expect(fallback.closest(".book-card-cover")).toBeInTheDocument();
});

it("keeps long author lists on one line with the full text available", () => {
const longAuthors = [
"Ursula K. Le Guin",
"Another Author",
"A Third Author",
"A Fourth Author",
];

render(<BookCard book={{ ...fullBook, authors: longAuthors }} />);

const authorText = longAuthors.join(", ");
const authorElement = screen.getByText(authorText);
expect(authorElement).toHaveClass("book-card-authors");
expect(authorElement).toHaveAttribute("title", authorText);
});

it("keeps the subject area fixed while expanding and resets its scroll on collapse", async () => {
const user = userEvent.setup();
const bookWithManySubjects: Book = {
...fullBook,
subjects: Array.from({ length: 12 }, (_, index) => `Subject ${index + 1}`),
};

render(<BookCard book={bookWithManySubjects} />);

const subjects = screen.getByRole("list", { name: "Subjects, showing 3 of 12" });
expect(subjects).toHaveClass("book-card-subjects");
expect(subjects.querySelector("button")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show 9 more subjects" })).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Show 9 more subjects" }));

expect(screen.getByRole("list", { name: "Subjects, showing 12 of 12" })).toBe(subjects);
expect(subjects).toHaveClass("is-expanded");
expect(screen.getByRole("button", { name: "Show fewer subjects" })).toBeInTheDocument();

subjects.scrollTop = 100;
await user.click(screen.getByRole("button", { name: "Show fewer subjects" }));

expect(subjects.scrollTop).toBe(0);
expect(screen.getByRole("list", { name: "Subjects, showing 3 of 12" })).toBe(subjects);
});

it("expands and collapses long subject lists", async () => {
const user = userEvent.setup();
const bookWithManySubjects: Book = {
Expand Down Expand Up @@ -108,4 +164,13 @@ describe("BookCard", () => {
expect(screen.queryByText("Earthsea")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show 3 more subjects" })).toBeInTheDocument();
});

it("keeps long subject text in the DOM for CSS truncation", () => {
const longSubject = "A very long subject name that should stay on one line";
render(<BookCard book={{ ...fullBook, subjects: [longSubject] }} />);

const subject = screen.getByText(longSubject);
expect(subject).toBeInTheDocument();
expect(subject.closest("li")).toBeInTheDocument();
});
});
Loading