diff --git a/src/components/ui/__stories__/Command.stories.tsx b/src/components/ui/__stories__/Command.stories.tsx new file mode 100644 index 00000000000..312e420578b --- /dev/null +++ b/src/components/ui/__stories__/Command.stories.tsx @@ -0,0 +1,253 @@ +import { Calendar, Settings, Smile, User, Wallet } from "lucide-react" +import type { Meta, StoryObj } from "@storybook/nextjs" + +import { Button } from "../buttons/Button" +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "../command" + +const meta = { + title: "UI / Primitives / Command", + component: Command, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Command palette built on `cmdk`. Compose `Command` > `CommandInput` + `CommandList` containing `CommandGroup`s of `CommandItem`s. `CommandSeparator` divides groups, `CommandShortcut` annotates an item with a keyboard hint, `CommandEmpty` renders when filtering yields no matches. Wrap in `CommandDialog` for a modal palette (Cmd-K style).", + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + + + + No results found. + + Calendar + Settings + Profile + + + + ), +} + +export const WithGroups: Story = { + parameters: { + docs: { + description: { + story: + "Multiple `CommandGroup`s with headings let users scan by category.", + }, + }, + }, + render: () => ( + + + + No results found. + + + + Calendar + + + + Search emoji + + + + + + Profile + + + + Preferences + + + + + ), +} + +export const WithSeparators: Story = { + render: () => ( + + + + + Calendar + Search emoji + + + + Profile + Preferences + + + + ), +} + +export const WithShortcuts: Story = { + parameters: { + docs: { + description: { + story: + "`CommandShortcut` renders a right-aligned hint inside an item. Useful for surfacing keyboard accelerators.", + }, + }, + }, + render: () => ( + + + + + + New file + Ctrl+N + + + Open + Ctrl+O + + + Save + Ctrl+S + + + + + ), +} + +export const WithDisabledItem: Story = { + render: () => ( + + + + + Connect wallet + Send transaction (no wallet) + View history + + + + ), +} + +export const Empty: Story = { + parameters: { + docs: { + description: { + story: + "`CommandEmpty` renders when the active filter excludes every item. Pre-set the input value to show the empty state without typing.", + }, + }, + }, + render: () => ( + + + + No results found for that query. + + Calendar + Settings + + + + ), +} + +export const InputWithKbdShortcut: Story = { + parameters: { + docs: { + description: { + story: + "`CommandInput` accepts `kbdShortcut` to render a right-aligned hint instead of the search icon.", + }, + }, + }, + render: () => ( + + + + + Calendar + Settings + + + + ), +} + +export const InputWithCustomIcon: Story = { + render: () => ( + + + + + Connect MetaMask + Connect Rainbow + + + + ), +} + +const DialogDemo = () => ( + + + + No results found. + + + + Calendar + + + + Search emoji + + + + Profile + + + + +) + +export const AsDialog: Story = { + parameters: { + docs: { + description: { + story: + "`CommandDialog` wraps `Command` in `Dialog` + `DialogContent` for a modal palette.", + }, + }, + }, + render: () => ( + <> + + + + ), +} diff --git a/src/components/ui/__stories__/Dialog.stories.tsx b/src/components/ui/__stories__/Dialog.stories.tsx new file mode 100644 index 00000000000..77f46d89679 --- /dev/null +++ b/src/components/ui/__stories__/Dialog.stories.tsx @@ -0,0 +1,132 @@ +import { fn } from "storybook/test" +import type { Meta, StoryObj } from "@storybook/nextjs" + +import { Button } from "../buttons/Button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../dialog" +import { Flex } from "../flex" + +const meta = { + title: "UI / Primitives / Dialog", + component: Dialog, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Vanilla shadcn `Dialog`. For most app use, prefer `Modal` (`UI / Modal`), which wraps this primitive with `size`, `variant`, and `actionButton` props. The overlay is always rendered as part of `DialogContent`. Width is constrained to `max-w-lg` by default; override via `className` on `DialogContent`.", + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Dialog title + + A short description of what this dialog does. + + + + + ), +} + +export const WithFooter: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Confirm action + + This action cannot be undone. Are you sure you want to proceed? + + + + + + + + + + + + + ), +} + +export const Widths: Story = { + args: { defaultOpen: true }, + parameters: { + docs: { + description: { + story: + "`DialogContent` defaults to `max-w-lg`. Override via `className` for narrower or wider dialogs.", + }, + }, + }, + render: (args) => ( + + + + + + + Wide dialog (max-w-2xl) + + For wider content, pass a Tailwind width class to `DialogContent`. + For canonical app sizing, use `Modal` instead. + + + + + ), +} + +export const LongContent: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Terms of use + + Long-form content scrolls within the dialog when it overflows the + viewport. Ethereum is a decentralized, open-source blockchain + featuring smart-contract functionality. Ether is the native + cryptocurrency of the platform. Among cryptocurrencies, ether is + second only to bitcoin in market capitalization. + + + + + ), +} diff --git a/src/components/ui/__stories__/DropdownMenu.stories.tsx b/src/components/ui/__stories__/DropdownMenu.stories.tsx new file mode 100644 index 00000000000..9493ab48284 --- /dev/null +++ b/src/components/ui/__stories__/DropdownMenu.stories.tsx @@ -0,0 +1,177 @@ +import { useState } from "react" +import type { Meta, StoryObj } from "@storybook/nextjs" + +import { Button } from "../buttons/Button" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "../dropdown-menu" + +const meta = { + title: "UI / Primitives / DropdownMenu", + component: DropdownMenu, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Anchored menu built on Radix DropdownMenu. Supports plain items, checkbox items, radio groups, submenus, labels, separators, and shortcut hints.", + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + Profile + Settings + Sign out + + + ), +} + +export const WithSubmenus: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + New file + + Share + + Copy link + Email + Embed + + + Delete + + + ), +} + +const CheckboxMenu = () => { + const [showToolbar, setShowToolbar] = useState(true) + const [showSidebar, setShowSidebar] = useState(false) + const [showStatus, setShowStatus] = useState(true) + + return ( + + + + + + Appearance + + Toolbar + + + Sidebar + + + Status bar + + + + ) +} + +export const WithCheckboxItems: Story = { + render: () => , +} + +const RadioMenu = () => { + const [theme, setTheme] = useState("system") + + return ( + + + + + + Theme + + Light + Dark + System + + + + ) +} + +export const WithRadioItems: Story = { + render: () => , +} + +export const WithSeparators: Story = { + args: { defaultOpen: true }, + parameters: { + docs: { + description: { + story: + "Combines labels, items, separators, and shortcut hints to model a typical app menu.", + }, + }, + }, + render: (args) => ( + + + + + + File + + New Ctrl+N + + + Open Ctrl+O + + + Edit + + Cut Ctrl+X + + + Copy Ctrl+C + + + Sign out + + + ), +} diff --git a/src/components/ui/__stories__/Modal.stories.tsx b/src/components/ui/__stories__/Modal.stories.tsx index 7648ff21f1f..ad72e02ce37 100644 --- a/src/components/ui/__stories__/Modal.stories.tsx +++ b/src/components/ui/__stories__/Modal.stories.tsx @@ -1,16 +1,19 @@ import { fn } from "storybook/test" -import { Meta, StoryObj } from "@storybook/nextjs" +import type { Meta, StoryObj } from "@storybook/nextjs" import ModalComponent from "../dialog-modal" const meta = { - title: "Molecules/Overlay Content/Modal", + title: "UI / Modal", component: ModalComponent, + parameters: { + chromatic: { disableSnapshot: true }, + }, args: { defaultOpen: true, - title: "Modal Title", + title: "Modal title", children: - "This is the base component to be used in the modal window. Please change the text to preview final content for ethereum.org", + "Base content for the modal. Replace this with the final copy and components for your screen.", actionButton: { label: "Save", onClick: fn(), @@ -22,10 +25,59 @@ export default meta type Story = StoryObj -export const Modal: Story = {} +export const Default: Story = {} + +export const SizeMd: Story = { + args: { size: "md" }, +} + +export const SizeLg: Story = { + args: { size: "lg" }, +} + +export const SizeXl: Story = { + args: { size: "xl" }, +} + +export const VariantSimulator: Story = { + args: { variant: "simulator" }, + parameters: { + docs: { + description: { + story: + "`simulator` variant moves the close button inline with the header content; used for embedded simulator-style modals.", + }, + }, + }, +} + +export const VariantUnstyled: Story = { + args: { + variant: "unstyled", + actionButton: undefined, + children: + "Unstyled content area. Use when the modal needs to render fully custom layout without the default padding, rounding, or background.", + }, + parameters: { + docs: { + description: { + story: + "`unstyled` variant strips the default content padding, rounding, and background so the consumer can render custom chrome.", + }, + }, + }, +} -export const Xl: Story = { +export const WithoutActionButton: Story = { args: { - size: "xl", + actionButton: undefined, + }, + parameters: { + docs: { + description: { + story: + "Without `actionButton`, the footer is omitted and the modal becomes informational. Closes via the X button or escape.", + }, + }, }, } diff --git a/src/components/ui/__stories__/PersistentPanel.stories.tsx b/src/components/ui/__stories__/PersistentPanel.stories.tsx new file mode 100644 index 00000000000..7be32d98205 --- /dev/null +++ b/src/components/ui/__stories__/PersistentPanel.stories.tsx @@ -0,0 +1,157 @@ +import { useRef, useState } from "react" +import type { Meta, StoryObj } from "@storybook/nextjs" + +import { Button } from "../buttons/Button" +import { PersistentPanel } from "../persistent-panel" + +const meta = { + title: "UI / PersistentPanel", + component: PersistentPanel, + args: { + open: false, + children: null, + }, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Custom side-panel overlay tuned for expensive content. Unlike `Sheet` (which mounts/unmounts on every open), `PersistentPanel` lazy-mounts on first open and then stays mounted -- toggles only flip CSS visibility. Use for heavy filter forms, virtualized lists, or any content where re-mount cost matters. For lighter cases, prefer `Sheet`.", + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +type DemoProps = { + side?: "left" | "right" | "top" | "bottom" + initialOpen?: boolean + ariaLabel: string + body?: React.ReactNode +} + +const PanelDemo = ({ + side = "left", + initialOpen = true, + ariaLabel, + body, +}: DemoProps) => { + const [open, setOpen] = useState(initialOpen) + const triggerRef = useRef(null) + + return ( + <> + + +
+

{ariaLabel}

+ {body ?? ( +

+ Panel content stays mounted between opens. Open/close several + times — the children do not unmount. +

+ )} + +
+
+ + ) +} + +export const Default: Story = { + render: () => , +} + +export const SideLeft: Story = { + render: () => , +} + +export const SideRight: Story = { + render: () => , +} + +export const SideTop: Story = { + render: () => , +} + +export const SideBottom: Story = { + render: () => , +} + +const LazyMountProbe = () => { + const [renderCount, setRenderCount] = useState(0) + + // Counts every render of the panel children + return ( +
+

+ Render count: {renderCount} +

+ +
+ ) +} + +export const LazyMountAndPersistence: Story = { + parameters: { + docs: { + description: { + story: + "Open the panel, bump the counter, close, then reopen — the counter retains its value because children are not unmounted. Compare this against `Sheet`, where each open is a fresh mount.", + }, + }, + }, + render: () => ( + } + /> + ), +} + +export const StartsClosed: Story = { + parameters: { + docs: { + description: { + story: + "Demonstrates lazy mount: the panel children do not render until the first open. Inspect the DOM before clicking — there is no panel element until then.", + }, + }, + }, + render: () => ( + + ), +} diff --git a/src/components/ui/__stories__/Popover.stories.tsx b/src/components/ui/__stories__/Popover.stories.tsx new file mode 100644 index 00000000000..d63e9c82ed2 --- /dev/null +++ b/src/components/ui/__stories__/Popover.stories.tsx @@ -0,0 +1,130 @@ +import type { Meta, StoryObj } from "@storybook/nextjs" + +import { Button } from "../buttons/Button" +import { HStack } from "../flex" +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "../popover" + +const meta = { + title: "UI / Primitives / Popover", + component: Popover, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Anchored floating panel built on Radix Popover. The arrow is always rendered as part of `PopoverContent`. Use `align` to shift the panel relative to its trigger.", + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + +

+ Popovers anchor to their trigger and surface secondary information or + short forms. +

+
+
+ ), +} + +export const Alignments: Story = { + parameters: { + docs: { + description: { + story: + "All three `align` options shown side-by-side. `start` left-aligns to the trigger, `end` right-aligns, `center` is the default.", + }, + }, + }, + render: () => ( + + + + + + +

Aligned to the start edge of the trigger.

+
+
+ + + + + + +

Centered under the trigger (default).

+
+
+ + + + + + +

Aligned to the end edge of the trigger.

+
+
+
+ ), +} + +export const WithRichContent: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + +
+

Network details

+

+ Layer 2 networks scale Ethereum by handling transactions off the + main chain while inheriting its security guarantees. +

+ +
+
+
+ ), +} + +export const WithCloseAction: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + +
+

+ Use `PopoverClose` to dismiss the panel from inside the content. +

+ + + +
+
+
+ ), +} diff --git a/src/components/ui/__stories__/Select.stories.tsx b/src/components/ui/__stories__/Select.stories.tsx new file mode 100644 index 00000000000..5a2338a70af --- /dev/null +++ b/src/components/ui/__stories__/Select.stories.tsx @@ -0,0 +1,174 @@ +import type { Meta, StoryObj } from "@storybook/nextjs" + +import { VStack } from "../flex" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "../select" + +const meta = { + title: "UI / Primitives / Select", + component: Select, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Single-select dropdown built on Radix Select. Use `SelectGroup` + `SelectLabel` to group items, `SelectSeparator` between groups. Note: `Select` does not currently expose a `hasError` variant — error styling is applied via `className` on `SelectTrigger`.", + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +const FRUITS = ["Apple", "Banana", "Cherry", "Date", "Elderberry"] as const + +export const WithPlaceholder: Story = { + render: () => ( +
+ +
+ ), +} + +export const WithDefaultValue: Story = { + render: () => ( +
+ +
+ ), +} + +export const WithGroups: Story = { + render: () => ( +
+ +
+ ), +} + +export const Disabled: Story = { + render: () => ( + + + + + ), +} + +export const ErrorState: Story = { + parameters: { + docs: { + description: { + story: + "`Select` lacks a built-in `hasError` variant. Apply `border-error` (and matching focus token) on `SelectTrigger` to mirror the error styling used by `Input` and `Textarea`.", + }, + }, + }, + render: () => ( +
+ +
+ ), +} + +export const WithDisabledItem: Story = { + render: () => ( +
+ +
+ ), +} diff --git a/src/components/ui/__stories__/Sheet.stories.tsx b/src/components/ui/__stories__/Sheet.stories.tsx new file mode 100644 index 00000000000..b5c379df1e7 --- /dev/null +++ b/src/components/ui/__stories__/Sheet.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from "@storybook/nextjs" + +import { Button } from "../buttons/Button" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "../sheet" + +const meta = { + title: "UI / Primitives / Sheet", + component: Sheet, + parameters: { + chromatic: { disableSnapshot: true }, + docs: { + description: { + component: + "Side-panel overlay built on Radix Dialog. Use `side` to choose entry edge, and `hideOverlay` when the sheet should appear without dimming the page (e.g., persistent filter panels). For lazy-mount + stay-mounted behavior, see `PersistentPanel`.", + }, + }, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +const SAMPLE_BODY = ( +

+ Sheets are good for secondary tasks that should not pull the user away from + the main page context, like filters, settings, or a navigation drawer. +

+) + +export const Default: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Sheet title + A short description of the sheet. + +
{SAMPLE_BODY}
+
+
+ ), +} + +export const SideRight: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Right sheet + +
{SAMPLE_BODY}
+
+
+ ), +} + +export const SideLeft: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Left sheet + +
{SAMPLE_BODY}
+
+
+ ), +} + +export const SideTop: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Top sheet + +
{SAMPLE_BODY}
+
+
+ ), +} + +export const SideBottom: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Bottom sheet + +
{SAMPLE_BODY}
+
+
+ ), +} + +export const WithoutOverlay: Story = { + args: { defaultOpen: true }, + parameters: { + docs: { + description: { + story: + "`hideOverlay` skips the dimmed backdrop so the sheet sits beside live page content.", + }, + }, + }, + render: (args) => ( + + + + + + + No overlay + + The page behind stays interactive. + + +
{SAMPLE_BODY}
+
+
+ ), +} + +export const WithHeaderAndFooter: Story = { + args: { defaultOpen: true }, + render: (args) => ( + + + + + + + Edit profile + + Make changes to your profile and save when you are done. + + +
{SAMPLE_BODY}
+ + Cancel + + +
+
+ ), +}