From dbeca4a4c300f5bb1973c199dc014408ee948b5c Mon Sep 17 00:00:00 2001 From: yyh Date: Sat, 6 Dec 2025 21:17:40 +0800 Subject: [PATCH 1/3] feat: add some components --- web/components/ui/alert/index.spec.tsx | 59 +++++ web/components/ui/alert/index.stories.tsx | 93 +++++++ web/components/ui/alert/index.tsx | 66 +++++ web/components/ui/button-group/index.spec.tsx | 70 +++++ .../ui/button-group/index.stories.tsx | 75 ++++++ web/components/ui/button-group/index.tsx | 83 ++++++ web/components/ui/carousel/index.spec.tsx | 116 +++++++++ web/components/ui/carousel/index.stories.tsx | 91 +++++++ web/components/ui/carousel/index.tsx | 241 ++++++++++++++++++ web/components/ui/collapsible/index.spec.tsx | 50 ++++ .../ui/collapsible/index.stories.tsx | 82 ++++++ web/components/ui/collapsible/index.tsx | 33 +++ web/components/ui/hover-card/index.spec.tsx | 46 ++++ .../ui/hover-card/index.stories.tsx | 82 ++++++ web/components/ui/hover-card/index.tsx | 44 ++++ web/components/ui/input-group/index.spec.tsx | 80 ++++++ .../ui/input-group/index.stories.tsx | 87 +++++++ web/components/ui/input-group/index.tsx | 170 ++++++++++++ web/components/ui/progress/index.spec.tsx | 28 ++ web/components/ui/progress/index.stories.tsx | 58 +++++ web/components/ui/progress/index.tsx | 31 +++ web/components/ui/scroll-area/index.spec.tsx | 43 ++++ .../ui/scroll-area/index.stories.tsx | 50 ++++ web/components/ui/scroll-area/index.tsx | 58 +++++ web/components/ui/separator/index.spec.tsx | 39 +++ web/components/ui/separator/index.stories.tsx | 60 +++++ web/components/ui/separator/index.tsx | 28 ++ web/components/ui/textarea/index.spec.tsx | 32 +++ web/components/ui/textarea/index.stories.tsx | 57 +++++ web/components/ui/textarea/index.tsx | 18 ++ 30 files changed, 2070 insertions(+) create mode 100644 web/components/ui/alert/index.spec.tsx create mode 100644 web/components/ui/alert/index.stories.tsx create mode 100644 web/components/ui/alert/index.tsx create mode 100644 web/components/ui/button-group/index.spec.tsx create mode 100644 web/components/ui/button-group/index.stories.tsx create mode 100644 web/components/ui/button-group/index.tsx create mode 100644 web/components/ui/carousel/index.spec.tsx create mode 100644 web/components/ui/carousel/index.stories.tsx create mode 100644 web/components/ui/carousel/index.tsx create mode 100644 web/components/ui/collapsible/index.spec.tsx create mode 100644 web/components/ui/collapsible/index.stories.tsx create mode 100644 web/components/ui/collapsible/index.tsx create mode 100644 web/components/ui/hover-card/index.spec.tsx create mode 100644 web/components/ui/hover-card/index.stories.tsx create mode 100644 web/components/ui/hover-card/index.tsx create mode 100644 web/components/ui/input-group/index.spec.tsx create mode 100644 web/components/ui/input-group/index.stories.tsx create mode 100644 web/components/ui/input-group/index.tsx create mode 100644 web/components/ui/progress/index.spec.tsx create mode 100644 web/components/ui/progress/index.stories.tsx create mode 100644 web/components/ui/progress/index.tsx create mode 100644 web/components/ui/scroll-area/index.spec.tsx create mode 100644 web/components/ui/scroll-area/index.stories.tsx create mode 100644 web/components/ui/scroll-area/index.tsx create mode 100644 web/components/ui/separator/index.spec.tsx create mode 100644 web/components/ui/separator/index.stories.tsx create mode 100644 web/components/ui/separator/index.tsx create mode 100644 web/components/ui/textarea/index.spec.tsx create mode 100644 web/components/ui/textarea/index.stories.tsx create mode 100644 web/components/ui/textarea/index.tsx diff --git a/web/components/ui/alert/index.spec.tsx b/web/components/ui/alert/index.spec.tsx new file mode 100644 index 0000000..5037eb2 --- /dev/null +++ b/web/components/ui/alert/index.spec.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { Alert, AlertDescription, AlertTitle } from "./index"; + +describe("Alert", () => { + test("renders an accessible alert with title and description slots", () => { + render( + + Heads up! + Use caution when proceeding. + + ); + + const alert = screen.getByRole("alert"); + expect(alert).toHaveAttribute("data-slot", "alert"); + expect(alert).toHaveClass("custom-alert"); + expect(alert.className).toContain("rounded-lg"); + + expect(screen.getByText("Heads up!")).toHaveAttribute( + "data-slot", + "alert-title" + ); + expect(screen.getByText("Use caution when proceeding.")).toHaveAttribute( + "data-slot", + "alert-description" + ); + }); + + test("applies the destructive variant styling", () => { + render( + + System issue + Something went wrong. + + ); + + const alert = screen.getByRole("alert"); + expect(alert.className).toContain("text-destructive"); + expect(alert.className).toContain("bg-card"); + }); + + test("keeps title and description aligned to the content column", () => { + render( + + Title + Details + + ); + + const title = screen.getByText("Title"); + const description = screen.getByText("Details"); + + expect(title.className).toContain("col-start-2"); + expect(description.className).toContain("col-start-2"); + expect(description.className).toContain("text-muted-foreground"); + }); +}); diff --git a/web/components/ui/alert/index.stories.tsx b/web/components/ui/alert/index.stories.tsx new file mode 100644 index 0000000..20a2e4d --- /dev/null +++ b/web/components/ui/alert/index.stories.tsx @@ -0,0 +1,93 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AlertCircleIcon, CheckIcon, InfoIcon } from "lucide-react"; + +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; + +const meta = { + title: "UI/Alert", + component: Alert, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + variant: { + control: "select", + options: ["default", "destructive"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + variant: "default", + }, + render: args => ( + + Heads up + + Use alerts to highlight contextual information without disrupting the + primary task. + + + ), +}; + +export const WithIcon: Story = { + render: args => ( + + + New feature available + + Discover the redesigned workspace switcher in the top navigation bar. + + + ), + args: { + variant: "default", + }, +}; + +export const Destructive: Story = { + render: args => ( + + + Action required + + Billing failed for the last invoice. Update payment details to prevent + service interruption. + + + ), + args: { + variant: "destructive", + }, +}; + +export const Checklist: Story = { + render: () => ( +
+ + + Environment configured + + Connection to production database is healthy and up to date. + + + + + Deploy notice + + Deployments run nightly at 02:00 UTC with automatic rollbacks. + + +
+ ), +}; diff --git a/web/components/ui/alert/index.tsx b/web/components/ui/alert/index.tsx new file mode 100644 index 0000000..5b1a0b5 --- /dev/null +++ b/web/components/ui/alert/index.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/web/components/ui/button-group/index.spec.tsx b/web/components/ui/button-group/index.spec.tsx new file mode 100644 index 0000000..7013b93 --- /dev/null +++ b/web/components/ui/button-group/index.spec.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, +} from "./index"; + +describe("ButtonGroup", () => { + test("renders a horizontal grouped control with role semantics", () => { + render( + + + + + ); + + const group = screen.getByTestId("button-group"); + expect(group).toHaveAttribute("role", "group"); + expect(group).toHaveAttribute("data-slot", "button-group"); + expect(group).toHaveAttribute("data-orientation", "horizontal"); + expect(group.className).toContain("flex"); + expect(group).toHaveClass("custom-group"); + }); + + test("supports vertical orientation for stacked actions", () => { + render( + + + + + ); + + const group = screen.getByRole("group"); + expect(group).toHaveAttribute("data-orientation", "vertical"); + expect(group.className).toContain("flex-col"); + }); + + test("forwards typography styles to child nodes when rendered asChild", () => { + render( + + Schedule + + ); + + const label = screen.getByTestId("label"); + expect(label.className).toContain("bg-muted"); + expect(label.className).toContain("rounded-md"); + }); + + test("renders separators that inherit orientation styles", () => { + render( + + + + + + ); + + const separator = screen.getByTestId("separator"); + expect(separator).toHaveAttribute("data-slot", "button-group-separator"); + expect(separator.className).toContain("self-stretch"); + }); +}); diff --git a/web/components/ui/button-group/index.stories.tsx b/web/components/ui/button-group/index.stories.tsx new file mode 100644 index 0000000..6b2a3f6 --- /dev/null +++ b/web/components/ui/button-group/index.stories.tsx @@ -0,0 +1,75 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CalendarDaysIcon, CheckIcon, SettingsIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, +} from "@/components/ui/button-group"; + +const meta = { + title: "UI/Button Group", + component: ButtonGroup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, + argTypes: { + orientation: { + control: "select", + options: ["horizontal", "vertical"], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Toolbar: Story = { + args: { + orientation: "horizontal", + }, + render: args => ( + + + + Sprint 12 + + + + + ), +}; + +export const WithSeparators: Story = { + render: () => ( + + + + + + + + ), +}; + +export const Vertical: Story = { + render: () => ( + + + + Deployment + + + + + + + ), +}; diff --git a/web/components/ui/button-group/index.tsx b/web/components/ui/button-group/index.tsx new file mode 100644 index 0000000..47b7583 --- /dev/null +++ b/web/components/ui/button-group/index.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Separator } from "@/components/ui/separator"; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +); + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot : "div"; + + return ( + + ); +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +}; diff --git a/web/components/ui/carousel/index.spec.tsx b/web/components/ui/carousel/index.spec.tsx new file mode 100644 index 0000000..197459e --- /dev/null +++ b/web/components/ui/carousel/index.spec.tsx @@ -0,0 +1,116 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; +import useEmblaCarousel from "embla-carousel-react"; + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "./index"; + +jest.mock("embla-carousel-react", () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockCarouselRef = jest.fn(); +const listeners: Record void> = {}; +const mockApi = { + canScrollPrev: jest.fn(), + canScrollNext: jest.fn(), + scrollPrev: jest.fn(), + scrollNext: jest.fn(), + on: jest.fn(), + off: jest.fn(), +}; +const useEmblaCarouselMock = useEmblaCarousel as unknown as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + Object.keys(listeners).forEach(key => delete listeners[key]); + + mockApi.canScrollPrev.mockReturnValue(false); + mockApi.canScrollNext.mockReturnValue(true); + mockApi.on.mockImplementation( + (event: string, handler: (api: unknown) => void) => { + listeners[event] = handler; + } + ); + + useEmblaCarouselMock.mockReturnValue([mockCarouselRef, mockApi]); +}); + +describe("Carousel", () => { + test("sets navigation state from Embla API and exposes it to controls", () => { + const setApi = jest.fn(); + + render( + + + Slide 1 + Slide 2 + + + + + ); + + expect(setApi).toHaveBeenCalledWith(mockApi); + + const prev = screen.getByRole("button", { name: /previous slide/i }); + const next = screen.getByRole("button", { name: /next slide/i }); + + expect(prev).toBeDisabled(); + expect(next).not.toBeDisabled(); + + mockApi.canScrollPrev.mockReturnValue(true); + mockApi.canScrollNext.mockReturnValue(false); + + act(() => { + listeners.select?.(mockApi); + }); + + expect(prev).not.toBeDisabled(); + expect(next).toBeDisabled(); + }); + + test("responds to keyboard navigation via arrow keys", () => { + render( + + + Slide + + + + + ); + + const region = document.querySelector('[data-slot="carousel"]'); + + fireEvent.keyDown(region as HTMLElement, { key: "ArrowLeft" }); + fireEvent.keyDown(region as HTMLElement, { key: "ArrowRight" }); + + expect(mockApi.scrollPrev).toHaveBeenCalled(); + expect(mockApi.scrollNext).toHaveBeenCalled(); + }); + + test("applies vertical orientation spacing to content and items", () => { + render( + + + Card + + + ); + + const content = screen.getByTestId("content"); + const item = screen.getByTestId("item"); + + expect(content.className).toContain("flex-col"); + expect(content.className).toContain("-mt-4"); + expect(item.className).toContain("pt-4"); + }); +}); diff --git a/web/components/ui/carousel/index.stories.tsx b/web/components/ui/carousel/index.stories.tsx new file mode 100644 index 0000000..89765ad --- /dev/null +++ b/web/components/ui/carousel/index.stories.tsx @@ -0,0 +1,91 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@/components/ui/carousel"; + +const meta = { + title: "UI/Carousel", + component: Carousel, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const slides = [ + { + title: "Analytics", + description: "Track product usage and retention in real time.", + }, + { + title: "Automation", + description: "Automate onboarding flows for new customers.", + }, + { + title: "Security", + description: "Enforce MFA and device trust policies with ease.", + }, + { + title: "Reliability", + description: "Multi-region redundancy keeps uptime above 99.9%.", + }, + { + title: "Support", + description: "Reach the team 24/7 with guaranteed response times.", + }, +]; + +export const Default: Story = { + render: () => ( +
+ + + {slides.map(item => ( + +
+
Feature
+

{item.title}

+

+ {item.description} +

+
+
+ ))} +
+ + +
+
+ ), +}; + +export const Vertical: Story = { + render: () => ( +
+ + + {["Overview", "Incidents", "Deployments", "SLOs"].map(item => ( + +
+

{item}

+

+ Scroll vertically to browse recent updates. +

+
+
+ ))} +
+ + +
+
+ ), +}; diff --git a/web/components/ui/carousel/index.tsx b/web/components/ui/carousel/index.tsx new file mode 100644 index 0000000..00b5a4e --- /dev/null +++ b/web/components/ui/carousel/index.tsx @@ -0,0 +1,241 @@ +"use client"; + +import * as React from "react"; +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext] + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; diff --git a/web/components/ui/collapsible/index.spec.tsx b/web/components/ui/collapsible/index.spec.tsx new file mode 100644 index 0000000..249db80 --- /dev/null +++ b/web/components/ui/collapsible/index.spec.tsx @@ -0,0 +1,50 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "./index"; + +describe("Collapsible", () => { + test("toggles open state when the trigger is clicked", () => { + const handleOpenChange = jest.fn(); + + render( + + Toggle + Hidden content + + ); + + const trigger = screen.getByRole("button", { name: /toggle/i }); + + fireEvent.click(trigger); + expect(handleOpenChange).toHaveBeenCalledWith(true); + + fireEvent.click(trigger); + expect(handleOpenChange).toHaveBeenCalledWith(false); + }); + + test("exposes slot attributes for trigger and content", () => { + render( + + Trigger + + Panel content + + + ); + + expect(screen.getByTestId("trigger")).toHaveAttribute( + "data-slot", + "collapsible-trigger" + ); + + const content = screen.getByTestId("content"); + expect(content).toHaveAttribute("data-slot", "collapsible-content"); + expect(content.getAttribute("data-state")).toBe("open"); + }); +}); diff --git a/web/components/ui/collapsible/index.stories.tsx b/web/components/ui/collapsible/index.stories.tsx new file mode 100644 index 0000000..ac235a9 --- /dev/null +++ b/web/components/ui/collapsible/index.stories.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ChevronDownIcon, FileTextIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +const meta = { + title: "UI/Collapsible", + component: Collapsible, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [open, setOpen] = useState(false); + + return ( + + + Project summary + + + + Keep non-critical content hidden by default to keep pages scannable. + Use collapsibles for FAQs, advanced options, or audit details. + + + ); + }, +}; + +export const WithActions: Story = { + render: () => ( + + + + + +
+
+

v2.8.0

+

+ Performance improvements and bug fixes. +

+
+ +
+
    +
  • Reduced cold-start times for the API.
  • +
  • Added audit logs for permission updates.
  • +
  • Improved error messages on failed imports.
  • +
+
+
+ ), +}; diff --git a/web/components/ui/collapsible/index.tsx b/web/components/ui/collapsible/index.tsx new file mode 100644 index 0000000..90935c6 --- /dev/null +++ b/web/components/ui/collapsible/index.tsx @@ -0,0 +1,33 @@ +"use client"; + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"; + +function Collapsible({ + ...props +}: React.ComponentProps) { + return ; +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/web/components/ui/hover-card/index.spec.tsx b/web/components/ui/hover-card/index.spec.tsx new file mode 100644 index 0000000..e19d331 --- /dev/null +++ b/web/components/ui/hover-card/index.spec.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { HoverCard, HoverCardContent, HoverCardTrigger } from "./index"; + +describe("HoverCard", () => { + test("renders trigger and content with slot attributes", () => { + render( + + Trigger + + Card details + + + ); + + expect(screen.getByTestId("trigger")).toHaveAttribute( + "data-slot", + "hover-card-trigger" + ); + + const content = screen.getByTestId("content"); + expect(content).toHaveAttribute("data-slot", "hover-card-content"); + expect(content.className).toContain("rounded-md"); + }); + + test("supports alignment and custom styling", () => { + render( + + Trigger + + Insights + + + ); + + const content = screen.getByText("Insights"); + expect(content).toHaveClass("custom-card"); + expect(content.className).toContain("data-[state=open]:animate-in"); + }); +}); diff --git a/web/components/ui/hover-card/index.stories.tsx b/web/components/ui/hover-card/index.stories.tsx new file mode 100644 index 0000000..febb040 --- /dev/null +++ b/web/components/ui/hover-card/index.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ArrowUpRightIcon, CheckIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; + +const meta = { + title: "UI/Hover Card", + component: HoverCard, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const ProfilePreview: Story = { + render: () => ( + + + + @design-lead + + +
+ +
+

+ Taylor Kim +

+

+ Design Lead · San Francisco +

+
+
+

+ Shipping a new design system for the workspace experience. Open for + feedback on tokens and motion guidelines. +

+
+ + Available for pairing this week +
+
+
+ ), +}; + +export const WithCTA: Story = { + render: () => ( + + + + + +
+

Q2 Highlights

+

+ Major bets and milestones planned for the quarter. +

+
+
    +
  • Multi-tenant billing rollout
  • +
  • Improved changelog and release feeds
  • +
  • Faster onboarding with saved templates
  • +
+ +
+
+ ), +}; diff --git a/web/components/ui/hover-card/index.tsx b/web/components/ui/hover-card/index.tsx new file mode 100644 index 0000000..fa38941 --- /dev/null +++ b/web/components/ui/hover-card/index.tsx @@ -0,0 +1,44 @@ +"use client"; + +import * as React from "react"; +import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; + +import { cn } from "@/lib/utils"; + +function HoverCard({ + ...props +}: React.ComponentProps) { + return ; +} + +function HoverCardTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function HoverCardContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/web/components/ui/input-group/index.spec.tsx b/web/components/ui/input-group/index.spec.tsx new file mode 100644 index 0000000..f4e8860 --- /dev/null +++ b/web/components/ui/input-group/index.spec.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupTextarea, + InputGroupText, +} from "./index"; + +describe("InputGroup", () => { + test("focuses the input when clicking on an addon", () => { + render( + + + +1 + + + + ); + + const input = screen.getByPlaceholderText("Phone"); + expect(document.activeElement).not.toBe(input); + + fireEvent.click(screen.getByTestId("addon")); + expect(document.activeElement).toBe(input); + }); + + test("does not steal focus when clicking an interactive child", () => { + render( + + + + ↵ + + + + + ); + + const innerButton = screen.getByTestId("inner-button"); + fireEvent.click(innerButton); + + expect(screen.getByPlaceholderText("Search")).not.toHaveFocus(); + }); + + test("supports textarea controls and block alignment", () => { + render( + + + Label + + + + ); + + const addon = screen.getByTestId("block-addon"); + expect(addon).toHaveAttribute("data-align", "block-start"); + expect(addon.className).toContain("w-full"); + + const textarea = screen.getByLabelText("Description"); + expect(textarea).toHaveAttribute("data-slot", "input-group-control"); + expect(textarea.className).toContain("resize-none"); + }); + + test("applies size token to embedded buttons", () => { + render( + + Run + + ); + + const button = screen.getByTestId("group-button"); + expect(button).toHaveAttribute("data-size", "icon-xs"); + expect(button.className).toContain("rounded-"); + }); +}); diff --git a/web/components/ui/input-group/index.stories.tsx b/web/components/ui/input-group/index.stories.tsx new file mode 100644 index 0000000..53ba591 --- /dev/null +++ b/web/components/ui/input-group/index.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AtSignIcon, LinkIcon, SearchIcon } from "lucide-react"; + +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, + InputGroupText, + InputGroupTextarea, +} from "@/components/ui/input-group"; + +const meta = { + title: "UI/Input Group", + component: InputGroup, + tags: ["autodocs"], + parameters: { + layout: "centered", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const InlineAddons: Story = { + render: () => ( +
+ + + + + + + + + .company.com + + + + + https:// + + + + + + + + +
+ ), +}; + +export const WithActions: Story = { + render: () => ( + + + + + + + + + + Filter + + + + ), +}; + +export const WithTextarea: Story = { + render: () => ( + + + Notes + + + + Max 500 characters + + + ), +}; diff --git a/web/components/ui/input-group/index.tsx b/web/components/ui/input-group/index.tsx new file mode 100644 index 0000000..e8ac0c6 --- /dev/null +++ b/web/components/ui/input-group/index.tsx @@ -0,0 +1,170 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ); +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +); + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return; + } + e.currentTarget.parentElement?.querySelector("input")?.focus(); + }} + {...props} + /> + ); +} + +const inputGroupButtonVariants = cva( + "text-sm shadow-none flex gap-2 items-center", + { + variants: { + size: { + xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", + sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +); + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +