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
12 changes: 6 additions & 6 deletions apps/web/src/features/referrals/components/referrals-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Card, CardContent } from "@workspace/ui/components/card"
import { Text } from "@workspace/ui/components/text"
import { cn } from "@workspace/ui/lib/utils"
import { getTierByLevel } from "../data/tiers"
import { CodeDisplay } from "./shared/code-display"
import { FaqAccordion } from "./shared/faq-accordion"
import { TierBadge } from "./shared/tier-badge"
import type {FaqItem} from "./shared/faq-accordion";
import { FaqAccordion } from "./shared/faq-accordion"
import type { FaqItem } from "./shared/faq-accordion"

const TRADER_FAQS: Array<FaqItem> = [
{
Expand Down Expand Up @@ -113,7 +112,7 @@ export function ReferralsSidebar({
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-10 font-semibold ring-1",
tier.colorClass,
tier.ringClass,
tier.ringClass
)}
>
{tier.label}
Expand All @@ -132,7 +131,8 @@ export function ReferralsSidebar({
) : (
<>
<p className="text-xs font-semibold text-green-400">
You're receiving a {traderDiscountPct}% discount on your trades!
You're receiving a {traderDiscountPct}% discount on your
trades!
</p>
<p className="mt-1 text-11 text-muted-foreground">
The reduced rate applies to every open and close fee.
Expand Down
80 changes: 24 additions & 56 deletions apps/web/src/features/referrals/components/shared/faq-accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,44 @@
import { useId, useState } from "react"
import { Card, CardContent } from "@workspace/ui/components/card"
import { Text } from "@workspace/ui/components/text"
import { cn } from "@workspace/ui/lib/utils"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
type HeadingLevel,
} from "@workspace/ui/components/accordion"

export type FaqItem = {
q: string
a: string
}

function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
className={cn("shrink-0 transition-transform duration-200", open && "rotate-180")}
>
<path d="M6 9l6 6 6-6" />
</svg>
)
}

function AccordionItem({ item }: { item: FaqItem }) {
const [open, setOpen] = useState(false)
const panelId = useId()

return (
<div className="border-b border-border/60 last:border-b-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={panelId}
className="flex w-full items-center justify-between gap-3 py-3 text-left"
>
<span className="text-xs font-medium leading-snug">{item.q}</span>
<ChevronIcon open={open} />
</button>

<div
id={panelId}
className={cn(
"overflow-hidden transition-all duration-200",
open ? "max-h-96 pb-3" : "max-h-0",
)}
>
<p className="text-11 leading-relaxed text-muted-foreground">{item.a}</p>
</div>
</div>
)
}

type Props = {
items: Array<FaqItem>
title?: string
headingLevel?: HeadingLevel
}

export function FaqAccordion({ items, title = "FAQ" }: Props) {
export function FaqAccordion({
items,
title = "FAQ",
headingLevel = 3,
}: Props) {
return (
<div className="rounded-xl border border-border bg-card p-4">
<p className="mb-1 text-11 font-semibold uppercase tracking-wider text-muted-foreground">
<p className="mb-1 text-11 font-semibold tracking-wider text-muted-foreground uppercase">
{title}
</p>
<div>
<Accordion type="single" className="w-full">
{items.map((item) => (
<AccordionItem key={item.q} item={item} />
<AccordionItem key={item.q} value={item.q}>
<AccordionTrigger headingLevel={headingLevel}>
{item.q}
</AccordionTrigger>
<AccordionContent>
<p>{item.a}</p>
</AccordionContent>
</AccordionItem>
))}
</div>
</Accordion>
</div>
)
}
206 changes: 206 additions & 0 deletions packages/ui/src/components/accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, expect, it, vi } from "vitest"
import { render, screen, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"

import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./accordion"

function ExampleAccordion({
type = "single",
}: {
type?: "single" | "multiple"
}) {
const content = (
<>
<AccordionItem value="first">
<AccordionTrigger headingLevel={2}>First question</AccordionTrigger>
<AccordionContent>First answer</AccordionContent>
</AccordionItem>
<AccordionItem value="second">
<AccordionTrigger headingLevel={2}>Second question</AccordionTrigger>
<AccordionContent>Second answer</AccordionContent>
</AccordionItem>
</>
)

if (type === "multiple") {
return (
<Accordion type="multiple" defaultValue={["first"]}>
{content}
</Accordion>
)
}

return (
<Accordion type="single" defaultValue="first">
{content}
</Accordion>
)
}

describe("Accordion", () => {
it("renders triggers as real buttons inside semantic headings", () => {
render(<ExampleAccordion />)

const heading = screen.getByRole("heading", {
level: 2,
name: "First question",
})

expect(
within(heading).getByRole("button", { name: "First question" })
).toBeInTheDocument()
})

it("connects content ids and expanded state programmatically", () => {
render(<ExampleAccordion />)

const trigger = screen.getByRole("button", { name: "First question" })
const contentId = trigger.getAttribute("aria-controls")
const content = document.getElementById(contentId ?? "")

expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(content).toHaveAttribute("role", "region")
expect(content).toHaveAttribute("aria-labelledby", trigger.id)
expect(content).toHaveAttribute("aria-hidden", "false")
})

it("keeps only one item open in single mode", async () => {
const user = userEvent.setup()
render(<ExampleAccordion />)

const first = screen.getByRole("button", { name: "First question" })
const second = screen.getByRole("button", { name: "Second question" })

expect(first).toHaveAttribute("aria-expanded", "true")
expect(second).toHaveAttribute("aria-expanded", "false")

await user.click(second)

expect(first).toHaveAttribute("aria-expanded", "false")
expect(second).toHaveAttribute("aria-expanded", "true")
})

it("allows multiple items to stay open in multiple mode", async () => {
const user = userEvent.setup()
render(<ExampleAccordion type="multiple" />)

const first = screen.getByRole("button", { name: "First question" })
const second = screen.getByRole("button", { name: "Second question" })

await user.click(second)

expect(first).toHaveAttribute("aria-expanded", "true")
expect(second).toHaveAttribute("aria-expanded", "true")
})

it("supports controlled single state", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()

render(
<Accordion type="single" value="first" onValueChange={onValueChange}>
<AccordionItem value="first">
<AccordionTrigger>First question</AccordionTrigger>
<AccordionContent>First answer</AccordionContent>
</AccordionItem>
<AccordionItem value="second">
<AccordionTrigger>Second question</AccordionTrigger>
<AccordionContent>Second answer</AccordionContent>
</AccordionItem>
</Accordion>
)

await user.click(screen.getByRole("button", { name: "Second question" }))

expect(onValueChange).toHaveBeenCalledWith("second")
expect(
screen.getByRole("button", { name: "First question" })
).toHaveAttribute("aria-expanded", "true")
})

it("supports controlled multiple state", async () => {
const user = userEvent.setup()
const onValueChange = vi.fn()

render(
<Accordion
type="multiple"
value={["first"]}
onValueChange={onValueChange}
>
<AccordionItem value="first">
<AccordionTrigger>First question</AccordionTrigger>
<AccordionContent>First answer</AccordionContent>
</AccordionItem>
<AccordionItem value="second">
<AccordionTrigger>Second question</AccordionTrigger>
<AccordionContent>Second answer</AccordionContent>
</AccordionItem>
</Accordion>
)

await user.click(screen.getByRole("button", { name: "Second question" }))

expect(onValueChange).toHaveBeenCalledWith(["first", "second"])
})

it("does not toggle disabled items", async () => {
const user = userEvent.setup()
render(
<Accordion type="single">
<AccordionItem value="disabled" disabled>
<AccordionTrigger>Disabled question</AccordionTrigger>
<AccordionContent>Disabled answer</AccordionContent>
</AccordionItem>
</Accordion>
)

const trigger = screen.getByRole("button", { name: "Disabled question" })
await user.click(trigger)

expect(trigger).toBeDisabled()
expect(trigger).toHaveAttribute("aria-expanded", "false")
})

it("supports sequential keyboard navigation, Space, and Enter", async () => {
const user = userEvent.setup()
render(
<Accordion type="multiple">
<AccordionItem value="first">
<AccordionTrigger>First question</AccordionTrigger>
<AccordionContent>First answer</AccordionContent>
</AccordionItem>
<AccordionItem value="second">
<AccordionTrigger>Second question</AccordionTrigger>
<AccordionContent>Second answer</AccordionContent>
</AccordionItem>
</Accordion>
)

const first = screen.getByRole("button", { name: "First question" })
const second = screen.getByRole("button", { name: "Second question" })

await user.tab()
expect(first).toHaveFocus()
await user.keyboard(" ")
expect(first).toHaveAttribute("aria-expanded", "true")

await user.tab()
expect(second).toHaveFocus()
await user.keyboard("{Enter}")
expect(second).toHaveAttribute("aria-expanded", "true")
})

it("uses motion-reduce classes on animated parts", () => {
render(<ExampleAccordion />)

expect(screen.getByRole("region", { name: "First question" })).toHaveClass(
"motion-reduce:transition-none"
)
})
})
Loading
Loading