diff --git a/packages/propel/.storybook/main.ts b/packages/propel/.storybook/main.ts index 2fe48942aa8..96607e1b5d6 100644 --- a/packages/propel/.storybook/main.ts +++ b/packages/propel/.storybook/main.ts @@ -11,7 +11,7 @@ function getAbsolutePath(value: string) { } const config: StorybookConfig = { stories: ["../src/**/*.stories.@(ts|tsx)"], - addons: ["@storybook/addon-designs", "@storybook/addon-docs"], + addons: [getAbsolutePath("@storybook/addon-designs"), getAbsolutePath("@storybook/addon-docs")], framework: { name: getAbsolutePath("@storybook/react-vite"), options: {}, diff --git a/packages/propel/package.json b/packages/propel/package.json index 923ac78ab93..b0b6ad39b5b 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -55,8 +55,8 @@ "@plane/types": "workspace:*", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", - "cmdk": "^1.1.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "frimousse": "^0.3.0", "lucide-react": "catalog:", "react": "catalog:", @@ -71,12 +71,12 @@ "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", "@storybook/addon-designs": "10.0.2", - "@storybook/addon-docs": "9.1.2", - "@storybook/react-vite": "9.1.2", + "@storybook/addon-docs": "9.1.10", + "@storybook/react-vite": "9.1.10", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "eslint-plugin-storybook": "9.1.2", - "storybook": "9.1.2", + "eslint-plugin-storybook": "9.1.10", + "storybook": "9.1.10", "tsdown": "catalog:", "typescript": "catalog:" } diff --git a/packages/propel/src/accordion/accordion.stories.tsx b/packages/propel/src/accordion/accordion.stories.tsx new file mode 100644 index 00000000000..5850c5062dd --- /dev/null +++ b/packages/propel/src/accordion/accordion.stories.tsx @@ -0,0 +1,198 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Accordion } from "./accordion"; + +const meta = { + title: "Components/Accordion", + component: Accordion.Root, + parameters: { + layout: "centered", + controls: { disable: true }, + }, + tags: ["autodocs"], + subcomponents: { + Item: Accordion.Item, + Trigger: Accordion.Trigger, + Content: Accordion.Content, + }, + args: { + children: null, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render() { + return ( + + + What is Plane? + + Plane is an open-source project management tool designed for developers and teams to plan, track, and manage + their work efficiently. + + + + How do I get started? + + You can get started by signing up for an account, creating your first workspace, and inviting your team + members to collaborate. + + + + Is it free to use? + + Plane offers both free and paid plans. The free plan includes essential features for small teams, while paid + plans unlock advanced functionality. + + + + ); + }, +}; + +export const SingleOpen: Story = { + render() { + return ( + + + Section 1 + Content for section 1. Only one section can be open at a time. + + + Section 2 + Content for section 2. + + + Section 3 + Content for section 3. + + + ); + }, +}; + +export const AllowMultiple: Story = { + render() { + return ( + + + First Section + Multiple sections can be open at the same time. + + + Second Section + This section is also open by default. + + + Third Section + You can open this section while keeping the others open. + + + ); + }, +}; + +export const WithDisabledItem: Story = { + render() { + return ( + + + Enabled Section + This section can be toggled. + + + Disabled Section + This content cannot be accessed. + + + Another Enabled Section + This section can also be toggled. + + + ); + }, +}; + +export const CustomIcon: Story = { + render() { + return ( + + + + + + } + > + Custom Chevron Icon + + + This accordion uses a custom chevron icon instead of the default plus icon. + + + + + + + } + > + Another Section + + All items in this accordion use the custom icon. + + + ); + }, +}; + +export const AsChildTrigger: Story = { + render() { + return ( + + + + + + + When using asChild, you can completely customize the trigger element without the default icon wrapper. + + + + + + + This gives you full control over the trigger styling and behavior. + + + ); + }, +}; diff --git a/packages/propel/src/accordion/accordion.tsx b/packages/propel/src/accordion/accordion.tsx index 51d519f4ecd..71850e18071 100644 --- a/packages/propel/src/accordion/accordion.tsx +++ b/packages/propel/src/accordion/accordion.tsx @@ -2,21 +2,21 @@ import * as React from "react"; import { Accordion as BaseAccordion } from "@base-ui-components/react"; import { PlusIcon } from "lucide-react"; -interface AccordionRootProps { +export interface AccordionRootProps { defaultValue?: string[]; allowMultiple?: boolean; className?: string; children: React.ReactNode; } -interface AccordionItemProps { +export interface AccordionItemProps { value: string; disabled?: boolean; className?: string; children: React.ReactNode; } -interface AccordionTriggerProps { +export interface AccordionTriggerProps { className?: string; icon?: React.ReactNode; children: React.ReactNode; @@ -24,7 +24,7 @@ interface AccordionTriggerProps { iconClassName?: string; } -interface AccordionContentProps { +export interface AccordionContentProps { className?: string; contentWrapperClassName?: string; children: React.ReactNode; diff --git a/packages/propel/src/animated-counter/animated-counter.stories.tsx b/packages/propel/src/animated-counter/animated-counter.stories.tsx index 3d7c215d9e0..ac6f433400a 100644 --- a/packages/propel/src/animated-counter/animated-counter.stories.tsx +++ b/packages/propel/src/animated-counter/animated-counter.stories.tsx @@ -1,55 +1,333 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { AnimatedCounter } from "./animated-counter"; -const meta: Meta = { +const meta = { title: "Components/AnimatedCounter", component: AnimatedCounter, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - size: { - control: { type: "select" }, - options: ["sm", "md", "lg"], - }, + args: { + size: "md", + count: 0, }, -}; +} satisfies Meta; export default meta; -type Story = StoryObj; - -const AnimatedCounterDemo = (args: React.ComponentProps) => { - const [count, setCount] = useState(args.count || 0); - - return ( -
-
- -
- -
- -
-
- ); -}; +type Story = StoryObj; export const Default: Story = { - render: (args) => , - args: { - count: 5, - size: "md", + render(args) { + const [count, setCount] = useState(0); + + return ( +
+
+ +
+ +
+ +
+
+ ); + }, +}; + +export const Sizes: Story = { + render() { + const [count, setCount] = useState(42); + + return ( +
+
+ + +
+
+
+ Small: +
+ +
+
+
+ Medium: +
+ +
+
+
+ Large: +
+ +
+
+
+
+ ); + }, +}; + +export const LargeNumbers: Story = { + render() { + const [count, setCount] = useState(1234567); + + return ( +
+
+ + +
+
+ +
+
+ ); + }, +}; + +export const Countdown: Story = { + render() { + const [count, setCount] = useState(10); + const [isRunning, setIsRunning] = useState(false); + + useEffect(() => { + if (isRunning && count > 0) { + const timer = setTimeout(() => setCount((prev) => prev - 1), 1000); + return () => clearTimeout(timer); + } + if (count === 0) { + setIsRunning(false); + } + }, [count, isRunning]); + + const handleStart = () => { + setCount(10); + setIsRunning(true); + }; + + return ( +
+
+
+ +
+ +
+
+ ); + }, +}; + +export const LiveCounter: Story = { + render() { + const [count, setCount] = useState(0); + const [isRunning, setIsRunning] = useState(false); + + useEffect(() => { + if (isRunning) { + const timer = setInterval(() => setCount((prev) => prev + 1), 500); + return () => clearInterval(timer); + } + }, [isRunning]); + + return ( +
+
+
+ +
+
+ + + +
+
+
+ ); + }, +}; + +export const MultipleCounters: Story = { + render() { + const [likes, setLikes] = useState(42); + const [comments, setComments] = useState(15); + const [shares, setShares] = useState(8); + + return ( +
+
+
+

Engagement Stats

+
+
+
+
Likes
+
+ +
+ +
+
+
+
+
Comments
+
+ +
+ +
+
+
+
+
Shares
+
+ +
+ +
+
+
+
+
+
+ ); + }, +}; + +export const InBadge: Story = { + render() { + const [notifications, setNotifications] = useState(3); + + return ( +
+
+
+ +
+ +
+
+ +
+
+ ); + }, +}; + +export const FastAnimation: Story = { + render() { + const [count, setCount] = useState(0); + + const incrementFast = () => { + for (let i = 1; i <= 10; i++) { + setTimeout(() => setCount((prev) => prev + 1), i * 50); + } + }; + + return ( +
+
+
+ +
+
+ + +
+
+
+ ); }, }; diff --git a/packages/propel/src/avatar/avatar.stories.tsx b/packages/propel/src/avatar/avatar.stories.tsx new file mode 100644 index 00000000000..c392af57d8e --- /dev/null +++ b/packages/propel/src/avatar/avatar.stories.tsx @@ -0,0 +1,171 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Avatar } from "./avatar"; + +const meta = { + title: "Components/Avatar", + component: Avatar, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + name: "John Doe", + src: "https://i.pravatar.cc/150?img=1", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithName: Story = { + args: { + name: "Jane Smith", + src: "https://i.pravatar.cc/150?img=5", + }, +}; + +export const Fallback: Story = { + args: { + name: "Alice Johnson", + src: "invalid-url", + }, +}; + +export const FallbackWithCustomColor: Story = { + args: { + name: "Bob Wilson", + src: "invalid-url", + fallbackBackgroundColor: "#3b82f6", + fallbackTextColor: "#ffffff", + }, +}; + +export const FallbackWithCustomText: Story = { + args: { + fallbackText: "AB", + src: "invalid-url", + fallbackBackgroundColor: "#10b981", + fallbackTextColor: "#ffffff", + }, +}; + +export const Small: Story = { + args: { + name: "Small Avatar", + src: "https://i.pravatar.cc/150?img=2", + size: "sm", + }, +}; + +export const Medium: Story = { + args: { + name: "Medium Avatar", + src: "https://i.pravatar.cc/150?img=3", + size: "md", + }, +}; + +export const Base: Story = { + args: { + name: "Base Avatar", + src: "https://i.pravatar.cc/150?img=4", + size: "base", + }, +}; + +export const Large: Story = { + args: { + name: "Large Avatar", + src: "https://i.pravatar.cc/150?img=6", + size: "lg", + }, +}; + +export const CircleShape: Story = { + args: { + name: "Circle Avatar", + src: "https://i.pravatar.cc/150?img=7", + shape: "circle", + }, +}; + +export const SquareShape: Story = { + args: { + name: "Square Avatar", + src: "https://i.pravatar.cc/150?img=8", + shape: "square", + }, +}; + +export const AllSizes: Story = { + parameters: { + controls: { disable: true }, + }, + render() { + return ( +
+ + + + +
+ ); + }, +}; + +export const AllShapes: Story = { + parameters: { + controls: { disable: true }, + }, + render() { + return ( +
+ + +
+ ); + }, +}; + +export const FallbackVariations: Story = { + parameters: { + controls: { disable: true }, + }, + render() { + return ( +
+ + + + + +
+ ); + }, +}; + +export const AvatarGroup: Story = { + parameters: { + controls: { disable: true }, + }, + render() { + return ( +
+ + + + + +
+ ); + }, +}; diff --git a/packages/propel/src/button/button.stories.tsx b/packages/propel/src/button/button.stories.tsx index 129cdf4f7a9..d0e4453648b 100644 --- a/packages/propel/src/button/button.stories.tsx +++ b/packages/propel/src/button/button.stories.tsx @@ -1,56 +1,22 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Button } from "./button"; -import type { TButtonVariant, TButtonSizes } from "./helper"; -const buttonVariants: TButtonVariant[] = [ - "primary", - "accent-primary", - "outline-primary", - "neutral-primary", - "link-primary", - "danger", - "accent-danger", - "outline-danger", - "link-danger", - "tertiary-danger", - "link-neutral", -]; - -const buttonSizes: TButtonSizes[] = ["sm", "md", "lg", "xl"]; - -const meta: Meta = { +const meta = { title: "Components/Button", component: Button, parameters: { layout: "centered", }, tags: ["autodocs"], - argTypes: { - variant: { - control: "select", - options: buttonVariants, - }, - size: { - control: "select", - options: buttonSizes, - }, - loading: { - control: "boolean", - }, - disabled: { - control: "boolean", - }, + args: { + children: "Button", }, -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; -export const Default: Story = { - args: { - children: "Button", - }, -}; +export const Default: Story = {}; export const Primary: Story = { args: { @@ -212,62 +178,68 @@ export const WithAppendIcon: Story = { }; export const AllVariants: Story = { - render: () => ( -
-
-

Primary Variants

-
- - - - - + render() { + return ( +
+
+

Primary Variants

+
+ + + + + +
-
-
-

Danger Variants

-
- - - - - +
+

Danger Variants

+
+ + + + + +
-
-
-

Other Variants

-
- +
+

Other Variants

+
+ +
-
- ), + ); + }, }; export const AllSizes: Story = { - render: () => ( -
-
- - - - + render() { + return ( +
+
+ + + + +
-
- ), + ); + }, }; export const AllStates: Story = { - render: () => ( -
-
-

Button States

-
- - - + render() { + return ( +
+
+

Button States

+
+ + + +
-
- ), + ); + }, }; diff --git a/packages/propel/src/calendar/calendar.stories.tsx b/packages/propel/src/calendar/calendar.stories.tsx index 221e86f3600..6f72d3eff34 100644 --- a/packages/propel/src/calendar/calendar.stories.tsx +++ b/packages/propel/src/calendar/calendar.stories.tsx @@ -1,10 +1,9 @@ -import { ComponentProps, useState } from "react"; +import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { DateRange } from "react-day-picker"; import { Calendar } from "./root"; -type CalendarProps = ComponentProps; - -const meta: Meta = { +const meta = { title: "Components/Calendar", component: Calendar, parameters: { @@ -13,14 +12,13 @@ const meta: Meta = { args: { showOutsideDays: true, }, -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; -export const Default: Story = { - args: {}, - render: (args: CalendarProps) => { +export const SingleDate: Story = { + render(args) { const [date, setDate] = useState(new Date()); return ( @@ -30,3 +28,167 @@ export const Default: Story = { ); }, }; + +export const MultipleDates: Story = { + render(args) { + const [dates, setDates] = useState([ + new Date(2024, 0, 15), + new Date(2024, 0, 20), + new Date(2024, 0, 25), + ]); + + return ( +
+ +
+ ); + }, +}; + +export const RangeSelection: Story = { + render(args) { + const [range, setRange] = useState({ + from: new Date(2024, 0, 10), + to: new Date(2024, 0, 20), + }); + + return ( +
+ +
+ ); + }, +}; + +export const DisabledDates: Story = { + render(args) { + const [date, setDate] = useState(); + const disabledDays = [new Date(2024, 0, 5), new Date(2024, 0, 12), new Date(2024, 0, 19), new Date(2024, 0, 26)]; + + return ( +
+ +
+ ); + }, +}; + +export const DisabledWeekends: Story = { + render(args) { + const [date, setDate] = useState(); + + return ( +
+ date.getDay() === 0 || date.getDay() === 6} + className="rounded-md border" + /> +
+ ); + }, +}; + +export const MinMaxDates: Story = { + render(args) { + const [date, setDate] = useState(); + const today = new Date(); + const tenDaysAgo = new Date(today); + tenDaysAgo.setDate(today.getDate() - 10); + const tenDaysFromNow = new Date(today); + tenDaysFromNow.setDate(today.getDate() + 10); + + return ( +
+ date < tenDaysAgo || date > tenDaysFromNow} + className="rounded-md border" + /> +
+ ); + }, +}; + +export const WeekStartsOnMonday: Story = { + render(args) { + const [date, setDate] = useState(new Date()); + + return ( +
+ +
+ ); + }, +}; + +export const WithoutOutsideDays: Story = { + render(args) { + const [date, setDate] = useState(new Date()); + + return ( +
+ +
+ ); + }, +}; + +export const TwoMonths: Story = { + render(args) { + const [range, setRange] = useState({ + from: new Date(2024, 0, 10), + to: new Date(2024, 1, 15), + }); + + return ( +
+ +
+ ); + }, +}; + +export const Uncontrolled: Story = { + render(args) { + return ( +
+ +
+ ); + }, +}; diff --git a/packages/propel/src/card/card.stories.tsx b/packages/propel/src/card/card.stories.tsx new file mode 100644 index 00000000000..62ae0e4bb84 --- /dev/null +++ b/packages/propel/src/card/card.stories.tsx @@ -0,0 +1,211 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Card, ECardVariant, ECardSpacing, ECardDirection } from "./card"; + +const meta = { + title: "Components/Card", + component: Card, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + children: ( + <> +

Card Title

+

This is a default card with shadow and large spacing.

+ + ), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithShadow: Story = { + args: { + variant: ECardVariant.WITH_SHADOW, + children: ( + <> +

Card with Shadow

+

Hover over this card to see the shadow effect.

+ + ), + }, +}; + +export const WithoutShadow: Story = { + args: { + variant: ECardVariant.WITHOUT_SHADOW, + children: ( + <> +

Card without Shadow

+

This card has no shadow effect on hover.

+ + ), + }, +}; + +export const SmallSpacing: Story = { + args: { + spacing: ECardSpacing.SM, + children: ( + <> +

Small Spacing

+

This card uses small spacing (p-4).

+ + ), + }, +}; + +export const LargeSpacing: Story = { + args: { + spacing: ECardSpacing.LG, + children: ( + <> +

Large Spacing

+

This card uses large spacing (p-6).

+ + ), + }, +}; + +export const ColumnDirection: Story = { + args: { + direction: ECardDirection.COLUMN, + children: ( + <> +

Column Direction

+

Content is arranged vertically.

+ + + ), + }, +}; + +export const RowDirection: Story = { + args: { + direction: ECardDirection.ROW, + children: ( + <> +
+
+
+
+

Row Direction

+

Content is arranged horizontally.

+
+ + ), + }, +}; + +export const ProductCard: Story = { + args: { + variant: ECardVariant.WITH_SHADOW, + spacing: ECardSpacing.LG, + direction: ECardDirection.COLUMN, + children: ( + <> +
+

Product Name

+

A brief description of the product goes here.

+
+ $99.99 + +
+ + ), + }, +}; + +export const UserCard: Story = { + args: { + variant: ECardVariant.WITH_SHADOW, + spacing: ECardSpacing.LG, + direction: ECardDirection.ROW, + children: ( + <> +
+
+

John Doe

+

Software Engineer

+

john.doe@example.com

+
+ + ), + }, +}; + +export const NotificationCard: Story = { + args: { + variant: ECardVariant.WITHOUT_SHADOW, + spacing: ECardSpacing.SM, + direction: ECardDirection.COLUMN, + children: ( + <> +
+

New Message

+ 2m ago +
+

You have received a new message from Alice.

+ + ), + }, +}; + +export const AllVariants: Story = { + render() { + return ( +
+ +

With Shadow

+

Hover to see the shadow effect

+
+ +

Without Shadow

+

No shadow on hover

+
+
+ ); + }, +}; + +export const AllSpacings: Story = { + render() { + return ( +
+ +

Small Spacing (p-4)

+

Compact padding

+
+ +

Large Spacing (p-6)

+

More generous padding

+
+
+ ); + }, +}; + +export const AllDirections: Story = { + render() { + return ( +
+ +

Column Direction

+

Vertical layout

+ +
+ +
+
+

Row Direction

+

Horizontal layout

+
+ +
+ ); + }, +}; diff --git a/packages/propel/src/collapsible/collapsible.stories.tsx b/packages/propel/src/collapsible/collapsible.stories.tsx new file mode 100644 index 00000000000..e45d102b5fb --- /dev/null +++ b/packages/propel/src/collapsible/collapsible.stories.tsx @@ -0,0 +1,174 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ChevronDown } from "lucide-react"; +import { useArgs } from "storybook/preview-api"; +import { Collapsible } from "./collapsible"; + +const meta = { + title: "Components/Collapsible", + component: Collapsible.CollapsibleRoot, + subcomponents: { + CollapsibleTrigger: Collapsible.CollapsibleTrigger, + CollapsibleContent: Collapsible.CollapsibleContent, + }, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + children: null, + isOpen: false, + onToggle: () => {}, + }, + render(args) { + const [{ isOpen }, updateArgs] = useArgs(); + const toggleOpen = () => updateArgs({ isOpen: !isOpen }); + + return ( + + + Click to toggle + + + +
+

This is the collapsible content that can be shown or hidden.

+
+
+
+ ); + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const DefaultOpen: Story = { + args: { isOpen: true }, +}; + +export const Controlled: Story = { + render() { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+
+ + + +
+ setIsOpen(!isOpen)} className="w-96"> + + Controlled Collapsible + + + +
+

This collapsible is controlled by external state.

+

Current state: {isOpen ? "Open" : "Closed"}

+
+
+
+
+ ); + }, +}; + +export const NestedContent: Story = { + render(args) { + const [isOpen, setIsOpen] = useState(args.isOpen); + return ( + setIsOpen(!isOpen)} className="w-96"> + + Collapsible with Nested Content + + + +
+

Section 1

+

This is some content in the first section.

+

Section 2

+

This is some content in the second section.

+
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+
+
+
+ ); + }, +}; + +export const CustomStyling: Story = { + render(args) { + const [isOpen, setIsOpen] = useState(args.isOpen); + return ( + setIsOpen(!isOpen)} className="w-96"> + + Custom Styled Trigger + + + +
+

This collapsible has custom styling with gradients, shadows, and colors.

+
+
+
+ ); + }, +}; + +export const MultipleCollapsibles: Story = { + render() { + return ( +
+ + + First Item + + + +
+

Content for the first item.

+
+
+
+ + + + Second Item + + + +
+

Content for the second item.

+
+
+
+ + + + Third Item + + + +
+

Content for the third item.

+
+
+
+
+ ); + }, +}; diff --git a/packages/propel/src/combobox/combobox.stories.tsx b/packages/propel/src/combobox/combobox.stories.tsx new file mode 100644 index 00000000000..5789514249b --- /dev/null +++ b/packages/propel/src/combobox/combobox.stories.tsx @@ -0,0 +1,260 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { useArgs } from "storybook/preview-api"; +import { Combobox } from "./combobox"; + +const frameworks = [ + { value: "react", label: "React" }, + { value: "vue", label: "Vue" }, + { value: "angular", label: "Angular" }, + { value: "svelte", label: "Svelte" }, + { value: "solid", label: "Solid" }, + { value: "next", label: "Next.js" }, + { value: "nuxt", label: "Nuxt" }, + { value: "remix", label: "Remix" }, +]; + +const meta = { + title: "Components/Combobox", + component: Combobox, + subcomponents: { + ComboboxButton: Combobox.Button, + ComboboxOptions: Combobox.Options, + ComboboxOption: Combobox.Option, + }, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + args: { + children: null, + value: "", + onValueChange: () => {}, + }, + render(args) { + const [{ value }, updateArgs] = useArgs(); + const setValue = (newValue: string | string[]) => updateArgs({ value: newValue }); + return ( + setValue(v as string)}> + + {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."} + + + + {frameworks.map((framework) => ( + + {value === framework.value && } + {framework.label} + + ))} + + + ); + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithoutSearch: Story = { + render() { + const [value, setValue] = useState(""); + return ( + setValue(v as string)}> + + {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."} + + + + {frameworks.map((framework) => ( + + {value === framework.value && } + {framework.label} + + ))} + + + ); + }, +}; + +export const MultiSelect: Story = { + render() { + const [value, setValue] = useState([]); + + return ( + setValue(v as string[])}> + + {value.length > 0 ? `${value.length} selected` : "Select frameworks..."} + + + + {frameworks.map((framework) => ( + + {value.includes(framework.value) && } + {framework.label} + + ))} + + + ); + }, +}; + +export const MultiSelectWithLimit: Story = { + render() { + const [value, setValue] = useState([]); + + return ( +
+ setValue(v as string[])}> + + + {value.length > 0 ? `${value.length}/3 selected` : "Select up to 3 frameworks..."} + + + + + {frameworks.map((framework) => ( + + {value.includes(framework.value) && } + {framework.label} + + ))} + + +

Maximum 3 selections allowed

+
+ ); + }, +}; + +export const Disabled: Story = { + args: { disabled: true }, + render() { + const [value, setValue] = useState(""); + return ( + setValue(v as string)}> + + {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."} + + + + {frameworks.map((framework) => ( + + {value === framework.value && } + {framework.label} + + ))} + + + ); + }, +}; + +export const DisabledOptions: Story = { + render() { + const [value, setValue] = useState(""); + return ( + setValue(v as string)}> + + {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."} + + + + {frameworks.map((framework) => ( + + {value === framework.value && } + {framework.label} + + ))} + + + ); + }, +}; + +export const CustomMaxHeight: Story = { + render() { + const [value, setValue] = useState(""); + return ( + setValue(v as string)}> + + {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."} + + + + {frameworks.map((framework) => ( + + {value === framework.value && } + {framework.label} + + ))} + + + ); + }, +}; + +export const CustomEmptyMessage: Story = { + render() { + const [value, setValue] = useState(""); + return ( + setValue(v as string)}> + + {value ? frameworks.find((f) => f.value === value)?.label : "Select framework..."} + + + + {frameworks.map((framework) => ( + + {value === framework.value && } + {framework.label} + + ))} + + + ); + }, +}; diff --git a/packages/propel/src/command/command.stories.tsx b/packages/propel/src/command/command.stories.tsx new file mode 100644 index 00000000000..9f41c9b4b93 --- /dev/null +++ b/packages/propel/src/command/command.stories.tsx @@ -0,0 +1,203 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { File, Folder, Settings, User } from "lucide-react"; +import { Command } from "./command"; + +const meta = { + title: "Components/Command", + component: Command, + subcomponents: { + CommandInput: Command.Input, + CommandList: Command.List, + CommandItem: Command.Item, + CommandEmpty: Command.Empty, + }, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render() { + return ( + + + + Item 1 + Item 2 + Item 3 + + No results found. + + ); + }, +}; + +export const WithIcons: Story = { + render() { + return ( + + + + + + Documents + + + + Downloads + + + + README.md + + + + package.json + + + No files or folders found. + + ); + }, +}; + +export const WithCategories: Story = { + render() { + return ( + + + +
User
+ + + Profile + + + + Settings + + +
Files
+ + + Open Folder + + + + New File + +
+ No commands found. +
+ ); + }, +}; + +export const EmptyState: Story = { + render() { + return ( + + + {/* No items - will show empty state */} + +

No results found

+

Try searching for something else

+
+
+ ); + }, +}; + +export const LongList: Story = { + render() { + return ( + + + + {Array.from({ length: 20 }, (_, i) => ( + + Item {i + 1} + + ))} + + No results found. + + ); + }, +}; + +export const WithoutSearch: Story = { + render() { + return ( + + + + + Profile + + + + Settings + + + + Files + + + + ); + }, +}; + +export const CustomStyling: Story = { + render() { + return ( + + + + + Custom Item 1 + + + Custom Item 2 + + + Custom Item 3 + + + No matching items found. + + ); + }, +}; + +export const DisabledItems: Story = { + render() { + return ( + + + + Active Item 1 + + Disabled Item + + Active Item 2 + + No results found. + + ); + }, +}; diff --git a/packages/propel/src/context-menu/context-menu.stories.tsx b/packages/propel/src/context-menu/context-menu.stories.tsx index bc33a304dde..014ac03e609 100644 --- a/packages/propel/src/context-menu/context-menu.stories.tsx +++ b/packages/propel/src/context-menu/context-menu.stories.tsx @@ -1,9 +1,23 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Copy, Download, Edit, Share, Trash, ChevronRight, Star, Archive } from "lucide-react"; import { ContextMenu } from "./context-menu"; +// cannot use satisfies here because base-ui does not have portable types. const meta: Meta = { title: "Components/ContextMenu", component: ContextMenu, + subcomponents: { + ContextMenuTrigger: ContextMenu.Trigger, + ContextMenuPortal: ContextMenu.Portal, + ContextMenuContent: ContextMenu.Content, + ContextMenuItem: ContextMenu.Item, + ContextMenuSeparator: ContextMenu.Separator, + ContextMenuSubmenu: ContextMenu.Submenu, + ContextMenuSubmenuTrigger: ContextMenu.SubmenuTrigger, + }, + args: { + children: null, + }, parameters: { layout: "centered", }, @@ -11,25 +25,354 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { - render: () => ( - - -
- Right click here -
-
- - - Back - Forward - Reload - - More Tools - - -
- ), + render() { + return ( + + +
+ Right click here +
+
+ + + Back + Forward + Reload + + More Tools + + +
+ ); + }, +}; + +export const WithIcons: Story = { + render() { + return ( + + +
+ Right click here +
+
+ + + + + Copy + + + + Edit + + + + Download + + + + + Share + + + + Delete + + + +
+ ); + }, +}; + +export const WithSubmenus: Story = { + render() { + return ( + + +
+ Right click here +
+
+ + + + + Copy + + + + Edit + + + + + + Share + + + + + Email + Message + Copy Link + + + + + + + Delete + + + +
+ ); + }, +}; + +export const DisabledItems: Story = { + render() { + return ( + + +
+ Right click here +
+
+ + + + + Copy + + + + Edit (Disabled) + + + + Download + + + + + Share (Disabled) + + + + Delete + + + +
+ ); + }, +}; + +export const OnFileCard: Story = { + render() { + return ( + + +
+
+
+ 📄 +
+
+
Document.pdf
+
2.4 MB
+
+
+
+
+ + + + + Download + + + + Copy Link + + + + Add to Favorites + + + + + Archive + + + + Delete + + + +
+ ); + }, +}; + +export const OnImage: Story = { + render() { + return ( + + +
+
+ Image Placeholder +
+
+
+ + + + + Save Image + + + + Copy Image + + + + Copy Image URL + + + Open Image in New Tab + + +
+ ); + }, +}; + +export const OnText: Story = { + render() { + return ( + + +
+

Context Menu on Text

+

+ Right click anywhere on this text area to see the context menu. This demonstrates how context menus can be + applied to text content areas. +

+
+
+ + + + + Copy + + + + Edit + + + Select All + + +
+ ); + }, +}; + +export const NestedSubmenus: Story = { + render() { + return ( + + +
+ Right click here +
+
+ + + New File + New Folder + + + + Import + + + + + From File + From URL + + + From Cloud + + + + + Google Drive + Dropbox + OneDrive + + + + + + + + + + Delete + + + +
+ ); + }, +}; + +export const WithKeyboardShortcuts: Story = { + render() { + return ( + + +
+ Right click here +
+
+ + + + + Copy + ⌘C + + + + Edit + ⌘E + + + + Download + ⌘D + + + + + Delete + ⌘⌫ + + + +
+ ); + }, }; diff --git a/packages/propel/src/dialog/dialog.stories.tsx b/packages/propel/src/dialog/dialog.stories.tsx new file mode 100644 index 00000000000..45ff50c89a0 --- /dev/null +++ b/packages/propel/src/dialog/dialog.stories.tsx @@ -0,0 +1,399 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { X } from "lucide-react"; +import { useArgs } from "storybook/preview-api"; +import { Dialog, EDialogWidth } from "./root"; + +const meta = { + title: "Components/Dialog", + component: Dialog, + subcomponents: { + DialogPanel: Dialog.Panel, + DialogTitle: Dialog.Title, + }, + args: { + children: null, + open: false, + onOpenChange: () => {}, + }, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + render(args) { + const [{ open }, updateArgs] = useArgs(); + const setOpen = (value: boolean) => updateArgs({ open: value }); + + return ( + <> + + {open && ( + + +
+ Dialog Title +
+

This is the dialog content. You can put any content here.

+
+
+ + +
+
+
+
+ )} + + ); + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: null, + }, +}; + +export const TopPosition: Story = { + render(args) { + const [open, setOpen] = useState(args.open); + return ( + <> + + {open && ( + + +
+ Top Positioned Dialog +
+

+ This dialog appears at the top of the screen instead of centered. +

+
+
+ +
+
+
+
+ )} + + ); + }, +}; + +export const SmallWidth: Story = { + render(args) { + const [open, setOpen] = useState(args.open); + return ( + <> + + {open && ( + + +
+ Small Dialog +
+

This is a small dialog.

+
+
+ +
+
+
+
+ )} + + ); + }, +}; + +export const LargeWidth: Story = { + render(args) { + const [open, setOpen] = useState(args.open); + return ( + <> + + {open && ( + + +
+ Large Dialog +
+

+ This is a large dialog with more horizontal space for content. +

+
+
+ +
+
+
+
+ )} + + ); + }, +}; + +export const WithCloseButton: Story = { + render(args) { + const [open, setOpen] = useState(args.open); + return ( + <> + + {open && ( + + +
+
+ Dialog with Close Button + +
+
+

This dialog has a close button in the header.

+
+
+
+
+ )} + + ); + }, +}; + +export const ConfirmationDialog: Story = { + render(args) { + const [open, setOpen] = useState(args.open); + const handleConfirm = () => { + alert("Confirmed!"); + setOpen(false); + }; + return ( + <> + + {open && ( + + +
+ Confirm Deletion +
+

+ Are you sure you want to delete this item? This action cannot be undone. +

+
+
+ + +
+
+
+
+ )} + + ); + }, +}; + +export const FormDialog: Story = { + render(args) { + const [open, setOpen] = useState(args.open); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + alert("Form submitted!"); + setOpen(false); + }; + return ( + <> + + {open && ( + + +
+ Create New Item +
+
+ + +
+
+ +