diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx new file mode 100644 index 000000000..5f159c933 --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { SortToggle } from "./SortToggle"; + +const meta: Meta = { + title: "Elements/SortToggle", + component: SortToggle, + args: { + onChange: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const NewestFirst: Story = { + args: { + value: "newest-first", + }, +}; + +export const OldestFirst: Story = { + args: { + value: "oldest-first", + }, +}; + +export const FlipsDirection: Story = { + args: { + value: "newest-first", + }, + play: async ({ canvasElement, args }) => { + const body = within(canvasElement.ownerDocument.body); + const select = await body.findByRole("textbox", { + name: "Sort direction", + }); + await userEvent.click(select); + await userEvent.click(await body.findByText("Oldest First")); + await expect(args.onChange).toHaveBeenCalledWith("oldest-first"); + }, +}; diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx new file mode 100644 index 000000000..a921b451c --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { SortToggle } from "./SortToggle"; + +describe("SortToggle", () => { + it("renders the current value as the selected option", () => { + renderWithMantine( {}} />); + expect(screen.getByDisplayValue("Newest First")).toBeInTheDocument(); + }); + + it("renders 'Oldest First' when value is oldest-first", () => { + renderWithMantine( {}} />); + expect(screen.getByDisplayValue("Oldest First")).toBeInTheDocument(); + }); + + it("uses the default aria-label", () => { + renderWithMantine( {}} />); + expect( + screen.getByRole("textbox", { name: "Sort direction" }), + ).toBeInTheDocument(); + }); + + it("honors a custom aria-label", () => { + renderWithMantine( + {}} + aria-label="Logs sort" + />, + ); + expect( + screen.getByRole("textbox", { name: "Logs sort" }), + ).toBeInTheDocument(); + }); + + it("invokes onChange with the new direction when the user picks another option", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("textbox", { name: "Sort direction" })); + await user.click(await screen.findByText("Oldest First")); + expect(onChange).toHaveBeenCalledWith("oldest-first"); + }); +}); diff --git a/clients/web/src/components/elements/SortToggle/SortToggle.tsx b/clients/web/src/components/elements/SortToggle/SortToggle.tsx new file mode 100644 index 000000000..ea87b4974 --- /dev/null +++ b/clients/web/src/components/elements/SortToggle/SortToggle.tsx @@ -0,0 +1,42 @@ +import { Select } from "@mantine/core"; +import { TbSortAscending2, TbSortDescending2 } from "react-icons/tb"; + +export type SortDirection = "oldest-first" | "newest-first"; + +export interface SortToggleProps { + value: SortDirection; + onChange: (next: SortDirection) => void; + "aria-label"?: string; +} + +const OPTIONS: { value: SortDirection; label: string }[] = [ + { value: "newest-first", label: "Newest First" }, + { value: "oldest-first", label: "Oldest First" }, +]; + +function isSortDirection(value: string | null): value is SortDirection { + return value === "oldest-first" || value === "newest-first"; +} + +export function SortToggle({ + value, + onChange, + "aria-label": ariaLabel = "Sort direction", +}: SortToggleProps) { + const Icon = value === "newest-first" ? TbSortDescending2 : TbSortAscending2; + return ( +