diff --git a/apps/web/src/features/referrals/components/referrals-sidebar.tsx b/apps/web/src/features/referrals/components/referrals-sidebar.tsx index fddc0e7..1edf2b1 100644 --- a/apps/web/src/features/referrals/components/referrals-sidebar.tsx +++ b/apps/web/src/features/referrals/components/referrals-sidebar.tsx @@ -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 = [ { @@ -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} @@ -132,7 +131,8 @@ export function ReferralsSidebar({ ) : ( <>

- You're receiving a {traderDiscountPct}% discount on your trades! + You're receiving a {traderDiscountPct}% discount on your + trades!

The reduced rate applies to every open and close fee. diff --git a/apps/web/src/features/referrals/components/shared/faq-accordion.tsx b/apps/web/src/features/referrals/components/shared/faq-accordion.tsx index 60bde85..59f1c4b 100644 --- a/apps/web/src/features/referrals/components/shared/faq-accordion.tsx +++ b/apps/web/src/features/referrals/components/shared/faq-accordion.tsx @@ -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 ( - - - - ) -} - -function AccordionItem({ item }: { item: FaqItem }) { - const [open, setOpen] = useState(false) - const panelId = useId() - - return ( -

- - -
-

{item.a}

-
-
- ) -} - type Props = { items: Array title?: string + headingLevel?: HeadingLevel } -export function FaqAccordion({ items, title = "FAQ" }: Props) { +export function FaqAccordion({ + items, + title = "FAQ", + headingLevel = 3, +}: Props) { return (
-

+

{title}

-
+ {items.map((item) => ( - + + + {item.q} + + +

{item.a}

+
+
))} -
+
) } diff --git a/packages/ui/src/components/accordion.test.tsx b/packages/ui/src/components/accordion.test.tsx new file mode 100644 index 0000000..307c0b3 --- /dev/null +++ b/packages/ui/src/components/accordion.test.tsx @@ -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 = ( + <> + + First question + First answer + + + Second question + Second answer + + + ) + + if (type === "multiple") { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} + +describe("Accordion", () => { + it("renders triggers as real buttons inside semantic headings", () => { + render() + + 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() + + 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() + + 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() + + 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( + + + First question + First answer + + + Second question + Second answer + + + ) + + 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( + + + First question + First answer + + + Second question + Second answer + + + ) + + 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( + + + Disabled question + Disabled answer + + + ) + + 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( + + + First question + First answer + + + Second question + Second answer + + + ) + + 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() + + expect(screen.getByRole("region", { name: "First question" })).toHaveClass( + "motion-reduce:transition-none" + ) + }) +}) diff --git a/packages/ui/src/components/accordion.tsx b/packages/ui/src/components/accordion.tsx new file mode 100644 index 0000000..a734b64 --- /dev/null +++ b/packages/ui/src/components/accordion.tsx @@ -0,0 +1,261 @@ +"use client" + +import * as React from "react" + +import { ChevronDownIcon } from "@hugeicons/core-free-icons" +import { HugeiconsIcon } from "@hugeicons/react" +import { cn } from "@workspace/ui/lib/utils" + +type AccordionType = "single" | "multiple" + +type AccordionBaseProps = Omit< + React.ComponentProps<"div">, + "defaultValue" | "onChange" +> & { + type?: AccordionType + collapsible?: boolean +} + +type SingleAccordionProps = AccordionBaseProps & { + type?: "single" + value?: string | null + defaultValue?: string | null + onValueChange?: (value: string | null) => void +} + +type MultipleAccordionProps = AccordionBaseProps & { + type: "multiple" + value?: Array + defaultValue?: Array + onValueChange?: (value: Array) => void +} + +type AccordionProps = SingleAccordionProps | MultipleAccordionProps + +type AccordionContextValue = { + type: AccordionType + openValues: Array + collapsible: boolean + toggleValue: (value: string) => void +} + +type AccordionItemContextValue = { + value: string + disabled: boolean + open: boolean + triggerId: string + contentId: string +} + +const AccordionContext = React.createContext(null) +const AccordionItemContext = + React.createContext(null) + +function useAccordionContext(component: string) { + const context = React.useContext(AccordionContext) + if (!context) { + throw new Error(`${component} must be used within Accordion`) + } + return context +} + +function useAccordionItemContext(component: string) { + const context = React.useContext(AccordionItemContext) + if (!context) { + throw new Error(`${component} must be used within AccordionItem`) + } + return context +} + +function Accordion(props: AccordionProps) { + const { + className, + type = "single", + defaultValue, + value, + onValueChange, + collapsible = true, + ...rootProps + } = props + + const isControlled = value !== undefined + const [uncontrolledValue, setUncontrolledValue] = React.useState< + string | null | Array + >(() => { + if (type === "multiple") return defaultValue ?? [] + return defaultValue ?? null + }) + + const openValues = React.useMemo(() => { + const currentValue = isControlled ? value : uncontrolledValue + if (type === "multiple") + return Array.isArray(currentValue) ? currentValue : [] + return typeof currentValue === "string" ? [currentValue] : [] + }, [isControlled, type, uncontrolledValue, value]) + + const toggleValue = React.useCallback( + (itemValue: string) => { + if (type === "multiple") { + const nextValue = openValues.includes(itemValue) + ? openValues.filter((openValue) => openValue !== itemValue) + : [...openValues, itemValue] + const multipleOnValueChange = + onValueChange as MultipleAccordionProps["onValueChange"] + + if (!isControlled) setUncontrolledValue(nextValue) + multipleOnValueChange?.(nextValue) + return + } + + const isOpen = openValues.includes(itemValue) + const nextValue = isOpen && collapsible ? null : itemValue + const singleOnValueChange = + onValueChange as SingleAccordionProps["onValueChange"] + + if (!isControlled) setUncontrolledValue(nextValue) + singleOnValueChange?.(nextValue) + }, + [collapsible, isControlled, onValueChange, openValues, type] + ) + + const contextValue = React.useMemo( + () => ({ type, openValues, collapsible, toggleValue }), + [collapsible, openValues, toggleValue, type] + ) + + return ( + +
+ + ) +} + +type AccordionItemProps = React.ComponentProps<"div"> & { + value: string + disabled?: boolean +} + +function AccordionItem({ + className, + value, + disabled = false, + ...props +}: AccordionItemProps) { + const { openValues } = useAccordionContext("AccordionItem") + const generatedId = React.useId() + const open = openValues.includes(value) + const triggerId = `${generatedId}-trigger` + const contentId = `${generatedId}-content` + + const contextValue = React.useMemo( + () => ({ value, disabled, open, triggerId, contentId }), + [contentId, disabled, open, triggerId, value] + ) + + return ( + +
+ + ) +} + +type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6 + +type AccordionTriggerProps = React.ComponentProps<"button"> & { + headingLevel?: HeadingLevel + showIcon?: boolean +} + +function AccordionTrigger({ + className, + children, + headingLevel = 3, + showIcon = true, + onClick, + ...props +}: AccordionTriggerProps) { + const { toggleValue } = useAccordionContext("AccordionTrigger") + const { value, disabled, open, triggerId, contentId } = + useAccordionItemContext("AccordionTrigger") + const Heading = `h${headingLevel}` as React.ElementType + + return ( + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const { open, triggerId, contentId } = + useAccordionItemContext("AccordionContent") + + return ( +
+
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export type { + AccordionProps, + AccordionItemProps, + AccordionTriggerProps, + HeadingLevel, +}