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
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect } from "storybook/test";
import { CompletionRing, DATA_TEST_ID } from "./CompletionRing";

const meta = {
component: CompletionRing,
tags: ["autodocs"],
args: {
value: 64,
},
} satisfies Meta<typeof CompletionRing>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Empty: Story = {
args: { value: 0, label: "0%" },
play: async ({ canvas }) => {
await expect(canvas.getByRole("progressbar", { name: "0% complete" })).toHaveAttribute("aria-valuenow", "0");
await expect(canvas.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-amber-400");
},
};

export const NeedsAttention: Story = {
args: { value: 40, label: "40%" },
play: async ({ canvas }) => {
await expect(canvas.getByRole("progressbar", { name: "40% complete" })).toHaveAttribute("aria-valuenow", "40");
await expect(canvas.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-amber-400");
},
};

export const Partial: Story = {
args: { value: 64, label: "64%" },
play: async ({ canvas }) => {
await expect(canvas.getByRole("progressbar", { name: "64% complete" })).toHaveAttribute("aria-valuenow", "64");
await expect(canvas.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-green-2");
},
};

export const Complete: Story = {
args: { value: 100, label: "100%" },
play: async ({ canvas }) => {
await expect(canvas.getByRole("progressbar", { name: "100% complete" })).toHaveAttribute("aria-valuenow", "100");
await expect(canvas.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-green-3");
},
};

export const WithoutCenterLabel: Story = {
args: { value: 90 },
play: async ({ canvas }) => {
await expect(canvas.getByRole("progressbar", { name: "90% complete" })).toBeVisible();
await expect(canvas.queryByText("90%")).not.toBeInTheDocument();
},
};
123 changes: 123 additions & 0 deletions frontend/src/components/shared/CompletionRing/CompletionRing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@/_test_utilities/test-utils";
import { CompletionRing, DATA_TEST_ID } from "./CompletionRing";

// How much of the ring is left undrawn — 0 when complete, the full circumference when empty.
function dashOffsetOf(): number {
const progress = screen.getByTestId(DATA_TEST_ID.PROGRESS);
return Number(progress.getAttribute("stroke-dashoffset"));
}

const CIRCUMFERENCE = 2 * Math.PI * 42;

describe("CompletionRing", () => {
it("should draw none of the ring at 0%", () => {
// GIVEN no progress at all
// WHEN rendered
render(<CompletionRing value={0} />);

// THEN the progress arc is fully offset, and the value is exposed as a progress bar
expect(dashOffsetOf()).toBeCloseTo(CIRCUMFERENCE);
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0");
});

it("should draw part of the ring at a partial value", () => {
// GIVEN 64% progress
// WHEN rendered
render(<CompletionRing value={64} />);

// THEN 64% of the ring is drawn
expect(dashOffsetOf()).toBeCloseTo(CIRCUMFERENCE * 0.36);
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "64");
});

it("should draw the whole ring at 100%", () => {
// GIVEN full progress
// WHEN rendered
render(<CompletionRing value={100} />);

// THEN nothing is left undrawn
expect(dashOffsetOf()).toBeCloseTo(0);
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100");
});

it("should name the progress bar with its percentage", () => {
// GIVEN 90% progress
// WHEN rendered
render(<CompletionRing value={90} />);

// THEN the ring is reachable by a name that states the percentage
expect(screen.getByRole("progressbar", { name: "90% complete" })).toBeInTheDocument();
});

it("should expose the fixed 0-100 range to assistive tech", () => {
// GIVEN any progress value
// WHEN rendered
render(<CompletionRing value={64} />);

// THEN the range bounds are always 0 to 100, regardless of the value
const ring = screen.getByRole("progressbar");
expect(ring).toHaveAttribute("aria-valuemin", "0");
expect(ring).toHaveAttribute("aria-valuemax", "100");
});

it("should clamp values outside 0–100", () => {
// GIVEN values below and above the range
// WHEN rendered
const { unmount } = render(<CompletionRing value={-20} />);

// THEN the low one clamps to 0
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0");
unmount();

// AND the high one clamps to 100
render(<CompletionRing value={140} />);
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100");
});

it("should color the arc amber below halfway, to read as needing attention", () => {
// GIVEN a score below 50%
// WHEN rendered
render(<CompletionRing value={40} />);

// THEN the arc is amber rather than green
expect(screen.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-amber-400");
});

it("should color the arc green from halfway up to, but not including, 100%", () => {
// GIVEN a score at the halfway boundary and one just short of complete
const { unmount } = render(<CompletionRing value={50} />);

// THEN the arc is green
expect(screen.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-green-2");
unmount();

// AND still the same green just short of 100%
render(<CompletionRing value={99} />);
expect(screen.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-green-2");
});

it("should color the arc a deeper green once fully complete", () => {
// GIVEN a fully complete score
// WHEN rendered
render(<CompletionRing value={100} />);

// THEN the arc gets its own, more emphatic green
expect(screen.getByTestId(DATA_TEST_ID.PROGRESS)).toHaveClass("stroke-green-3");
});

it("should show the centre label when one is given, and nothing when it isn't", () => {
// GIVEN a ring with a centre label
const { unmount } = render(<CompletionRing value={90} label="90%" />);

// THEN the label shows in the middle
expect(screen.getByText("90%")).toBeInTheDocument();
unmount();

// WHEN the same ring is rendered without a label
render(<CompletionRing value={90} />);

// THEN the middle stays empty
expect(screen.queryByTestId(DATA_TEST_ID.LABEL)).not.toBeInTheDocument();
});
});
86 changes: 86 additions & 0 deletions frontend/src/components/shared/CompletionRing/CompletionRing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";

const uniqueId = "0d070f6c-22b9-4885-a26b-817502d1591d";

export const DATA_TEST_ID = {
CONTAINER: `completion-ring-container-${uniqueId}`,
TRACK: `completion-ring-track-${uniqueId}`,
PROGRESS: `completion-ring-progress-${uniqueId}`,
LABEL: `completion-ring-label-${uniqueId}`,
};

export interface CompletionRingProps {
value: number;
label?: string;
className?: string;
}

const RADIUS = 42;
const STROKE_WIDTH = 10;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;

// Below halfway reads as needing attention; a full ring gets its own, more emphatic green.
const colorOf = (percentage: number): string => {
if (percentage >= 100) return "stroke-green-3";
if (percentage >= 50) return "stroke-green-2";
return "stroke-amber-400";
};

export function CompletionRing({ value, label, className }: Readonly<CompletionRingProps>) {
const { t } = useTranslation();
const percentage = Math.min(100, Math.max(0, Math.round(value)));

return (
<div
data-slot="completion-ring"
data-testid={DATA_TEST_ID.CONTAINER}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percentage}
aria-label={t("shared.completionRing.label", { value: percentage })}
className={cn("relative size-24", className)}
>
{/* Rotated so the arc starts at 12 o'clock. */}
<svg viewBox="0 0 100 100" aria-hidden="true" className="size-full -rotate-90">
<circle
data-slot="completion-ring-track"
data-testid={DATA_TEST_ID.TRACK}
cx="50"
cy="50"
r={RADIUS}
fill="none"
strokeWidth={STROKE_WIDTH}
className="stroke-muted"
/>
<circle
data-slot="completion-ring-progress"
data-testid={DATA_TEST_ID.PROGRESS}
cx="50"
cy="50"
r={RADIUS}
fill="none"
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={CIRCUMFERENCE * (1 - percentage / 100)}
className={cn(
colorOf(percentage),
"transition-[stroke-dashoffset,stroke] duration-(--duration-base) ease-(--ease-out)"
)}
/>
</svg>
{label && (
<span
data-slot="completion-ring-label"
data-testid={DATA_TEST_ID.LABEL}
aria-hidden="true"
className="absolute inset-0 flex items-center justify-center text-lg font-semibold text-foreground"
>
{label}
</span>
)}
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/components/shared/CompletionRing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./CompletionRing";
42 changes: 42 additions & 0 deletions frontend/src/components/shared/EmptyState/EmptyState.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn } from "storybook/test";
import { Users } from "lucide-react";
import { EmptyState } from "./EmptyState";

const meta = {
component: EmptyState,
tags: ["autodocs"],
args: {
message: "No jobseekers match these filters.",
},
} satisfies Meta<typeof EmptyState>;

export default meta;
type Story = StoryObj<typeof meta>;

export const MessageOnly: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByText("No jobseekers match these filters.")).toBeVisible();
await expect(canvas.queryByRole("button")).not.toBeInTheDocument();
},
};

export const WithAction: Story = {
args: {
action: { label: "Clear filters", onClick: fn() },
},
play: async ({ canvas }) => {
await expect(canvas.getByRole("button", { name: "Clear filters" })).toBeVisible();
},
};

export const WithCustomIcon: Story = {
args: {
message: "No institutions in this grant yet.",
icon: <Users />,
action: { label: "Clear filters", onClick: fn() },
},
play: async ({ canvas }) => {
await expect(canvas.getByText("No institutions in this grant yet.")).toBeVisible();
},
};
56 changes: 56 additions & 0 deletions frontend/src/components/shared/EmptyState/EmptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { Users } from "lucide-react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@/_test_utilities/test-utils";
import { EmptyState, DATA_TEST_ID } from "./EmptyState";

describe("EmptyState", () => {
it("should announce the message and offer no action by default", () => {
// GIVEN an empty list with only a message
// WHEN rendered
render(<EmptyState message="No jobseekers match these filters." />);

// THEN the message is announced and there's nothing to click
expect(screen.getByRole("status")).toHaveTextContent("No jobseekers match these filters.");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

it("should render a default icon when no icon is provided", () => {
// GIVEN an empty list with no icon of its own
// WHEN rendered
render(<EmptyState message="No results." />);

// THEN the fallback icon still fills the slot
expect(screen.getByTestId(DATA_TEST_ID.ICON).querySelector("svg")).toBeInTheDocument();
});

it("should hide the icon from assistive tech, since the message alone conveys the state", () => {
// GIVEN an empty list with an icon
// WHEN rendered
render(<EmptyState message="No results." />);

// THEN the icon slot is hidden from screen readers
expect(screen.getByTestId(DATA_TEST_ID.ICON)).toHaveAttribute("aria-hidden", "true");
});

it("should render the icon passed into the icon slot", () => {
// GIVEN an empty list with its own icon
// WHEN rendered
render(<EmptyState message="No institutions yet." icon={<Users data-testid="icon" />} />);

// THEN that icon is the one in the slot
expect(screen.getByTestId(DATA_TEST_ID.ICON).querySelector("svg")).toHaveAttribute("data-testid", "icon");
});

it("should call the action when its button is clicked", async () => {
// GIVEN an empty list offering a way to clear the filters
const onClick = vi.fn();
render(<EmptyState message="No results." action={{ label: "Clear filters", onClick }} />);

// WHEN clicking the action
await userEvent.click(screen.getByRole("button", { name: "Clear filters" }));

// THEN the action runs
expect(onClick).toHaveBeenCalledTimes(1);
});
});
Loading
Loading