diff --git a/frontend/src/components/shared/CompletionRing/CompletionRing.stories.tsx b/frontend/src/components/shared/CompletionRing/CompletionRing.stories.tsx new file mode 100644 index 0000000..c3c13fd --- /dev/null +++ b/frontend/src/components/shared/CompletionRing/CompletionRing.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/frontend/src/components/shared/CompletionRing/CompletionRing.test.tsx b/frontend/src/components/shared/CompletionRing/CompletionRing.test.tsx new file mode 100644 index 0000000..90bc8c3 --- /dev/null +++ b/frontend/src/components/shared/CompletionRing/CompletionRing.test.tsx @@ -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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // THEN the low one clamps to 0 + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0"); + unmount(); + + // AND the high one clamps to 100 + render(); + 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(); + + // 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(); + + // 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(); + 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(); + + // 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(); + + // THEN the label shows in the middle + expect(screen.getByText("90%")).toBeInTheDocument(); + unmount(); + + // WHEN the same ring is rendered without a label + render(); + + // THEN the middle stays empty + expect(screen.queryByTestId(DATA_TEST_ID.LABEL)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/shared/CompletionRing/CompletionRing.tsx b/frontend/src/components/shared/CompletionRing/CompletionRing.tsx new file mode 100644 index 0000000..95d15bf --- /dev/null +++ b/frontend/src/components/shared/CompletionRing/CompletionRing.tsx @@ -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) { + const { t } = useTranslation(); + const percentage = Math.min(100, Math.max(0, Math.round(value))); + + return ( +
+ {/* Rotated so the arc starts at 12 o'clock. */} + + {label && ( + + )} +
+ ); +} diff --git a/frontend/src/components/shared/CompletionRing/index.ts b/frontend/src/components/shared/CompletionRing/index.ts new file mode 100644 index 0000000..9efe914 --- /dev/null +++ b/frontend/src/components/shared/CompletionRing/index.ts @@ -0,0 +1 @@ +export * from "./CompletionRing"; diff --git a/frontend/src/components/shared/EmptyState/EmptyState.stories.tsx b/frontend/src/components/shared/EmptyState/EmptyState.stories.tsx new file mode 100644 index 0000000..306a3cf --- /dev/null +++ b/frontend/src/components/shared/EmptyState/EmptyState.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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: , + action: { label: "Clear filters", onClick: fn() }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("No institutions in this grant yet.")).toBeVisible(); + }, +}; diff --git a/frontend/src/components/shared/EmptyState/EmptyState.test.tsx b/frontend/src/components/shared/EmptyState/EmptyState.test.tsx new file mode 100644 index 0000000..0d73419 --- /dev/null +++ b/frontend/src/components/shared/EmptyState/EmptyState.test.tsx @@ -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(); + + // 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(); + + // 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(); + + // 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(} />); + + // 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(); + + // WHEN clicking the action + await userEvent.click(screen.getByRole("button", { name: "Clear filters" })); + + // THEN the action runs + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/shared/EmptyState/EmptyState.tsx b/frontend/src/components/shared/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..353c1a4 --- /dev/null +++ b/frontend/src/components/shared/EmptyState/EmptyState.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { SearchX } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const uniqueId = "6251498c-22bf-46ee-9201-83bcac828359"; + +export const DATA_TEST_ID = { + CONTAINER: `empty-state-container-${uniqueId}`, + ICON: `empty-state-icon-${uniqueId}`, + ACTION_BUTTON: `empty-state-action-button-${uniqueId}`, +}; + +export interface EmptyStateAction { + label: string; + onClick: () => void; +} + +export interface EmptyStateProps { + message: string; + icon?: ReactNode; + action?: EmptyStateAction; + className?: string; +} + +export function EmptyState({ message, icon, action, className }: Readonly) { + return ( +
+ +

{message}

+ {action && ( + + )} +
+ ); +} diff --git a/frontend/src/components/shared/EmptyState/index.ts b/frontend/src/components/shared/EmptyState/index.ts new file mode 100644 index 0000000..986154c --- /dev/null +++ b/frontend/src/components/shared/EmptyState/index.ts @@ -0,0 +1 @@ +export * from "./EmptyState"; diff --git a/frontend/src/components/shared/FilterMenu/FilterMenu.stories.tsx b/frontend/src/components/shared/FilterMenu/FilterMenu.stories.tsx new file mode 100644 index 0000000..de265a7 --- /dev/null +++ b/frontend/src/components/shared/FilterMenu/FilterMenu.stories.tsx @@ -0,0 +1,131 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { FilterMenu, type FilterMenuProps } from "./FilterMenu"; + +const REGIONS = [ + { value: "lusaka", label: "Lusaka" }, + { value: "copperbelt", label: "Copperbelt" }, + { value: "southern", label: "Southern" }, + { value: "eastern", label: "Eastern" }, + { value: "central", label: "Central" }, +]; + +// The menu is controlled, so the story owns the selection to keep the checkboxes interactive. +function ControlledFilterMenu({ selected, onSelectionChange, ...props }: Readonly) { + const [value, setValue] = useState(selected); + + return ( + { + setValue(next); + onSelectionChange(next); + }} + /> + ); +} + +// Popover content is portalled to the body, so it lives outside the story canvas. +async function openMenu(canvasElement: HTMLElement) { + await userEvent.click(within(canvasElement).getByRole("button", { name: "Filter by Region" })); + const menu = within(document.body); + // The popover fades in, so wait for the option group to settle before asserting on visibility. + await waitFor(() => expect(menu.getByRole("group", { name: "Filter · Region" })).toBeVisible()); + return menu; +} + +const meta = { + component: FilterMenu, + tags: ["autodocs"], + render: (args) => , + args: { + label: "Region", + options: REGIONS, + selected: [], + onSelectionChange: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const NoSelection: Story = { + play: async ({ canvasElement }) => { + const menu = await openMenu(canvasElement); + + await expect(menu.getByRole("checkbox", { name: "Lusaka" })).not.toBeChecked(); + await expect(menu.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument(); + }, +}; + +export const PartialSelection: Story = { + args: { selected: ["lusaka", "eastern"] }, + play: async ({ canvasElement }) => { + const menu = await openMenu(canvasElement); + + await expect(menu.getByRole("checkbox", { name: "Lusaka" })).toBeChecked(); + await expect(menu.getByRole("checkbox", { name: "Southern" })).not.toBeChecked(); + await expect(menu.getByText("2 selected")).toBeVisible(); + }, +}; + +export const AllSelected: Story = { + args: { selected: REGIONS.map((region) => region.value) }, + play: async ({ canvasElement }) => { + const menu = await openMenu(canvasElement); + + for (const region of REGIONS) { + await expect(menu.getByRole("checkbox", { name: region.label })).toBeChecked(); + } + await expect(menu.getByText("5 selected")).toBeVisible(); + }, +}; + +export const NoOptions: Story = { + args: { options: [] }, + play: async ({ canvasElement }) => { + const menu = await openMenu(canvasElement); + + await expect(menu.getByText("No filter options available")).toBeVisible(); + await expect(menu.queryByRole("checkbox")).not.toBeInTheDocument(); + }, +}; + +export const TogglingAnOption: Story = { + play: async ({ canvasElement }) => { + const menu = await openMenu(canvasElement); + + await userEvent.click(menu.getByRole("checkbox", { name: "Copperbelt" })); + + await expect(menu.getByRole("checkbox", { name: "Copperbelt" })).toBeChecked(); + await expect(menu.getByText("1 selected")).toBeVisible(); + }, +}; + +// How the per-module filters sit in the Jobseekers table headers: funnel icon only. +export const IconOnly: Story = { + args: { + label: "Build Your Profile", + showLabel: false, + options: [ + { value: "completed", label: "Completed" }, + { value: "in-progress", label: "In progress" }, + { value: "not-started", label: "Not started" }, + ], + selected: ["completed"], + }, + render: (args) => ( +
+ {args.label} + +
+ ), + play: async ({ canvasElement }) => { + const trigger = within(canvasElement).getByRole("button", { name: "Filter by Build Your Profile" }); + + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveTextContent(""); + }, +}; diff --git a/frontend/src/components/shared/FilterMenu/FilterMenu.test.tsx b/frontend/src/components/shared/FilterMenu/FilterMenu.test.tsx new file mode 100644 index 0000000..48a3051 --- /dev/null +++ b/frontend/src/components/shared/FilterMenu/FilterMenu.test.tsx @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { FilterMenu, type FilterMenuOption } from "./FilterMenu"; + +const REGIONS: FilterMenuOption[] = [ + { value: "lusaka", label: "Lusaka" }, + { value: "copperbelt", label: "Copperbelt" }, + { value: "southern", label: "Southern" }, +]; + +async function renderAndOpenMenu(selected: string[] = [], options: FilterMenuOption[] = REGIONS) { + const onSelectionChange = vi.fn(); + render(); + await userEvent.click(screen.getByRole("button", { name: "Filter by Region" })); + return { onSelectionChange }; +} + +describe("FilterMenu", () => { + it("should keep the options hidden until the menu is opened", async () => { + // GIVEN a closed filter menu + render(); + + // THEN no options are shown + expect(screen.queryByRole("checkbox", { name: "Lusaka" })).not.toBeInTheDocument(); + + // WHEN the trigger is clicked + await userEvent.click(screen.getByRole("button", { name: "Filter by Region" })); + + // THEN every option is shown + expect(screen.getByRole("checkbox", { name: "Lusaka" })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Copperbelt" })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Southern" })).toBeInTheDocument(); + }); + + it("should show nothing selected and no way to clear when the selection is empty", async () => { + // GIVEN an open menu with nothing selected + await renderAndOpenMenu(); + + // THEN no option is ticked and there's nothing to clear + expect(screen.getByRole("checkbox", { name: "Lusaka" })).not.toBeChecked(); + expect(screen.queryByRole("button", { name: "Clear" })).not.toBeInTheDocument(); + }); + + it("should tick only the selected options and report how many are selected", async () => { + // GIVEN an open menu with two of three regions selected + await renderAndOpenMenu(["lusaka", "southern"]); + + // THEN those two are ticked, the third isn't, and the count reflects the selection + expect(screen.getByRole("checkbox", { name: "Lusaka" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Southern" })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: "Copperbelt" })).not.toBeChecked(); + expect(screen.getByText("2 selected")).toBeInTheDocument(); + + // AND the trigger itself carries a badge with the same count + expect(screen.getByRole("button", { name: "Filter by Region" })).toHaveTextContent("2"); + }); + + it("should give the open menu a name of its own, since Radix exposes it as a dialog", async () => { + // GIVEN an open menu + await renderAndOpenMenu(); + + // THEN the menu surface is reachable as a named dialog, not just its fieldset + expect(screen.getByRole("dialog", { name: "Filter by Region" })).toBeInTheDocument(); + }); + + it("should add a region to the selection when an unticked option is clicked", async () => { + // GIVEN an open menu with one region already selected + const { onSelectionChange } = await renderAndOpenMenu(["lusaka"]); + + // WHEN ticking another region + await userEvent.click(screen.getByRole("checkbox", { name: "Southern" })); + + // THEN the callback receives the full new selection + expect(onSelectionChange).toHaveBeenCalledWith(["lusaka", "southern"]); + }); + + it("should remove a region from the selection when a ticked option is clicked", async () => { + // GIVEN an open menu with two regions selected + const { onSelectionChange } = await renderAndOpenMenu(["lusaka", "southern"]); + + // WHEN unticking one of them + await userEvent.click(screen.getByRole("checkbox", { name: "Lusaka" })); + + // THEN the callback receives the selection without it + expect(onSelectionChange).toHaveBeenCalledWith(["southern"]); + }); + + it("should empty the selection when Clear is clicked", async () => { + // GIVEN an open menu with every region selected + const { onSelectionChange } = await renderAndOpenMenu(REGIONS.map((region) => region.value)); + + // WHEN clicking Clear + await userEvent.click(screen.getByRole("button", { name: "Clear" })); + + // THEN the callback receives an empty selection + expect(onSelectionChange).toHaveBeenCalledWith([]); + }); + + it("should drop the visible label but keep the accessible name when used as a column filter", async () => { + // GIVEN a column-header filter, which shows only its funnel icon + render(); + + // THEN the trigger carries no visible text, but is still reachable by name + const trigger = screen.getByRole("button", { name: "Filter by Region" }); + expect(trigger).toHaveTextContent(""); + + // WHEN it is clicked + await userEvent.click(trigger); + + // THEN the same options open, under a heading that names the filter + expect(screen.getByRole("group", { name: "Filter · Region" })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Lusaka" })).toBeInTheDocument(); + }); + + it("should explain that there is nothing to filter by when the options list is empty", async () => { + // GIVEN an open menu with no options + await renderAndOpenMenu([], []); + + // THEN the empty message shows instead of any checkbox + expect(screen.getByText("No filter options available")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/shared/FilterMenu/FilterMenu.tsx b/frontend/src/components/shared/FilterMenu/FilterMenu.tsx new file mode 100644 index 0000000..3ba1951 --- /dev/null +++ b/frontend/src/components/shared/FilterMenu/FilterMenu.tsx @@ -0,0 +1,125 @@ +import { useId } from "react"; +import { useTranslation } from "react-i18next"; +import { Filter } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +const uniqueId = "21dfbbd5-171d-4103-ac3e-ca390a3b6c0c"; + +export const DATA_TEST_ID = { + TRIGGER: `filter-menu-trigger-${uniqueId}`, + CONTENT: `filter-menu-content-${uniqueId}`, + CLEAR_BUTTON: `filter-menu-clear-button-${uniqueId}`, +}; + +export interface FilterMenuOption { + value: string; + label: string; +} + +export interface FilterMenuProps { + label: string; + options: readonly FilterMenuOption[]; + selected: readonly string[]; + onSelectionChange: (selected: string[]) => void; + showLabel?: boolean; + className?: string; +} + +export function FilterMenu({ + label, + options, + selected, + onSelectionChange, + showLabel = true, + className, +}: Readonly) { + const { t } = useTranslation(); + const baseId = useId(); + const headingId = `${baseId}-heading`; + const selectedCount = selected.length; + const menuLabel = t("shared.filterMenu.trigger", { label }); + + const toggle = (value: string) => { + const next = selected.includes(value) ? selected.filter((option) => option !== value) : [...selected, value]; + onSelectionChange(next); + }; + + return ( + + + + + +
+

+ {t("shared.filterMenu.heading", { label })} +

+ {options.length === 0 ? ( +

{t("shared.filterMenu.noOptions")}

+ ) : ( + options.map((option) => { + const optionId = `${baseId}-${option.value}`; + return ( + + ); + }) + )} +
+ {selectedCount > 0 && ( + <> + +
+ + {t("shared.filterMenu.selectedCount", { value: selectedCount })} + + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/components/shared/FilterMenu/index.ts b/frontend/src/components/shared/FilterMenu/index.ts new file mode 100644 index 0000000..14eb2aa --- /dev/null +++ b/frontend/src/components/shared/FilterMenu/index.ts @@ -0,0 +1 @@ +export * from "./FilterMenu"; diff --git a/frontend/src/components/shared/ScreenHead/ScreenHead.stories.tsx b/frontend/src/components/shared/ScreenHead/ScreenHead.stories.tsx new file mode 100644 index 0000000..e76d4bd --- /dev/null +++ b/frontend/src/components/shared/ScreenHead/ScreenHead.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { ScreenHead } from "./ScreenHead"; + +const meta = { + component: ScreenHead, + tags: ["autodocs"], + args: { + title: "Overview", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const TitleOnly: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByRole("heading", { level: 1, name: "Overview" })).toBeVisible(); + }, +}; + +export const WithEyebrow: Story = { + args: { eyebrow: "Deployment overview" }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Deployment overview")).toBeVisible(); + }, +}; + +export const WithDescription: Story = { + args: { description: "Ndola Livelihoods Trust · Jul '25 – Jul '26" }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Ndola Livelihoods Trust · Jul '25 – Jul '26")).toBeVisible(); + }, +}; + +export const WithEverything: Story = { + args: { + eyebrow: "Individual view", + title: "Jobseekers", + description: "Every jobseeker in scope, one per row. Sort any column by its header.", + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Individual view")).toBeVisible(); + await expect(canvas.getByRole("heading", { level: 1, name: "Jobseekers" })).toBeVisible(); + await expect(canvas.getByText(/Every jobseeker in scope/)).toBeVisible(); + }, +}; diff --git a/frontend/src/components/shared/ScreenHead/ScreenHead.test.tsx b/frontend/src/components/shared/ScreenHead/ScreenHead.test.tsx new file mode 100644 index 0000000..0b9db44 --- /dev/null +++ b/frontend/src/components/shared/ScreenHead/ScreenHead.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { ScreenHead, DATA_TEST_ID } from "./ScreenHead"; + +describe("ScreenHead", () => { + it("should render the title as the screen's top-level heading", () => { + // GIVEN a screen with only a title + // WHEN rendered + render(); + + // THEN the title is the h1 + expect(screen.getByRole("heading", { level: 1, name: "Overview" })).toBeInTheDocument(); + }); + + it("should omit the eyebrow and description when they aren't provided", () => { + // GIVEN a screen with only a title + // WHEN rendered + render(); + + // THEN neither optional line is in the document + expect(screen.queryByTestId(DATA_TEST_ID.EYEBROW)).not.toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.DESCRIPTION)).not.toBeInTheDocument(); + }); + + it("should render the eyebrow above the title when provided", () => { + // GIVEN a screen with an eyebrow + // WHEN rendered + render(); + + // THEN the eyebrow shows alongside the title + expect(screen.getByText("Deployment overview")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: "Overview" })).toBeInTheDocument(); + }); + + it("should render the description when provided", () => { + // GIVEN a screen with a description + // WHEN rendered + render(); + + // THEN the description shows + expect(screen.getByText("Every jobseeker in scope, one per row.")).toBeInTheDocument(); + }); + + it("should render the eyebrow, title and description together", () => { + // GIVEN a screen with all three parts + // WHEN rendered + render(); + + // THEN all three show + expect(screen.getByText("Individual view")).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: "Jobseekers" })).toBeInTheDocument(); + expect(screen.getByText("One row per jobseeker.")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/shared/ScreenHead/ScreenHead.tsx b/frontend/src/components/shared/ScreenHead/ScreenHead.tsx new file mode 100644 index 0000000..389719e --- /dev/null +++ b/frontend/src/components/shared/ScreenHead/ScreenHead.tsx @@ -0,0 +1,49 @@ +import { cn } from "@/lib/utils"; + +const uniqueId = "1fab9670-bf8a-4987-bdbe-5f60075ab092"; + +export const DATA_TEST_ID = { + CONTAINER: `screen-head-container-${uniqueId}`, + EYEBROW: `screen-head-eyebrow-${uniqueId}`, + TITLE: `screen-head-title-${uniqueId}`, + DESCRIPTION: `screen-head-description-${uniqueId}`, +}; + +export interface ScreenHeadProps { + title: string; + eyebrow?: string; + description?: string; + className?: string; +} + +export function ScreenHead({ title, eyebrow, description, className }: Readonly) { + return ( +
+ {eyebrow && ( +

+ {eyebrow} +

+ )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ ); +} diff --git a/frontend/src/components/shared/ScreenHead/index.ts b/frontend/src/components/shared/ScreenHead/index.ts new file mode 100644 index 0000000..95a697d --- /dev/null +++ b/frontend/src/components/shared/ScreenHead/index.ts @@ -0,0 +1 @@ +export * from "./ScreenHead"; diff --git a/frontend/src/components/shared/StatTile/StatTile.stories.tsx b/frontend/src/components/shared/StatTile/StatTile.stories.tsx new file mode 100644 index 0000000..6f72407 --- /dev/null +++ b/frontend/src/components/shared/StatTile/StatTile.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { ScanSearch, Timer } from "lucide-react"; +import { StatTile, DATA_TEST_ID } from "./StatTile"; + +const meta = { + component: StatTile, + tags: ["autodocs"], + args: { + label: "Notebooks published", + value: "812", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const BareValue: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByText("Notebooks published")).toBeVisible(); + await expect(canvas.getByText("812")).toBeVisible(); + }, +}; + +export const WithUpwardTrend: Story = { + args: { trend: { value: 18, label: "vs. last sprint" } }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Up 18%")).toBeInTheDocument(); + await expect(canvas.getByText("vs. last sprint")).toBeVisible(); + }, +}; + +export const WithDownwardTrend: Story = { + args: { trend: { value: -24, label: "since the last release" } }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Down 24%")).toBeInTheDocument(); + await expect(canvas.getByText("since the last release")).toBeVisible(); + }, +}; + +export const WithFlatTrend: Story = { + args: { trend: { value: 0 } }, + play: async ({ canvas }) => { + await expect(canvas.getByText("No change")).toBeInTheDocument(); + }, +}; + +export const WithIconAndCaption: Story = { + args: { + label: "Median time on task", + value: "6m 40s", + icon: , + caption: "across 3,204 sessions this week", + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("across 3,204 sessions this week")).toBeVisible(); + }, +}; + +function SparklinePlaceholder() { + return ( + + ); +} + +export const WithSparkline: Story = { + args: { + icon: , + trend: { value: -24, label: "since the last release" }, + sparkline: , + }, + play: async ({ canvas }) => { + const value = canvas.getByText("812"); + const sparkline = canvas.getByTestId(DATA_TEST_ID.SPARKLINE); + await expect(value.parentElement).toContainElement(sparkline); + }, +}; diff --git a/frontend/src/components/shared/StatTile/StatTile.test.tsx b/frontend/src/components/shared/StatTile/StatTile.test.tsx new file mode 100644 index 0000000..4a13128 --- /dev/null +++ b/frontend/src/components/shared/StatTile/StatTile.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { StatTile, DATA_TEST_ID } from "./StatTile"; + +describe("StatTile", () => { + it("should render the label and value of a metric with nothing else", () => { + // GIVEN a metric with no trend and no sparkline + // WHEN rendered + render(); + + // THEN the label and value show, and neither optional slot is filled + expect(screen.getByText("Cumulative users")).toBeInTheDocument(); + expect(screen.getByText("4,118")).toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.TREND)).not.toBeInTheDocument(); + expect(screen.queryByTestId(DATA_TEST_ID.SPARKLINE)).not.toBeInTheDocument(); + }); + + it("should describe a rise as an upward trend", () => { + // GIVEN a metric that grew by 12% since the last quarter + // WHEN rendered + render(); + + // THEN the delta reads as a rise, with its qualifier + expect(screen.getByTestId(DATA_TEST_ID.TREND)).toHaveAttribute("data-direction", "up"); + expect(screen.getByText("Up 12%")).toBeInTheDocument(); + expect(screen.getByText("vs. last quarter")).toBeInTheDocument(); + }); + + it("should describe a fall as a downward trend, showing the magnitude without its sign", () => { + // GIVEN a metric that fell by 60% + // WHEN rendered + render(); + + // THEN the delta reads as a fall, and the visible figure drops the minus sign + expect(screen.getByTestId(DATA_TEST_ID.TREND)).toHaveAttribute("data-direction", "down"); + expect(screen.getByText("Down 60%")).toBeInTheDocument(); + expect(screen.getByText("60%")).toBeInTheDocument(); + }); + + it("should describe a delta of zero as no change rather than a rise", () => { + // GIVEN a metric that didn't move + // WHEN rendered + render(); + + // THEN it reads as no change + expect(screen.getByTestId(DATA_TEST_ID.TREND)).toHaveAttribute("data-direction", "flat"); + expect(screen.getByText("No change")).toBeInTheDocument(); + }); + + it("should render whatever is passed into the sparkline slot, alongside the value rather than the trend", () => { + // GIVEN a metric with a chart in its sparkline slot, and a trend underneath it + render(chart} />); + + // THEN the chart shows inside the slot + const sparkline = screen.getByText("chart"); + expect(sparkline).toBeInTheDocument(); + + // AND it shares a row with the value, not with the trend below it + const valueRow = screen.getByTestId(DATA_TEST_ID.VALUE).parentElement; + expect(valueRow).toContainElement(sparkline); + expect(valueRow).not.toContainElement(screen.getByTestId(DATA_TEST_ID.TREND)); + }); + + it("should render the caption under the value", () => { + // GIVEN a metric qualified by a caption rather than a delta + // WHEN rendered + render(); + + // THEN the caption shows + expect(screen.getByText("41% of users · last 30 days")).toBeInTheDocument(); + }); + + it("should render the icon passed into the icon slot as decoration", () => { + // GIVEN a metric with an icon + // WHEN rendered + render(} />); + + // THEN the icon fills the slot and stays out of the accessibility tree + const slot = screen.getByTestId(DATA_TEST_ID.ICON); + expect(slot).toHaveAttribute("aria-hidden", "true"); + expect(slot.querySelector("svg")).toHaveAttribute("data-testid", "icon"); + }); +}); diff --git a/frontend/src/components/shared/StatTile/StatTile.tsx b/frontend/src/components/shared/StatTile/StatTile.tsx new file mode 100644 index 0000000..5cc266b --- /dev/null +++ b/frontend/src/components/shared/StatTile/StatTile.tsx @@ -0,0 +1,123 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Minus, TrendingDown, TrendingUp } from "lucide-react"; +import { Card, CardAction, CardContent, CardDescription, CardHeader } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +const uniqueId = "6c2c88aa-5237-44f2-b983-de191a71e415"; + +export const DATA_TEST_ID = { + CONTAINER: `stat-tile-container-${uniqueId}`, + LABEL: `stat-tile-label-${uniqueId}`, + ICON: `stat-tile-icon-${uniqueId}`, + VALUE: `stat-tile-value-${uniqueId}`, + SPARKLINE: `stat-tile-sparkline-${uniqueId}`, + TREND: `stat-tile-trend-${uniqueId}`, + CAPTION: `stat-tile-caption-${uniqueId}`, +}; + +export interface StatTileTrend { + value: number; + label?: string; +} + +export interface StatTileProps { + label: string; + value: ReactNode; + icon?: ReactNode; + caption?: string; + trend?: StatTileTrend; + sparkline?: ReactNode; + className?: string; +} + +const TREND_STYLES = { + up: { Icon: TrendingUp, className: "text-green-3" }, + down: { Icon: TrendingDown, className: "text-destructive" }, + flat: { Icon: Minus, className: "text-muted-foreground" }, +} as const; + +// trendFlat has no {{value}} placeholder, so passing it along is harmless. +const TREND_LABEL_KEYS = { + up: "shared.statTile.trendUp", + down: "shared.statTile.trendDown", + flat: "shared.statTile.trendFlat", +} as const; + +// A delta of exactly 0 reads as "no change" rather than a rise. +function directionOf(value: number): keyof typeof TREND_STYLES { + if (value > 0) return "up"; + if (value < 0) return "down"; + return "flat"; +} + +function TrendIndicator({ value, label }: Readonly) { + const { t } = useTranslation(); + const direction = directionOf(value); + const { Icon, className } = TREND_STYLES[direction]; + const magnitude = Math.abs(value); + const srLabel = t(TREND_LABEL_KEYS[direction], { value: magnitude }); + + return ( +

+ + + {label && {label}} +

+ ); +} + +export function StatTile({ label, value, icon, caption, trend, sparkline, className }: Readonly) { + return ( + + + + {label} + + {icon && ( + + )} + + +
+

+ {value} +

+ {sparkline && ( +
+ {sparkline} +
+ )} +
+ {trend && } + {caption && ( +

+ {caption} +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/shared/StatTile/index.ts b/frontend/src/components/shared/StatTile/index.ts new file mode 100644 index 0000000..58245ee --- /dev/null +++ b/frontend/src/components/shared/StatTile/index.ts @@ -0,0 +1 @@ +export * from "./StatTile"; diff --git a/frontend/src/components/shared/UserAvatar/UserAvatar.stories.tsx b/frontend/src/components/shared/UserAvatar/UserAvatar.stories.tsx new file mode 100644 index 0000000..0b3af20 --- /dev/null +++ b/frontend/src/components/shared/UserAvatar/UserAvatar.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect } from "storybook/test"; +import { UserAvatar, DATA_TEST_ID } from "./UserAvatar"; + +const meta = { + component: UserAvatar, + tags: ["autodocs"], + args: { + name: "Amara Moyo", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const IconFallback: Story = { + play: async ({ canvas }) => { + // GIVEN a jobseeker with no photo + // WHEN rendered + // THEN a generic person icon stands in for the photo + await expect(canvas.getByTestId(DATA_TEST_ID.FALLBACK).querySelector("svg")).toBeInTheDocument(); + }, +}; + +export const WithPhoto: Story = { + args: { src: "https://github.com/shadcn.png" }, + play: async ({ canvas }) => { + // GIVEN a jobseeker with a photo + // WHEN rendered + // THEN the circle renders — the photo replaces the icon only once it has loaded + await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toBeInTheDocument(); + }, +}; + +export const Small: Story = { + args: { size: "sm", name: "Blessing González" }, + play: async ({ canvas }) => { + // GIVEN a table-row-sized avatar + // WHEN rendered + // THEN it renders at the small size + await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-size", "sm"); + }, +}; + +export const Large: Story = { + args: { size: "lg", name: "Ndola Livelihoods Trust" }, + play: async ({ canvas }) => { + // GIVEN a profile-card-sized avatar + // WHEN rendered + // THEN it renders at the large size + await expect(canvas.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-size", "lg"); + }, +}; diff --git a/frontend/src/components/shared/UserAvatar/UserAvatar.test.tsx b/frontend/src/components/shared/UserAvatar/UserAvatar.test.tsx new file mode 100644 index 0000000..f21bfc5 --- /dev/null +++ b/frontend/src/components/shared/UserAvatar/UserAvatar.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@/_test_utilities/test-utils"; +import { UserAvatar, DATA_TEST_ID } from "./UserAvatar"; + +describe("UserAvatar", () => { + it("should fall back to a generic person icon when there is no photo", () => { + // GIVEN a jobseeker without a photo + // WHEN rendered + render(); + + // THEN a person icon stands in for the photo + expect(screen.getByTestId(DATA_TEST_ID.FALLBACK).querySelector("svg")).toBeInTheDocument(); + }); + + it("should keep the full name available to screen readers", () => { + // GIVEN a jobseeker without a photo + // WHEN rendered + render(); + + // THEN the name is readable even though only the icon is drawn + expect(screen.getByText("Amara Moyo")).toBeInTheDocument(); + }); + + it("should keep showing the fallback icon until a provided photo has loaded", () => { + // GIVEN a jobseeker whose photo hasn't loaded yet + // WHEN rendered + render(); + + // THEN the icon holds the space, and the name is still readable + expect(screen.getByTestId(DATA_TEST_ID.FALLBACK).querySelector("svg")).toBeInTheDocument(); + expect(screen.getByText("Amara Moyo")).toBeInTheDocument(); + }); + + it("should render at the default size when none is given", () => { + // GIVEN an avatar with no explicit size + // WHEN rendered + render(); + + // THEN it renders at the default size + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-size", "default"); + }); + + it("should render at the small size used in table rows", () => { + // GIVEN a table row avatar + // WHEN rendered + render(); + + // THEN it renders at the small size + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-size", "sm"); + }); + + it("should render at the large size used in profile cards", () => { + // GIVEN a profile card avatar + // WHEN rendered + render(); + + // THEN it renders at the large size + expect(screen.getByTestId(DATA_TEST_ID.CONTAINER)).toHaveAttribute("data-size", "lg"); + }); + + it("should let the fallback colors be overridden for placement on a dark background", () => { + // GIVEN an avatar placed on a dark background, like the sidebar footer + // WHEN rendered with a fallback color override + render(); + + // THEN the override wins over the default colors + const fallback = screen.getByTestId(DATA_TEST_ID.FALLBACK); + expect(fallback).toHaveClass("bg-tabiya-green", "text-tabiya-blue"); + expect(fallback).not.toHaveClass("bg-tabiya-blue", "text-white"); + }); +}); diff --git a/frontend/src/components/shared/UserAvatar/UserAvatar.tsx b/frontend/src/components/shared/UserAvatar/UserAvatar.tsx new file mode 100644 index 0000000..b00173e --- /dev/null +++ b/frontend/src/components/shared/UserAvatar/UserAvatar.tsx @@ -0,0 +1,34 @@ +import { User } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { cn } from "@/lib/utils"; + +const uniqueId = "31ac4e7b-ef8d-47b3-8969-c3f135ea300c"; + +export const DATA_TEST_ID = { + CONTAINER: `user-avatar-container-${uniqueId}`, + FALLBACK: `user-avatar-fallback-${uniqueId}`, +}; + +export interface UserAvatarProps { + name: string; + src?: string; + size?: "sm" | "default" | "lg"; + className?: string; +} + +export function UserAvatar({ name, src, size = "default", className }: Readonly) { + return ( + + {src && } + + {/* The photo is decorative, so the name lives here and reads the same either way. */} + {name} + + ); +} diff --git a/frontend/src/components/shared/UserAvatar/index.ts b/frontend/src/components/shared/UserAvatar/index.ts new file mode 100644 index 0000000..80c8188 --- /dev/null +++ b/frontend/src/components/shared/UserAvatar/index.ts @@ -0,0 +1 @@ +export * from "./UserAvatar"; diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx b/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx index 48614c5..51e1f3b 100644 --- a/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx +++ b/frontend/src/components/sidebar/components/sidebar-user-menu.stories.tsx @@ -24,6 +24,8 @@ type Story = StoryObj; export const Default: Story = { play: async ({ canvas }) => { - await expect(canvas.getByRole("button", { name: "Open account menu" })).toBeVisible(); + const trigger = canvas.getByRole("button", { name: "Open account menu" }); + await expect(trigger).toBeVisible(); + await expect(trigger.querySelector("svg")).toBeInTheDocument(); }, }; diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx b/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx index 128b3b3..9512f7a 100644 --- a/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx +++ b/frontend/src/components/sidebar/components/sidebar-user-menu.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { render, screen } from "@/_test_utilities/test-utils"; import { SidebarProvider } from "@/components/ui/sidebar"; +import { DATA_TEST_ID as USER_AVATAR_TEST_ID } from "@/components/shared/UserAvatar"; import { SidebarUserMenu } from "./sidebar-user-menu"; function renderMenu() { @@ -21,8 +22,18 @@ describe("SidebarUserMenu", () => { renderMenu(); // THEN the trigger is reachable by its accessible name and shows a visible label + // (the avatar also carries its own sr-only copy of the name, so scope to the visible one) expect(screen.getByRole("button", { name: /Open account menu/ })).toBeInTheDocument(); - expect(screen.getByText("My account")).toBeInTheDocument(); + expect(screen.getByText("My account", { selector: ":not(.sr-only)" })).toBeInTheDocument(); + }); + + it("should fall back to a generic person icon, with no real user profile to draw a photo from yet", () => { + // GIVEN the footer menu, with no real user profile to draw a photo from yet + // WHEN rendered + renderMenu(); + + // THEN the avatar falls back to a person icon + expect(screen.getByTestId(USER_AVATAR_TEST_ID.FALLBACK).querySelector("svg")).toBeInTheDocument(); }); it("should link Account settings to /settings and call onSignOut when Sign out is clicked", async () => { diff --git a/frontend/src/components/sidebar/components/sidebar-user-menu.tsx b/frontend/src/components/sidebar/components/sidebar-user-menu.tsx index 7e7bd53..8db046b 100644 --- a/frontend/src/components/sidebar/components/sidebar-user-menu.tsx +++ b/frontend/src/components/sidebar/components/sidebar-user-menu.tsx @@ -1,7 +1,6 @@ import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { ChevronsUpDown, CircleUser } from "lucide-react"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { ChevronsUpDown } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -11,9 +10,11 @@ import { } from "@/components/ui/dropdown-menu"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar"; import { routerPaths } from "@/app/routerPaths"; +import { UserAvatar } from "@/components/shared/UserAvatar"; export function SidebarUserMenu({ onSignOut }: Readonly<{ onSignOut: () => void }>) { const { t } = useTranslation(); + const label = t("nav.userMenu.label"); return ( @@ -21,12 +22,8 @@ export function SidebarUserMenu({ onSignOut }: Readonly<{ onSignOut: () => void - - - - - - {t("nav.userMenu.label")} + + {label} diff --git a/frontend/src/components/ui/checkbox.stories.tsx b/frontend/src/components/ui/checkbox.stories.tsx new file mode 100644 index 0000000..f4263d9 --- /dev/null +++ b/frontend/src/components/ui/checkbox.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent } from "storybook/test"; +import { Checkbox } from "./checkbox"; + +const meta = { + component: Checkbox, + tags: ["ai-generated"], + args: { + "aria-label": "Example option", + onCheckedChange: fn(), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Unchecked: Story = { + play: async ({ canvas }) => { + await expect(canvas.getByRole("checkbox")).not.toBeChecked(); + }, +}; + +export const Checked: Story = { + args: { checked: true }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("checkbox")).toBeChecked(); + }, +}; + +export const Disabled: Story = { + args: { disabled: true }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("checkbox")).toBeDisabled(); + }, +}; + +export const Toggle: Story = { + play: async ({ canvas, args }) => { + await userEvent.click(canvas.getByRole("checkbox")); + await expect(args.onCheckedChange).toHaveBeenCalledWith(true); + }, +}; diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..478efff --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import { CheckIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..15fd818 --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { Popover as PopoverPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function Popover({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function PopoverAnchor({ ...props }: React.ComponentProps) { + return ; +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/frontend/src/i18n/locales/en-GB/translation.json b/frontend/src/i18n/locales/en-GB/translation.json index 17a3baa..4d31daa 100644 --- a/frontend/src/i18n/locales/en-GB/translation.json +++ b/frontend/src/i18n/locales/en-GB/translation.json @@ -124,5 +124,22 @@ "week": "week", "month": "month" } + }, + "shared": { + "statTile": { + "trendUp": "Up {{value}}%", + "trendDown": "Down {{value}}%", + "trendFlat": "No change" + }, + "filterMenu": { + "trigger": "Filter by {{label}}", + "heading": "Filter · {{label}}", + "selectedCount": "{{value}} selected", + "clear": "Clear", + "noOptions": "No filter options available" + }, + "completionRing": { + "label": "{{value}}% complete" + } } }