diff --git a/.gitignore b/.gitignore index 865a3fd3be3..bdd1aa82719 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies node_modules +.pnpm-store .pnp .pnp.js @@ -38,4 +39,4 @@ tsconfig.tsbuildinfo # ide .idea .fleet -.vscode +.vscode \ No newline at end of file diff --git a/apps/www/__registry__/index.tsx b/apps/www/__registry__/index.tsx index 52cdfa55598..529bd41df9e 100644 --- a/apps/www/__registry__/index.tsx +++ b/apps/www/__registry__/index.tsx @@ -590,6 +590,21 @@ export const Index: Record = { source: "", meta: undefined, }, + "stepper": { + name: "stepper", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [{ + path: "registry/new-york/ui/stepper.tsx", + type: "registry:ui", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/ui/stepper.tsx")), + source: "", + meta: undefined, + }, "switch": { name: "switch", description: "", @@ -4567,6 +4582,126 @@ export const Index: Record = { source: "", meta: undefined, }, + "stepper-demo": { + name: "stepper-demo", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/new-york/examples/stepper-demo.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-demo.tsx")), + source: "", + meta: undefined, + }, + "stepper-variants": { + name: "stepper-variants", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/new-york/examples/stepper-variants.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-variants.tsx")), + source: "", + meta: undefined, + }, + "stepper-responsive-variant": { + name: "stepper-responsive-variant", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/new-york/examples/stepper-responsive-variant.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-responsive-variant.tsx")), + source: "", + meta: undefined, + }, + "stepper-description": { + name: "stepper-description", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/new-york/examples/stepper-description.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-description.tsx")), + source: "", + meta: undefined, + }, + "stepper-label-orientation": { + name: "stepper-label-orientation", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/new-york/examples/stepper-label-orientation.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-label-orientation.tsx")), + source: "", + meta: undefined, + }, + "stepper-tracking": { + name: "stepper-tracking", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/new-york/examples/stepper-tracking.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-tracking.tsx")), + source: "", + meta: undefined, + }, + "stepper-icon": { + name: "stepper-icon", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/new-york/examples/stepper-icon.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-icon.tsx")), + source: "", + meta: undefined, + }, + "stepper-form": { + name: "stepper-form", + description: "", + type: "registry:example", + registryDependencies: ["stepper","form"], + files: [{ + path: "registry/new-york/examples/stepper-form.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/new-york/examples/stepper-form.tsx")), + source: "", + meta: undefined, + }, "switch-demo": { name: "switch-demo", description: "", @@ -5888,6 +6023,21 @@ export const Index: Record = { source: "", meta: undefined, }, + "stepper": { + name: "stepper", + description: "", + type: "registry:ui", + registryDependencies: ["button"], + files: [{ + path: "registry/default/ui/stepper.tsx", + type: "registry:ui", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/ui/stepper.tsx")), + source: "", + meta: undefined, + }, "switch": { name: "switch", description: "", @@ -9865,6 +10015,126 @@ export const Index: Record = { source: "", meta: undefined, }, + "stepper-demo": { + name: "stepper-demo", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/default/examples/stepper-demo.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-demo.tsx")), + source: "", + meta: undefined, + }, + "stepper-variants": { + name: "stepper-variants", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/default/examples/stepper-variants.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-variants.tsx")), + source: "", + meta: undefined, + }, + "stepper-responsive-variant": { + name: "stepper-responsive-variant", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/default/examples/stepper-responsive-variant.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-responsive-variant.tsx")), + source: "", + meta: undefined, + }, + "stepper-description": { + name: "stepper-description", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/default/examples/stepper-description.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-description.tsx")), + source: "", + meta: undefined, + }, + "stepper-label-orientation": { + name: "stepper-label-orientation", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/default/examples/stepper-label-orientation.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-label-orientation.tsx")), + source: "", + meta: undefined, + }, + "stepper-tracking": { + name: "stepper-tracking", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/default/examples/stepper-tracking.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-tracking.tsx")), + source: "", + meta: undefined, + }, + "stepper-icon": { + name: "stepper-icon", + description: "", + type: "registry:example", + registryDependencies: ["stepper"], + files: [{ + path: "registry/default/examples/stepper-icon.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-icon.tsx")), + source: "", + meta: undefined, + }, + "stepper-form": { + name: "stepper-form", + description: "", + type: "registry:example", + registryDependencies: ["stepper","form"], + files: [{ + path: "registry/default/examples/stepper-form.tsx", + type: "registry:example", + target: "" + }], + categories: undefined, + component: React.lazy(() => import("@/registry/default/examples/stepper-form.tsx")), + source: "", + meta: undefined, + }, "switch-demo": { name: "switch-demo", description: "", diff --git a/apps/www/config/docs.ts b/apps/www/config/docs.ts index ba6854f578a..8dd3f82082b 100644 --- a/apps/www/config/docs.ts +++ b/apps/www/config/docs.ts @@ -363,6 +363,12 @@ export const docsConfig: DocsConfig = { href: "/docs/components/sonner", items: [], }, + { + title: "Stepper", + href: "/docs/components/stepper", + items: [], + label: "New", + }, { title: "Switch", href: "/docs/components/switch", diff --git a/apps/www/content/docs/components/stepper.mdx b/apps/www/content/docs/components/stepper.mdx new file mode 100644 index 00000000000..468ba4e74ff --- /dev/null +++ b/apps/www/content/docs/components/stepper.mdx @@ -0,0 +1,607 @@ +--- +title: Stepper +description: Accessible and typesafe Stepper component to create step-by-step workflows. +component: true +links: + doc: https://stepperize.vercel.app/docs/react + api: https://stepperize.vercel.app/docs/react/api-references/define +--- + + + +## About + +Stepperize is built and maintained by [damianricobelli](https://x.com/damianricobelli). + +## Installation + + + + + CLI + Manual + + + +```bash +npx shadcn@latest add stepper +``` + + + + + + + +Install the following dependencies: + +```bash +npm install @stepperize/react +``` + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + + + + + + + +## Structure + +A `Stepper` component is composed of the following parts: + +- `StepperProvider` - Handles the stepper logic. +- `StepperNavigation` - Contains the buttons and labels to navigate through the steps. +- `StepperStep` - Step component. +- `StepperTitle` - Step title. +- `StepperDescription` - Step description. +- `StepperPanel` - Section to render the step content based on the current step. +- `StepperControls` - Section to render the buttons to navigate through the steps. + +## Usage + +```tsx showLineNumbers +import { defineStepper } from "@/components/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperDescription, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) + +export function Component() { + return ( + + + + + + + ... + + + ... + + ) +} +``` + +## Your first Stepper + +Let's start with the most basic stepper. A stepper with a horizontal navigation. + + + +Create a stepper instance with the `defineStepper` function. + +```tsx +const { StepperProvider, ... } = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) +``` + +Wrap your application in a `StepperProvider` component. + +```tsx +export function MyFirstStepper() { + return ... +} +``` + + + Add a `StepperNavigation` component to render the navigation buttons and + labels. + + +```tsx +const { StepperProvider, StepperNavigation, StepperStep, StepperTitle } = + defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } + ) +export function MyFirstStepper() { + return ( + + {({ methods }) => ( + + {methods.all.map((step) => ( + methods.goTo(step.id)}> + {step.title} + + ))} + + )} + + ) +} +``` + +Add a `StepperPanel` component to render the content of the step. + +```tsx +const { StepperProvider, StepperPanel, ... } = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) + +export function MyFirstStepper() { + return ( + + {({ methods }) => ( + <> + {/* StepperNavigation code */} + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + )} + + ) +} +``` + + + Add a `StepperControls` component to render the buttons to navigate through + the steps. + + +```tsx +const { StepperProvider, StepperControls, ... } = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) + +export function MyFirstStepper() { + return ( + + {({ methods }) => ( + <> + {/* StepperNavigation code */} + {/* StepperPanel code */} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} +``` + +Add some styles to make it look nice. + +```tsx +const { + StepperProvider, + StepperNavigation, + StepperStep, + StepperTitle, + StepperControls, + StepperPanel, +} = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) + +export function MyFirstStepper() { + return ( + + {({ methods }) => ( + <> + + {methods.all.map((step) => ( + methods.goTo(step.id)}> + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

Content for {id}

+
+ ) +} +``` + +
+ +## Components + +The components in `stepper.tsx` are built to be composable i.e you build your stepper by putting the provided components together. +They also compose well with other shadcn/ui components such as DropdownMenu, Collapsible or Dialog etc. + +**If you need to change the code in `stepper.tsx`, you are encouraged to do so. The code is yours. Use `stepper.tsx` as a starting point and build your own.** + +In the next sections, we'll go over each component and how to use them. + + + If you want to use + [@stepperize/react](https://stepperize.vercel.app/docs/react) API directly, + like `when`, `switch`, `match`, etc. you can use the `useStepper` hook from + your stepper instance and build your own components. + + +## defineStepper + +The `defineStepper` function is used to define the steps. It returns a `Stepper` instance with a hook and utils to interact with the stepper. + +Unlike `@stepperize/react`, `defineStepper` also offers all the components for rendering the stepper. + +For example, you can define the steps like this: + +```tsx +const stepperInstance = defineStepper( + { id: "step-1", title: "Step 1", description: "Step 1 description" }, + { id: "step-2", title: "Step 2", description: "Step 2 description" }, + { id: "step-3", title: "Step 3", description: "Step 3 description" } +) +``` + +Each instance will return: + +- `steps` - Array of steps. +- `useStepper` - Hook to interact with the stepper component. +- `utils` - Provides a set of pure functions for working with steps. + +And the components: + +- `StepperProvider` +- `StepperNavigation` +- `StepperStep` +- `StepperTitle` +- `StepperDescription` +- `StepperPanel` +- `StepperControls` + + + Each step in the `defineStepper` needs only an `id` to work and they are not + limited to any type. You can define anything within each step, even + components! + + +## useStepper + +The `useStepper` hook is used to interact with the stepper. It provides methods to interact with and render your stepper. + +## StepperProvider + +The `StepperProvider` component is used to provide the stepper instance from `defineStepper` to the other components. You should always wrap your application in a `StepperProvider` component. + +Allow us to work with the `useStepper` hook in components that are within the provider. + +For example: + +```tsx +const { StepperProvider, useStepper } = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) + +export function MyStepper() { + const methods = useStepper() // ❌ This won't work if the component is not within the provider + return ( + + + + ) +} + +function MyCustomComponent() { + const methods = useStepper() // ✅ This will work + return
{methods.currentStep.title}
+} +``` + +You also get access to the methods in the children's component + +```tsx +export function MyStepper() { + return ( + + {({ methods }) => ( + ... + )} + + ) +} +``` + +You can set the initial step and metadata for the stepper passing these props: + +- `initialStep` - The ID of the initial step to display +- `initialMetadata` - The initial metadata to set for the steps. See [Metadata](#metadata) for more information. + + + If you don't need the `methods` prop, you can just pass the children directly + and get the methods from the `useStepper` hook from your stepper instance. + + +**Props** + +| Name | Type | Description | +| ------------------ | -------------------------------- | ---------------------------------------------------------------------------------- | +| `variant` | `horizontal, vertical or circle` | Style of the stepper. | +| `labelOrientation` | `horizontal, vertical` | Orientation of the labels. This is only applicable if `variant` is `"horizontal"`. | +| `tracking` | `boolean` | Track the scroll position of the stepper. | +| `initialStep` | `string` | Initial step to render. | +| `initialMetadata` | `Record` | Initial metadata. | + +## StepperNavigation + +The `StepperNavigation` component is used to render the navigation buttons and labels. + +## StepperStep + +The `StepperStep` component is a wrapper of the button and labels. You just need to pass the `of` prop which is the step id you want to render. + + + This is a good place to add your `onClick` handler. + + +**Props** + +| Name | Type | Description | +| ------ | ----------------- | ----------------------------------------- | +| `of` | `string` | Step to render. | +| `icon` | `React.ReactNode` | Icon to render instead of the step number | + + + To keep the stepper simple and consistent, `StepperStep` only accepts these 3 + types of children: `StepperTitle`, `StepperDescription` and `StepperPanel` + + +### StepperTitle + +The `StepperTitle` component is used to render the title of the step. + +**Props** + +| Name | Type | Description | +| ---------- | ----------------- | ---------------- | +| `children` | `React.ReactNode` | Title to render. | +| `asChild` | `boolean` | Render as child. | + +### StepperDescription + +The `StepperDescription` component is used to render the description of the step. + +**Props** + +| Name | Type | Description | +| ---------- | ----------------- | ---------------------- | +| `children` | `React.ReactNode` | Description to render. | +| `asChild` | `boolean` | Render as child. | + +## StepperPanel + +The `StepperPanel` component is used to render the content of the step. + +**Props** + +| Name | Type | Description | +| ---------- | ----------------- | ------------------ | +| `children` | `React.ReactNode` | Content to render. | +| `asChild` | `boolean` | Render as child. | + +## StepperControls + +The `StepperControls` component is used to render the buttons to navigate through the steps. + +**Props** + +| Name | Type | Description | +| ---------- | ----------------- | ------------------ | +| `children` | `React.ReactNode` | Buttons to render. | +| `asChild` | `boolean` | Render as child. | + +## Before/after actions + +You can add a callback to the `next` and `prev` methods to execute a callback before or after the action is executed. +**This is useful if you need to validate the form or check if the step is valid before moving to the prev/next step.** + +For example: + +```tsx +methods.beforeNext(async () => { + const valid = await form.trigger() + if (!valid) return false + return true +}) +``` + +That function will validate the form and check if the step is valid before moving to the next step returning a boolean value. + +More info about the `beforeNext` and `beforePrev` methods can be found in the [API References](https://stepperize.vercel.app/docs/react/api-references/hook#beforeafter-functions). + +## Skip steps + +Through the methods you can access functions like `goTo` to skip to a specific step. + +```tsx +// From step 1 to step 3 +methods.goTo("step-3") +``` + +## Metadata + +You can add metadata to each step to store any information you need. This data can be accessed in the `useStepper` hook and changed at any time. + +```tsx +const { metadata, getMetadata, setMetadata, resetMetadata } = useStepper() +``` + +## Multi Scoped + +The `StepperProvider` component can be used multiple times in the same application. Each instance will be independent from the others. + +```tsx +const stepperInstance1 = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) + +const stepperInstance2 = defineStepper( + { id: "step-1", title: "Step 1" }, + { id: "step-2", title: "Step 2" }, + { id: "step-3", title: "Step 3" } +) + + + + ... + + +``` + +## Examples + +### Variants + + + +### Responsive variant + +If you need to render the stepper in a responsive way, you can use a custom hook to detect the screen size and render the stepper in a different variant. + + + Resize the window to see the stepper in a different variant. + + + + +### Description + +You can add a description to each step by using `` component inside ``. + + + +### Label Orientation + +You can change the orientation of the labels by using the `labelOrientation` prop in the `Stepper` component. + + + This is only applicable if `variant` is `"horizontal"`. + + + + +### Icon + +You can add an icon to each step by using the `icon` prop in the `StepperStep` component. + + + +### Step tracking + +If you need to track the scroll position of the stepper, you can use the `tracking` prop in the `Stepper` component. + + + +### Forms + + + +### Custom components + +If you need to add custom components, you can do so by using the `useStepper` hook and `utils` from your stepper instance. + + + If all this is not enough for you, you can use the + [@stepperize/react](https://stepperize.vercel.app/docs/react) API to create + your own stepper. + diff --git a/apps/www/package.json b/apps/www/package.json index 8d0e6474031..0d2ca024fb7 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -52,6 +52,7 @@ "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.6", + "@stepperize/react": "^5.0.1", "@tanstack/react-table": "^8.9.1", "@vercel/analytics": "^1.2.2", "@vercel/og": "^0.0.21", diff --git a/apps/www/public/r/index.json b/apps/www/public/r/index.json index 590d2f98ca6..5449cd08d1f 100644 --- a/apps/www/public/r/index.json +++ b/apps/www/public/r/index.json @@ -601,6 +601,24 @@ } ] }, + { + "name": "stepper", + "type": "registry:ui", + "dependencies": [ + "@radix-ui/react-slot", + "@stepperize/react", + "class-variance-authority" + ], + "registryDependencies": [ + "button" + ], + "files": [ + { + "path": "ui/stepper.tsx", + "type": "registry:ui" + } + ] + }, { "name": "switch", "type": "registry:ui", diff --git a/apps/www/public/r/styles/default/stepper-demo.json b/apps/www/public/r/styles/default/stepper-demo.json new file mode 100644 index 00000000000..ef394c14212 --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-demo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-demo", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-demo.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/default/ui/button\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-description.json b/apps/www/public/r/styles/default/stepper-description.json new file mode 100644 index 00000000000..85654daa6f3 --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-description.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-description", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-description.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/default/ui/button\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n StepperDescription,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n description: \"This is the first step\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n description: \"This is the second step\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n description: \"This is the third step\",\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {step.description}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-form.json b/apps/www/public/r/styles/default/stepper-form.json new file mode 100644 index 00000000000..2a0b8c56ab6 --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-form.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-form", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper", + "form" + ], + "files": [ + { + "path": "examples/stepper-form.tsx", + "content": "import * as React from \"react\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useForm, useFormContext } from \"react-hook-form\"\nimport { z } from \"zod\"\n\nimport { Button } from \"@/registry/default/ui/button\"\nimport { Form } from \"@/registry/default/ui/form\"\nimport { Input } from \"@/registry/default/ui/input\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\nconst shippingSchema = z.object({\n address: z.string().min(1, \"Address is required\"),\n city: z.string().min(1, \"City is required\"),\n postalCode: z.string().min(5, \"Postal code is required\"),\n})\n\nconst paymentSchema = z.object({\n cardNumber: z.string().min(16, \"Card number is required\"),\n expirationDate: z.string().min(5, \"Expiration date is required\"),\n cvv: z.string().min(3, \"CVV is required\"),\n})\n\ntype ShippingFormValues = z.infer\ntype PaymentFormValues = z.infer\n\nconst ShippingForm = () => {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Address\n \n \n {errors.address && (\n \n {errors.address.message}\n \n )}\n
\n
\n \n City\n \n \n {errors.city && (\n \n {errors.city.message}\n \n )}\n
\n
\n \n Postal Code\n \n \n {errors.postalCode && (\n \n {errors.postalCode.message}\n \n )}\n
\n
\n )\n}\n\nfunction PaymentForm() {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Card Number\n \n \n {errors.cardNumber && (\n \n {errors.cardNumber.message}\n \n )}\n
\n
\n \n Expiration Date\n \n \n {errors.expirationDate && (\n \n {errors.expirationDate.message}\n \n )}\n
\n
\n \n CVV\n \n \n {errors.cvv && (\n {errors.cvv.message}\n )}\n
\n
\n )\n}\n\nfunction CompleteComponent() {\n return
Thank you! Your order is complete.
\n}\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperStep,\n StepperTitle,\n useStepper,\n} = defineStepper(\n {\n id: \"shipping\",\n title: \"Shipping\",\n schema: shippingSchema,\n Component: ShippingForm,\n },\n {\n id: \"payment\",\n title: \"Payment\",\n schema: paymentSchema,\n Component: PaymentForm,\n },\n {\n id: \"complete\",\n title: \"Complete\",\n schema: z.object({}),\n Component: CompleteComponent,\n }\n)\n\nexport default function StepperForm() {\n return (\n \n \n \n )\n}\n\nconst FormStepperComponent = () => {\n const methods = useStepper()\n\n const form = useForm({\n mode: \"onTouched\",\n resolver: zodResolver(methods.current.schema),\n })\n\n const onSubmit = (values: z.infer) => {\n console.log(`Form values for step ${methods.current.id}:`, values)\n }\n\n return (\n
\n \n \n {methods.all.map((step) => (\n {\n const valid = await form.trigger()\n if (!valid) return\n methods.goTo(step.id)\n }}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n shipping: ({ Component }) => ,\n payment: ({ Component }) => ,\n complete: ({ Component }) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n {\n if (methods.isLast) {\n return methods.reset()\n }\n methods.beforeNext(async () => {\n const valid = await form.trigger()\n if (!valid) return false\n return true\n })\n }}\n >\n {methods.isLast ? \"Reset\" : \"Next\"}\n \n \n \n \n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-forms.json b/apps/www/public/r/styles/default/stepper-forms.json new file mode 100644 index 00000000000..278841b8170 --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-forms.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-forms", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper", + "form" + ], + "files": [ + { + "path": "examples/stepper-forms.tsx", + "content": "import { zodResolver } from \"@hookform/resolvers/zod\"\nimport { Form, useForm, useFormContext } from \"react-hook-form\"\nimport { z } from \"zod\"\n\nimport { Input } from \"@/registry/default/ui/input\"\nimport {\n Stepper,\n StepperAction,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n defineStepper,\n} from \"@/registry/default/ui/stepper\"\n\nconst shippingSchema = z.object({\n address: z.string().min(1, \"Address is required\"),\n city: z.string().min(1, \"City is required\"),\n postalCode: z.string().min(5, \"Postal code is required\"),\n})\n\nconst paymentSchema = z.object({\n cardNumber: z.string().min(16, \"Card number is required\"),\n expirationDate: z.string().min(5, \"Expiration date is required\"),\n cvv: z.string().min(3, \"CVV is required\"),\n})\n\ntype ShippingFormValues = z.infer\ntype PaymentFormValues = z.infer\n\nconst ShippingForm = () => {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Address\n \n \n {errors.address && (\n \n {errors.address.message}\n \n )}\n
\n
\n \n City\n \n \n {errors.city && (\n \n {errors.city.message}\n \n )}\n
\n
\n \n Postal Code\n \n \n {errors.postalCode && (\n \n {errors.postalCode.message}\n \n )}\n
\n
\n )\n}\n\nfunction PaymentForm() {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Card Number\n \n \n {errors.cardNumber && (\n \n {errors.cardNumber.message}\n \n )}\n
\n
\n \n Expiration Date\n \n \n {errors.expirationDate && (\n \n {errors.expirationDate.message}\n \n )}\n
\n
\n \n CVV\n \n \n {errors.cvv && (\n {errors.cvv.message}\n )}\n
\n
\n )\n}\n\nfunction CompleteComponent() {\n return
Thank you! Your order is complete.
\n}\n\nconst stepperInstance = defineStepper(\n {\n id: \"shipping\",\n title: \"Shipping\",\n schema: shippingSchema,\n component: ShippingForm,\n },\n {\n id: \"payment\",\n title: \"Payment\",\n schema: paymentSchema,\n component: PaymentForm,\n },\n {\n id: \"complete\",\n title: \"Complete\",\n schema: z.object({}),\n component: CompleteComponent,\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n \n \n )\n}\n\nconst FormStepperComponent = () => {\n const { steps, useStepper, utils } = stepperInstance\n const methods = useStepper()\n\n const form = useForm({\n mode: \"onTouched\",\n resolver: zodResolver(methods.current.schema),\n })\n\n const onSubmit = (values: z.infer) => {\n console.log(`Form values for step ${methods.current.id}:`, values)\n if (methods.isLast) {\n methods.reset()\n } else {\n methods.next()\n }\n }\n\n const currentIndex = utils.getIndex(methods.current.id)\n\n return (\n
\n \n \n {steps.map((step) => (\n {\n const valid = await form.trigger()\n //must be validated\n if (!valid) return\n //can't skip steps forwards but can go back anywhere if validated\n if (utils.getIndex(step.id) - currentIndex > 1) return\n methods.goTo(step.id)\n }}\n >\n {step.title}\n \n ))}\n \n {steps.map((step) => (\n \n \n \n ))}\n \n Previous\n Next\n Reset\n \n \n \n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-icon.json b/apps/www/public/r/styles/default/stepper-icon.json new file mode 100644 index 00000000000..e5982f5eb9b --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-icon.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-icon", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-icon.tsx", + "content": "import * as React from \"react\"\nimport { HomeIcon, SettingsIcon, UserIcon } from \"lucide-react\"\n\nimport { Button } from \"@/registry/default/ui/button\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n icon: ,\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n icon: ,\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n icon: ,\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n icon={step.icon}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-label-orientation.json b/apps/www/public/r/styles/default/stepper-label-orientation.json new file mode 100644 index 00000000000..43dde2142cf --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-label-orientation.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-label-orientation", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-label-orientation.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/default/ui/button\"\nimport { Label } from \"@/registry/default/ui/label\"\nimport { RadioGroup, RadioGroupItem } from \"@/registry/default/ui/radio-group\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\ntype LabelOrientation = \"horizontal\" | \"vertical\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperVariants() {\n const [labelOrientation, setLabelOrientation] =\n React.useState(\"horizontal\")\n return (\n
\n \n setLabelOrientation(value as LabelOrientation)\n }\n >\n
\n \n \n
\n
\n \n \n
\n \n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n
\n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-responsive-variant.json b/apps/www/public/r/styles/default/stepper-responsive-variant.json new file mode 100644 index 00000000000..87cc41e1ec0 --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-responsive-variant.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-responsive-variant", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-responsive-variant.tsx", + "content": "import * as React from \"react\"\n\nimport { useMediaQuery } from \"@/hooks/use-media-query\"\nimport { Button } from \"@/registry/default/ui/button\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperResponsiveVariant() {\n const isMobile = useMediaQuery(\"(max-width: 768px)\")\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {isMobile &&\n methods.when(step.id, (step) => (\n \n

\n Content for {step.id}\n

\n
\n ))}\n \n ))}\n
\n {!isMobile &&\n methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n
\n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-tracking.json b/apps/www/public/r/styles/default/stepper-tracking.json new file mode 100644 index 00000000000..74ae76c4b95 --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-tracking.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-tracking", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-tracking.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/default/ui/button\"\nimport { Label } from \"@/registry/default/ui/label\"\nimport { RadioGroup, RadioGroupItem } from \"@/registry/default/ui/radio-group\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n },\n {\n id: \"step-4\",\n title: \"Step 4\",\n },\n {\n id: \"step-5\",\n title: \"Step 5\",\n },\n {\n id: \"step-6\",\n title: \"Step 6\",\n }\n)\n\nexport default function StepperVerticalFollow() {\n const [tracking, setTracking] = React.useState(false)\n return (\n
\n setTracking(value === \"true\")}\n >\n
\n \n \n
\n
\n \n \n
\n \n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {methods.when(step.id, () => (\n \n
\n

\n Content for {step.id}\n

\n
\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n {methods.isLast ? \"Reset\" : \"Next\"}\n \n \n
\n ))}\n \n ))}\n
\n
\n )}\n \n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper-variants.json b/apps/www/public/r/styles/default/stepper-variants.json new file mode 100644 index 00000000000..f7ed692cf9c --- /dev/null +++ b/apps/www/public/r/styles/default/stepper-variants.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-variants", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-variants.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/default/ui/button\"\nimport { Label } from \"@/registry/default/ui/label\"\nimport { RadioGroup, RadioGroupItem } from \"@/registry/default/ui/radio-group\"\nimport { defineStepper } from \"@/registry/default/ui/stepper\"\n\ntype Variant = \"horizontal\" | \"vertical\" | \"circle\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperVariants() {\n const [variant, setVariant] = React.useState(\"horizontal\")\n return (\n
\n setVariant(value as Variant)}\n >\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n \n {variant === \"horizontal\" && }\n {variant === \"vertical\" && }\n {variant === \"circle\" && }\n
\n )\n}\n\nconst HorizontalStepper = () => {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n\nconst VerticalStepper = () => {\n return (\n \n {({ methods }) => (\n <>\n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {methods.when(step.id, () => (\n \n

Content for {step.id}

\n
\n ))}\n \n ))}\n
\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n
\n )\n}\n\nconst CircleStepper = () => {\n return (\n \n {({ methods }) => (\n \n \n \n {methods.current.title}\n \n \n {methods.when(methods.current.id, () => (\n \n

\n Content for {methods.current.id}\n

\n
\n ))}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n
\n )}\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/stepper.json b/apps/www/public/r/styles/default/stepper.json new file mode 100644 index 00000000000..a38b04fa427 --- /dev/null +++ b/apps/www/public/r/styles/default/stepper.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper", + "type": "registry:ui", + "author": "shadcn (https://ui.shadcn.com)", + "dependencies": [ + "@radix-ui/react-slot", + "@stepperize/react", + "class-variance-authority" + ], + "registryDependencies": [ + "button" + ], + "files": [ + { + "path": "ui/stepper.tsx", + "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport * as Stepperize from \"@stepperize/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/registry/default/ui/button\"\n\n//#region Types\ntype StepperVariant = \"horizontal\" | \"vertical\" | \"circle\"\ntype StepperLabelOrientation = \"horizontal\" | \"vertical\"\n\ntype StepperConfig = {\n variant?: StepperVariant\n labelOrientation?: StepperLabelOrientation\n tracking?: boolean\n}\n\ntype DefineStepperProps = Omit<\n Stepperize.StepperReturn,\n \"Scoped\"\n> & {\n StepperProvider: (\n props: Omit, \"children\"> &\n Omit, \"children\"> &\n StepperConfig & {\n children:\n | React.ReactNode\n | ((props: { methods: Stepperize.Stepper }) => React.ReactNode)\n }\n ) => React.ReactElement\n StepperNavigation: (props: React.ComponentProps<\"nav\">) => React.ReactElement\n StepperStep: (\n props: React.ComponentProps<\"button\"> & {\n of: Stepperize.Get.Id\n icon?: React.ReactNode\n }\n ) => React.ReactElement\n StepperTitle: (\n props: React.ComponentProps<\"h4\"> & { asChild?: boolean }\n ) => React.ReactElement\n StepperDescription: (\n props: React.ComponentProps<\"p\"> & { asChild?: boolean }\n ) => React.ReactElement\n StepperPanel: (\n props: React.ComponentProps<\"div\"> & { asChild?: boolean }\n ) => React.ReactElement\n StepperControls: (\n props: React.ComponentProps<\"div\"> & { asChild?: boolean }\n ) => React.ReactElement\n}\n\ntype CircleStepIndicatorProps = {\n currentStep: number\n totalSteps: number\n size?: number\n strokeWidth?: number\n}\n\n//#endregion Types\n\n//#region Context\n\nconst StepperContext = React.createContext(null)\n\nconst useStepperProvider = (): StepperConfig => {\n const context = React.useContext(StepperContext)\n if (!context) {\n throw new Error(\"useStepper must be used within a StepperProvider.\")\n }\n return context\n}\n\n//#endregion Context\n\n//#region Define Stepper\n\nconst defineStepper = (\n ...steps: Steps\n): DefineStepperProps => {\n const { Scoped, useStepper, ...rest } = Stepperize.defineStepper(...steps)\n\n const StepperContainer = ({\n children,\n className,\n ...props\n }: Omit, \"children\"> & {\n children:\n | React.ReactNode\n | ((props: { methods: Stepperize.Stepper }) => React.ReactNode)\n }) => {\n const methods = useStepper()\n\n return (\n
\n {typeof children === \"function\" ? children({ methods }) : children}\n
\n )\n }\n\n return {\n ...rest,\n useStepper,\n StepperProvider: ({\n variant = \"horizontal\",\n labelOrientation = \"horizontal\",\n tracking = false,\n children,\n className,\n ...props\n }) => {\n return (\n \n \n \n {children}\n \n \n \n )\n },\n StepperNavigation: ({\n children,\n className,\n \"aria-label\": ariaLabel = \"Stepper Navigation\",\n ...props\n }) => {\n const { variant } = useStepperProvider()\n return (\n \n
    {children}
\n \n )\n },\n StepperStep: ({ children, className, icon, ...props }) => {\n const { variant, labelOrientation } = useStepperProvider()\n const { current } = useStepper()\n\n const utils = rest.utils\n const steps = rest.steps\n\n const stepIndex = utils.getIndex(props.of)\n const step = steps[stepIndex]\n const currentIndex = utils.getIndex(current.id)\n\n const isLast = utils.getLast().id === props.of\n const isActive = current.id === props.of\n\n const dataState = getStepState(currentIndex, stepIndex)\n const childMap = useStepChildren(children)\n\n const title = childMap.get(\"title\")\n const description = childMap.get(\"description\")\n const panel = childMap.get(\"panel\")\n\n if (variant === \"circle\") {\n return (\n \n \n
\n {title}\n {description}\n
\n \n )\n }\n\n return (\n <>\n \n \n onStepKeyDown(\n e,\n utils.getNext(props.of),\n utils.getPrev(props.of)\n )\n }\n {...props}\n >\n {icon ?? stepIndex + 1}\n \n {variant === \"horizontal\" && labelOrientation === \"vertical\" && (\n \n )}\n
\n {title}\n {description}\n
\n \n\n {variant === \"horizontal\" && labelOrientation === \"horizontal\" && (\n \n )}\n\n {variant === \"vertical\" && (\n
\n {!isLast && (\n
\n \n
\n )}\n
{panel}
\n
\n )}\n \n )\n },\n StepperTitle,\n StepperDescription,\n StepperPanel: ({ children, className, asChild, ...props }) => {\n const Comp = asChild ? Slot : \"div\"\n const { tracking } = useStepperProvider()\n\n return (\n scrollIntoStepperPanel(node, tracking)}\n {...props}\n >\n {children}\n \n )\n },\n StepperControls: ({ children, className, asChild, ...props }) => {\n const Comp = asChild ? Slot : \"div\"\n return (\n \n {children}\n \n )\n },\n }\n}\n\n//#endregion Define Stepper\n\n//#region Stepper Title\n\nconst StepperTitle = ({\n children,\n className,\n asChild,\n ...props\n}: React.ComponentProps<\"h4\"> & { asChild?: boolean }) => {\n const Comp = asChild ? Slot : \"h4\"\n\n return (\n \n {children}\n \n )\n}\n\n//#endregion Stepper Title\n\n//#region Stepper Description\n\nconst StepperDescription = ({\n children,\n className,\n asChild,\n ...props\n}: React.ComponentProps<\"p\"> & { asChild?: boolean }) => {\n const Comp = asChild ? Slot : \"p\"\n\n return (\n \n {children}\n \n )\n}\n\n//#endregion Stepper Description\n\n//#region Stepper Separator\n\nconst StepperSeparator = ({\n orientation,\n isLast,\n labelOrientation,\n state,\n disabled,\n}: {\n isLast: boolean\n state: string\n disabled?: boolean\n} & VariantProps) => {\n if (isLast) {\n return null\n }\n return (\n \n )\n}\n\n//#endregion Stepper Separator\n\n//#region Circle Indicator\n\nconst CircleStepIndicator = ({\n currentStep,\n totalSteps,\n size = 80,\n strokeWidth = 6,\n}: CircleStepIndicatorProps) => {\n const radius = (size - strokeWidth) / 2\n const circumference = radius * 2 * Math.PI\n const fillPercentage = (currentStep / totalSteps) * 100\n const dashOffset = circumference - (circumference * fillPercentage) / 100\n return (\n \n \n Step Indicator\n \n \n \n
\n \n {currentStep} of {totalSteps}\n \n
\n \n )\n}\n\n//#endregion Circle Indicator\n\n//#region Styles\n\nconst listVariants = cva(\"stepper-navigation-list flex gap-2\", {\n variants: {\n variant: {\n horizontal: \"flex-row items-center justify-between\",\n vertical: \"flex-col\",\n circle: \"flex-row items-center justify-between\",\n },\n },\n})\n\nconst classForSeparator = cva(\n [\n \"bg-muted\",\n \"data-[state=completed]:bg-primary data-[disabled]:opacity-50\",\n \"transition-all duration-300 ease-in-out\",\n ],\n {\n variants: {\n orientation: {\n horizontal: \"h-0.5 flex-1\",\n vertical: \"h-full w-0.5\",\n },\n labelOrientation: {\n vertical:\n \"absolute left-[calc(50%+30px)] right-[calc(-50%+20px)] top-5 block shrink-0\",\n },\n },\n }\n)\n\n//#endregion Styles\n\n//#region Utils\n\nfunction scrollIntoStepperPanel(\n node: HTMLDivElement | null,\n tracking?: boolean\n) {\n if (tracking) {\n node?.scrollIntoView({ behavior: \"smooth\", block: \"center\" })\n }\n}\n\nconst useStepChildren = (children: React.ReactNode) => {\n return React.useMemo(() => extractChildren(children), [children])\n}\n\nconst extractChildren = (children: React.ReactNode) => {\n const childrenArray = React.Children.toArray(children)\n const map = new Map()\n\n for (const child of childrenArray) {\n if (React.isValidElement(child)) {\n if (child.type === StepperTitle) {\n map.set(\"title\", child)\n } else if (child.type === StepperDescription) {\n map.set(\"description\", child)\n } else {\n map.set(\"panel\", child)\n }\n }\n }\n\n return map\n}\n\nconst onStepKeyDown = (\n e: React.KeyboardEvent,\n nextStep: Stepperize.Step,\n prevStep: Stepperize.Step\n) => {\n const { key } = e\n const directions = {\n next: [\"ArrowRight\", \"ArrowDown\"],\n prev: [\"ArrowLeft\", \"ArrowUp\"],\n }\n\n if (directions.next.includes(key) || directions.prev.includes(key)) {\n const direction = directions.next.includes(key) ? \"next\" : \"prev\"\n const step = direction === \"next\" ? nextStep : prevStep\n\n if (!step) {\n return\n }\n\n const stepElement = document.getElementById(`step-${step.id}`)\n if (!stepElement) {\n return\n }\n\n const isActive =\n stepElement.parentElement?.getAttribute(\"data-state\") !== \"inactive\"\n if (isActive || direction === \"prev\") {\n stepElement.focus()\n }\n }\n}\n\nconst getStepState = (currentIndex: number, stepIndex: number) => {\n if (currentIndex === stepIndex) {\n return \"active\"\n }\n if (currentIndex > stepIndex) {\n return \"completed\"\n }\n return \"inactive\"\n}\n\n//#endregion Utils\n\nexport { defineStepper }\n", + "type": "registry:ui", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-demo.json b/apps/www/public/r/styles/new-york/stepper-demo.json new file mode 100644 index 00000000000..874cc7ae27b --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-demo.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-demo", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-demo.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-description.json b/apps/www/public/r/styles/new-york/stepper-description.json new file mode 100644 index 00000000000..ca7104f5bae --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-description.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-description", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-description.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n StepperDescription,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n description: \"This is the first step\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n description: \"This is the second step\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n description: \"This is the third step\",\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {step.description}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-form.json b/apps/www/public/r/styles/new-york/stepper-form.json new file mode 100644 index 00000000000..c425c2a6267 --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-form.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-form", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper", + "form" + ], + "files": [ + { + "path": "examples/stepper-form.tsx", + "content": "import * as React from \"react\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { useForm, useFormContext } from \"react-hook-form\"\nimport { z } from \"zod\"\n\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { Form } from \"@/registry/new-york/ui/form\"\nimport { Input } from \"@/registry/new-york/ui/input\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\nconst shippingSchema = z.object({\n address: z.string().min(1, \"Address is required\"),\n city: z.string().min(1, \"City is required\"),\n postalCode: z.string().min(5, \"Postal code is required\"),\n})\n\nconst paymentSchema = z.object({\n cardNumber: z.string().min(16, \"Card number is required\"),\n expirationDate: z.string().min(5, \"Expiration date is required\"),\n cvv: z.string().min(3, \"CVV is required\"),\n})\n\ntype ShippingFormValues = z.infer\ntype PaymentFormValues = z.infer\n\nconst ShippingForm = () => {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Address\n \n \n {errors.address && (\n \n {errors.address.message}\n \n )}\n
\n
\n \n City\n \n \n {errors.city && (\n \n {errors.city.message}\n \n )}\n
\n
\n \n Postal Code\n \n \n {errors.postalCode && (\n \n {errors.postalCode.message}\n \n )}\n
\n
\n )\n}\n\nfunction PaymentForm() {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Card Number\n \n \n {errors.cardNumber && (\n \n {errors.cardNumber.message}\n \n )}\n
\n
\n \n Expiration Date\n \n \n {errors.expirationDate && (\n \n {errors.expirationDate.message}\n \n )}\n
\n
\n \n CVV\n \n \n {errors.cvv && (\n {errors.cvv.message}\n )}\n
\n
\n )\n}\n\nfunction CompleteComponent() {\n return
Thank you! Your order is complete.
\n}\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperStep,\n StepperTitle,\n useStepper,\n} = defineStepper(\n {\n id: \"shipping\",\n title: \"Shipping\",\n schema: shippingSchema,\n Component: ShippingForm,\n },\n {\n id: \"payment\",\n title: \"Payment\",\n schema: paymentSchema,\n Component: PaymentForm,\n },\n {\n id: \"complete\",\n title: \"Complete\",\n schema: z.object({}),\n Component: CompleteComponent,\n }\n)\n\nexport default function StepperForm() {\n return (\n \n \n \n )\n}\n\nconst FormStepperComponent = () => {\n const methods = useStepper()\n\n const form = useForm({\n mode: \"onTouched\",\n resolver: zodResolver(methods.current.schema),\n })\n\n const onSubmit = (values: z.infer) => {\n console.log(`Form values for step ${methods.current.id}:`, values)\n }\n\n return (\n
\n \n \n {methods.all.map((step) => (\n {\n const valid = await form.trigger()\n if (!valid) return\n methods.goTo(step.id)\n }}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n shipping: ({ Component }) => ,\n payment: ({ Component }) => ,\n complete: ({ Component }) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n {\n if (methods.isLast) {\n return methods.reset()\n }\n methods.beforeNext(async () => {\n const valid = await form.trigger()\n if (!valid) return false\n return true\n })\n }}\n >\n {methods.isLast ? \"Reset\" : \"Next\"}\n \n \n \n \n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-forms.json b/apps/www/public/r/styles/new-york/stepper-forms.json new file mode 100644 index 00000000000..cb1392076a1 --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-forms.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-forms", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper", + "form" + ], + "files": [ + { + "path": "examples/stepper-forms.tsx", + "content": "import { zodResolver } from \"@hookform/resolvers/zod\"\nimport { Form, useForm, useFormContext } from \"react-hook-form\"\nimport { z } from \"zod\"\n\nimport { Input } from \"@/registry/new-york/ui/input\"\nimport {\n Stepper,\n StepperAction,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n defineStepper,\n} from \"@/registry/new-york/ui/stepper\"\n\nconst shippingSchema = z.object({\n address: z.string().min(1, \"Address is required\"),\n city: z.string().min(1, \"City is required\"),\n postalCode: z.string().min(5, \"Postal code is required\"),\n})\n\nconst paymentSchema = z.object({\n cardNumber: z.string().min(16, \"Card number is required\"),\n expirationDate: z.string().min(5, \"Expiration date is required\"),\n cvv: z.string().min(3, \"CVV is required\"),\n})\n\ntype ShippingFormValues = z.infer\ntype PaymentFormValues = z.infer\n\nconst ShippingForm = () => {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Address\n \n \n {errors.address && (\n \n {errors.address.message}\n \n )}\n
\n
\n \n City\n \n \n {errors.city && (\n \n {errors.city.message}\n \n )}\n
\n
\n \n Postal Code\n \n \n {errors.postalCode && (\n \n {errors.postalCode.message}\n \n )}\n
\n
\n )\n}\n\nfunction PaymentForm() {\n const {\n register,\n formState: { errors },\n } = useFormContext()\n\n return (\n
\n
\n \n Card Number\n \n \n {errors.cardNumber && (\n \n {errors.cardNumber.message}\n \n )}\n
\n
\n \n Expiration Date\n \n \n {errors.expirationDate && (\n \n {errors.expirationDate.message}\n \n )}\n
\n
\n \n CVV\n \n \n {errors.cvv && (\n {errors.cvv.message}\n )}\n
\n
\n )\n}\n\nfunction CompleteComponent() {\n return
Thank you! Your order is complete.
\n}\n\nconst stepperInstance = defineStepper(\n {\n id: \"shipping\",\n title: \"Shipping\",\n schema: shippingSchema,\n component: ShippingForm,\n },\n {\n id: \"payment\",\n title: \"Payment\",\n schema: paymentSchema,\n component: PaymentForm,\n },\n {\n id: \"complete\",\n title: \"Complete\",\n schema: z.object({}),\n component: CompleteComponent,\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n \n \n )\n}\n\nconst FormStepperComponent = () => {\n const { steps, useStepper, utils } = stepperInstance\n const methods = useStepper()\n\n const form = useForm({\n mode: \"onTouched\",\n resolver: zodResolver(methods.current.schema),\n })\n\n const onSubmit = (values: z.infer) => {\n console.log(`Form values for step ${methods.current.id}:`, values)\n if (methods.isLast) {\n methods.reset()\n } else {\n methods.next()\n }\n }\n\n const currentIndex = utils.getIndex(methods.current.id)\n\n return (\n
\n \n \n {steps.map((step) => (\n {\n const valid = await form.trigger()\n //must be validated\n if (!valid) return\n //can't skip steps forwards but can go back anywhere if validated\n if (utils.getIndex(step.id) - currentIndex > 1) return\n methods.goTo(step.id)\n }}\n >\n {step.title}\n \n ))}\n \n {steps.map((step) => (\n \n \n \n ))}\n \n Previous\n Next\n Reset\n \n \n \n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-icon.json b/apps/www/public/r/styles/new-york/stepper-icon.json new file mode 100644 index 00000000000..45bed755f10 --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-icon.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-icon", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-icon.tsx", + "content": "import * as React from \"react\"\nimport { HomeIcon, SettingsIcon, UserIcon } from \"lucide-react\"\n\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n icon: ,\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n icon: ,\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n icon: ,\n }\n)\n\nexport default function StepperDemo() {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n icon={step.icon}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-label-orientation.json b/apps/www/public/r/styles/new-york/stepper-label-orientation.json new file mode 100644 index 00000000000..f8dd031d46f --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-label-orientation.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-label-orientation", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-label-orientation.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { Label } from \"@/registry/new-york/ui/label\"\nimport { RadioGroup, RadioGroupItem } from \"@/registry/new-york/ui/radio-group\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\ntype LabelOrientation = \"horizontal\" | \"vertical\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperVariants() {\n const [labelOrientation, setLabelOrientation] =\n React.useState(\"horizontal\")\n return (\n
\n \n setLabelOrientation(value as LabelOrientation)\n }\n >\n
\n \n \n
\n
\n \n \n
\n \n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n
\n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-responsive-variant.json b/apps/www/public/r/styles/new-york/stepper-responsive-variant.json new file mode 100644 index 00000000000..e5b18868701 --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-responsive-variant.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-responsive-variant", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-responsive-variant.tsx", + "content": "import * as React from \"react\"\n\nimport { useMediaQuery } from \"@/hooks/use-media-query\"\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperResponsiveVariant() {\n const isMobile = useMediaQuery(\"(max-width: 768px)\")\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {isMobile &&\n methods.when(step.id, (step) => (\n \n

\n Content for {step.id}\n

\n
\n ))}\n \n ))}\n
\n {!isMobile &&\n methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n
\n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-tracking.json b/apps/www/public/r/styles/new-york/stepper-tracking.json new file mode 100644 index 00000000000..da1700fbad5 --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-tracking.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-tracking", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-tracking.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { Label } from \"@/registry/new-york/ui/label\"\nimport { RadioGroup, RadioGroupItem } from \"@/registry/new-york/ui/radio-group\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n },\n {\n id: \"step-4\",\n title: \"Step 4\",\n },\n {\n id: \"step-5\",\n title: \"Step 5\",\n },\n {\n id: \"step-6\",\n title: \"Step 6\",\n }\n)\n\nexport default function StepperVerticalFollow() {\n const [tracking, setTracking] = React.useState(false)\n return (\n
\n setTracking(value === \"true\")}\n >\n
\n \n \n
\n
\n \n \n
\n \n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {methods.when(step.id, () => (\n \n
\n

\n Content for {step.id}\n

\n
\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n {methods.isLast ? \"Reset\" : \"Next\"}\n \n \n
\n ))}\n \n ))}\n
\n
\n )}\n \n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper-variants.json b/apps/www/public/r/styles/new-york/stepper-variants.json new file mode 100644 index 00000000000..50512ca4131 --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper-variants.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper-variants", + "type": "registry:example", + "author": "shadcn (https://ui.shadcn.com)", + "registryDependencies": [ + "stepper" + ], + "files": [ + { + "path": "examples/stepper-variants.tsx", + "content": "import * as React from \"react\"\n\nimport { Button } from \"@/registry/new-york/ui/button\"\nimport { Label } from \"@/registry/new-york/ui/label\"\nimport { RadioGroup, RadioGroupItem } from \"@/registry/new-york/ui/radio-group\"\nimport { defineStepper } from \"@/registry/new-york/ui/stepper\"\n\ntype Variant = \"horizontal\" | \"vertical\" | \"circle\"\n\nconst {\n StepperProvider,\n StepperControls,\n StepperNavigation,\n StepperPanel,\n StepperStep,\n StepperTitle,\n} = defineStepper(\n {\n id: \"step-1\",\n title: \"Step 1\",\n },\n {\n id: \"step-2\",\n title: \"Step 2\",\n },\n {\n id: \"step-3\",\n title: \"Step 3\",\n }\n)\n\nexport default function StepperVariants() {\n const [variant, setVariant] = React.useState(\"horizontal\")\n return (\n
\n setVariant(value as Variant)}\n >\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n \n {variant === \"horizontal\" && }\n {variant === \"vertical\" && }\n {variant === \"circle\" && }\n
\n )\n}\n\nconst HorizontalStepper = () => {\n return (\n \n {({ methods }) => (\n \n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n \n ))}\n \n {methods.switch({\n \"step-1\": (step) => ,\n \"step-2\": (step) => ,\n \"step-3\": (step) => ,\n })}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n \n )\n}\n\nconst Content = ({ id }: { id: string }) => {\n return (\n \n

Content for {id}

\n
\n )\n}\n\nconst VerticalStepper = () => {\n return (\n \n {({ methods }) => (\n <>\n \n {methods.all.map((step) => (\n methods.goTo(step.id)}\n >\n {step.title}\n {methods.when(step.id, () => (\n \n

Content for {step.id}

\n
\n ))}\n \n ))}\n
\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n \n )}\n
\n )\n}\n\nconst CircleStepper = () => {\n return (\n \n {({ methods }) => (\n \n \n \n {methods.current.title}\n \n \n {methods.when(methods.current.id, () => (\n \n

\n Content for {methods.current.id}\n

\n
\n ))}\n \n {!methods.isLast && (\n \n Previous\n \n )}\n \n \n
\n )}\n
\n )\n}\n", + "type": "registry:example", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/public/r/styles/new-york/stepper.json b/apps/www/public/r/styles/new-york/stepper.json new file mode 100644 index 00000000000..2511cca6d8c --- /dev/null +++ b/apps/www/public/r/styles/new-york/stepper.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "stepper", + "type": "registry:ui", + "author": "shadcn (https://ui.shadcn.com)", + "dependencies": [ + "@radix-ui/react-slot", + "@stepperize/react", + "class-variance-authority" + ], + "registryDependencies": [ + "button" + ], + "files": [ + { + "path": "ui/stepper.tsx", + "content": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport * as Stepperize from \"@stepperize/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/registry/new-york/ui/button\"\n\n//#region Types\ntype StepperVariant = \"horizontal\" | \"vertical\" | \"circle\"\ntype StepperLabelOrientation = \"horizontal\" | \"vertical\"\n\ntype StepperConfig = {\n variant?: StepperVariant\n labelOrientation?: StepperLabelOrientation\n tracking?: boolean\n}\n\ntype DefineStepperProps = Omit<\n Stepperize.StepperReturn,\n \"Scoped\"\n> & {\n StepperProvider: (\n props: Omit, \"children\"> &\n Omit, \"children\"> &\n StepperConfig & {\n children:\n | React.ReactNode\n | ((props: { methods: Stepperize.Stepper }) => React.ReactNode)\n }\n ) => React.ReactElement\n StepperNavigation: (props: React.ComponentProps<\"nav\">) => React.ReactElement\n StepperStep: (\n props: React.ComponentProps<\"button\"> & {\n of: Stepperize.Get.Id\n icon?: React.ReactNode\n }\n ) => React.ReactElement\n StepperTitle: (\n props: React.ComponentProps<\"h4\"> & { asChild?: boolean }\n ) => React.ReactElement\n StepperDescription: (\n props: React.ComponentProps<\"p\"> & { asChild?: boolean }\n ) => React.ReactElement\n StepperPanel: (\n props: React.ComponentProps<\"div\"> & { asChild?: boolean }\n ) => React.ReactElement\n StepperControls: (\n props: React.ComponentProps<\"div\"> & { asChild?: boolean }\n ) => React.ReactElement\n}\n\ntype CircleStepIndicatorProps = {\n currentStep: number\n totalSteps: number\n size?: number\n strokeWidth?: number\n}\n\n//#endregion Types\n\n//#region Context\n\nconst StepperContext = React.createContext(null)\n\nconst useStepperProvider = (): StepperConfig => {\n const context = React.useContext(StepperContext)\n if (!context) {\n throw new Error(\"useStepper must be used within a StepperProvider.\")\n }\n return context\n}\n\n//#endregion Context\n\n//#region Define Stepper\n\nconst defineStepper = (\n ...steps: Steps\n): DefineStepperProps => {\n const { Scoped, useStepper, ...rest } = Stepperize.defineStepper(...steps)\n\n const StepperContainer = ({\n children,\n className,\n ...props\n }: Omit, \"children\"> & {\n children:\n | React.ReactNode\n | ((props: { methods: Stepperize.Stepper }) => React.ReactNode)\n }) => {\n const methods = useStepper()\n\n return (\n
\n {typeof children === \"function\" ? children({ methods }) : children}\n
\n )\n }\n\n return {\n ...rest,\n useStepper,\n StepperProvider: ({\n variant = \"horizontal\",\n labelOrientation = \"horizontal\",\n tracking = false,\n children,\n className,\n ...props\n }) => {\n return (\n \n \n \n {children}\n \n \n \n )\n },\n StepperNavigation: ({\n children,\n className,\n \"aria-label\": ariaLabel = \"Stepper Navigation\",\n ...props\n }) => {\n const { variant } = useStepperProvider()\n return (\n \n
    {children}
\n \n )\n },\n StepperStep: ({ children, className, icon, ...props }) => {\n const { variant, labelOrientation } = useStepperProvider()\n const { current } = useStepper()\n\n const utils = rest.utils\n const steps = rest.steps\n\n const stepIndex = utils.getIndex(props.of)\n const step = steps[stepIndex]\n const currentIndex = utils.getIndex(current.id)\n\n const isLast = utils.getLast().id === props.of\n const isActive = current.id === props.of\n\n const dataState = getStepState(currentIndex, stepIndex)\n const childMap = useStepChildren(children)\n\n const title = childMap.get(\"title\")\n const description = childMap.get(\"description\")\n const panel = childMap.get(\"panel\")\n\n if (variant === \"circle\") {\n return (\n \n \n
\n {title}\n {description}\n
\n \n )\n }\n\n return (\n <>\n \n \n onStepKeyDown(\n e,\n utils.getNext(props.of),\n utils.getPrev(props.of)\n )\n }\n {...props}\n >\n {icon ?? stepIndex + 1}\n \n {variant === \"horizontal\" && labelOrientation === \"vertical\" && (\n \n )}\n
\n {title}\n {description}\n
\n \n\n {variant === \"horizontal\" && labelOrientation === \"horizontal\" && (\n \n )}\n\n {variant === \"vertical\" && (\n
\n {!isLast && (\n
\n \n
\n )}\n
{panel}
\n
\n )}\n \n )\n },\n StepperTitle,\n StepperDescription,\n StepperPanel: ({ children, className, asChild, ...props }) => {\n const Comp = asChild ? Slot : \"div\"\n const { tracking } = useStepperProvider()\n\n return (\n scrollIntoStepperPanel(node, tracking)}\n {...props}\n >\n {children}\n \n )\n },\n StepperControls: ({ children, className, asChild, ...props }) => {\n const Comp = asChild ? Slot : \"div\"\n return (\n \n {children}\n \n )\n },\n }\n}\n\n//#endregion Define Stepper\n\n//#region Stepper Title\n\nconst StepperTitle = ({\n children,\n className,\n asChild,\n ...props\n}: React.ComponentProps<\"h4\"> & { asChild?: boolean }) => {\n const Comp = asChild ? Slot : \"h4\"\n\n return (\n \n {children}\n \n )\n}\n\n//#endregion Stepper Title\n\n//#region Stepper Description\n\nconst StepperDescription = ({\n children,\n className,\n asChild,\n ...props\n}: React.ComponentProps<\"p\"> & { asChild?: boolean }) => {\n const Comp = asChild ? Slot : \"p\"\n\n return (\n \n {children}\n \n )\n}\n\n//#endregion Stepper Description\n\n//#region Stepper Separator\n\nconst StepperSeparator = ({\n orientation,\n isLast,\n labelOrientation,\n state,\n disabled,\n}: {\n isLast: boolean\n state: string\n disabled?: boolean\n} & VariantProps) => {\n if (isLast) {\n return null\n }\n return (\n \n )\n}\n\n//#endregion Stepper Separator\n\n//#region Circle Indicator\n\nconst CircleStepIndicator = ({\n currentStep,\n totalSteps,\n size = 80,\n strokeWidth = 6,\n}: CircleStepIndicatorProps) => {\n const radius = (size - strokeWidth) / 2\n const circumference = radius * 2 * Math.PI\n const fillPercentage = (currentStep / totalSteps) * 100\n const dashOffset = circumference - (circumference * fillPercentage) / 100\n return (\n \n \n Step Indicator\n \n \n \n
\n \n {currentStep} of {totalSteps}\n \n
\n \n )\n}\n\n//#endregion Circle Indicator\n\n//#region Styles\n\nconst listVariants = cva(\"stepper-navigation-list flex gap-2\", {\n variants: {\n variant: {\n horizontal: \"flex-row items-center justify-between\",\n vertical: \"flex-col\",\n circle: \"flex-row items-center justify-between\",\n },\n },\n})\n\nconst classForSeparator = cva(\n [\n \"bg-muted\",\n \"data-[state=completed]:bg-primary data-[disabled]:opacity-50\",\n \"transition-all duration-300 ease-in-out\",\n ],\n {\n variants: {\n orientation: {\n horizontal: \"h-0.5 flex-1\",\n vertical: \"h-full w-0.5\",\n },\n labelOrientation: {\n vertical:\n \"absolute left-[calc(50%+30px)] right-[calc(-50%+20px)] top-5 block shrink-0\",\n },\n },\n }\n)\n\n//#endregion Styles\n\n//#region Utils\n\nfunction scrollIntoStepperPanel(\n node: HTMLDivElement | null,\n tracking?: boolean\n) {\n if (tracking) {\n node?.scrollIntoView({ behavior: \"smooth\", block: \"center\" })\n }\n}\n\nconst useStepChildren = (children: React.ReactNode) => {\n return React.useMemo(() => extractChildren(children), [children])\n}\n\nconst extractChildren = (children: React.ReactNode) => {\n const childrenArray = React.Children.toArray(children)\n const map = new Map()\n\n for (const child of childrenArray) {\n if (React.isValidElement(child)) {\n if (child.type === StepperTitle) {\n map.set(\"title\", child)\n } else if (child.type === StepperDescription) {\n map.set(\"description\", child)\n } else {\n map.set(\"panel\", child)\n }\n }\n }\n\n return map\n}\n\nconst onStepKeyDown = (\n e: React.KeyboardEvent,\n nextStep: Stepperize.Step,\n prevStep: Stepperize.Step\n) => {\n const { key } = e\n const directions = {\n next: [\"ArrowRight\", \"ArrowDown\"],\n prev: [\"ArrowLeft\", \"ArrowUp\"],\n }\n\n if (directions.next.includes(key) || directions.prev.includes(key)) {\n const direction = directions.next.includes(key) ? \"next\" : \"prev\"\n const step = direction === \"next\" ? nextStep : prevStep\n\n if (!step) {\n return\n }\n\n const stepElement = document.getElementById(`step-${step.id}`)\n if (!stepElement) {\n return\n }\n\n const isActive =\n stepElement.parentElement?.getAttribute(\"data-state\") !== \"inactive\"\n if (isActive || direction === \"prev\") {\n stepElement.focus()\n }\n }\n}\n\nconst getStepState = (currentIndex: number, stepIndex: number) => {\n if (currentIndex === stepIndex) {\n return \"active\"\n }\n if (currentIndex > stepIndex) {\n return \"completed\"\n }\n return \"inactive\"\n}\n\n//#endregion Utils\n\nexport { defineStepper }\n", + "type": "registry:ui", + "target": "" + } + ] +} \ No newline at end of file diff --git a/apps/www/registry/default/examples/stepper-demo.tsx b/apps/www/registry/default/examples/stepper-demo.tsx new file mode 100644 index 00000000000..66e1e34feed --- /dev/null +++ b/apps/www/registry/default/examples/stepper-demo.tsx @@ -0,0 +1,75 @@ +import * as React from "react" + +import { Button } from "@/registry/default/ui/button" +import { defineStepper } from "@/registry/default/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperDemo() { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

Content for {id}

+
+ ) +} diff --git a/apps/www/registry/default/examples/stepper-description.tsx b/apps/www/registry/default/examples/stepper-description.tsx new file mode 100644 index 00000000000..f102ac0d6e8 --- /dev/null +++ b/apps/www/registry/default/examples/stepper-description.tsx @@ -0,0 +1,80 @@ +import * as React from "react" + +import { Button } from "@/registry/default/ui/button" +import { defineStepper } from "@/registry/default/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, + StepperDescription, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + description: "This is the first step", + }, + { + id: "step-2", + title: "Step 2", + description: "This is the second step", + }, + { + id: "step-3", + title: "Step 3", + description: "This is the third step", + } +) + +export default function StepperDemo() { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {step.description} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

Content for {id}

+
+ ) +} diff --git a/apps/www/registry/default/examples/stepper-form.tsx b/apps/www/registry/default/examples/stepper-form.tsx new file mode 100644 index 00000000000..276203de83a --- /dev/null +++ b/apps/www/registry/default/examples/stepper-form.tsx @@ -0,0 +1,261 @@ +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFormContext } from "react-hook-form" +import { z } from "zod" + +import { Button } from "@/registry/default/ui/button" +import { Form } from "@/registry/default/ui/form" +import { Input } from "@/registry/default/ui/input" +import { defineStepper } from "@/registry/default/ui/stepper" + +const shippingSchema = z.object({ + address: z.string().min(1, "Address is required"), + city: z.string().min(1, "City is required"), + postalCode: z.string().min(5, "Postal code is required"), +}) + +const paymentSchema = z.object({ + cardNumber: z.string().min(16, "Card number is required"), + expirationDate: z.string().min(5, "Expiration date is required"), + cvv: z.string().min(3, "CVV is required"), +}) + +type ShippingFormValues = z.infer +type PaymentFormValues = z.infer + +const ShippingForm = () => { + const { + register, + formState: { errors }, + } = useFormContext() + + return ( +
+
+ + + {errors.address && ( + + {errors.address.message} + + )} +
+
+ + + {errors.city && ( + + {errors.city.message} + + )} +
+
+ + + {errors.postalCode && ( + + {errors.postalCode.message} + + )} +
+
+ ) +} + +function PaymentForm() { + const { + register, + formState: { errors }, + } = useFormContext() + + return ( +
+
+ + + {errors.cardNumber && ( + + {errors.cardNumber.message} + + )} +
+
+ + + {errors.expirationDate && ( + + {errors.expirationDate.message} + + )} +
+
+ + + {errors.cvv && ( + {errors.cvv.message} + )} +
+
+ ) +} + +function CompleteComponent() { + return
Thank you! Your order is complete.
+} + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperStep, + StepperTitle, + useStepper, +} = defineStepper( + { + id: "shipping", + title: "Shipping", + schema: shippingSchema, + Component: ShippingForm, + }, + { + id: "payment", + title: "Payment", + schema: paymentSchema, + Component: PaymentForm, + }, + { + id: "complete", + title: "Complete", + schema: z.object({}), + Component: CompleteComponent, + } +) + +export default function StepperForm() { + return ( + + + + ) +} + +const FormStepperComponent = () => { + const methods = useStepper() + + const form = useForm({ + mode: "onTouched", + resolver: zodResolver(methods.current.schema), + }) + + const onSubmit = (values: z.infer) => { + console.log(`Form values for step ${methods.current.id}:`, values) + } + + return ( +
+ + + {methods.all.map((step) => ( + { + const valid = await form.trigger() + if (!valid) return + methods.goTo(step.id) + }} + > + {step.title} + + ))} + + {methods.switch({ + shipping: ({ Component }) => , + payment: ({ Component }) => , + complete: ({ Component }) => , + })} + + {!methods.isLast && ( + + )} + + + + + ) +} diff --git a/apps/www/registry/default/examples/stepper-icon.tsx b/apps/www/registry/default/examples/stepper-icon.tsx new file mode 100644 index 00000000000..eefcdd0d594 --- /dev/null +++ b/apps/www/registry/default/examples/stepper-icon.tsx @@ -0,0 +1,80 @@ +import * as React from "react" +import { HomeIcon, SettingsIcon, UserIcon } from "lucide-react" + +import { Button } from "@/registry/default/ui/button" +import { defineStepper } from "@/registry/default/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + icon: , + }, + { + id: "step-2", + title: "Step 2", + icon: , + }, + { + id: "step-3", + title: "Step 3", + icon: , + } +) + +export default function StepperDemo() { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + icon={step.icon} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

Content for {id}

+
+ ) +} diff --git a/apps/www/registry/default/examples/stepper-label-orientation.tsx b/apps/www/registry/default/examples/stepper-label-orientation.tsx new file mode 100644 index 00000000000..1fd110e9757 --- /dev/null +++ b/apps/www/registry/default/examples/stepper-label-orientation.tsx @@ -0,0 +1,102 @@ +import * as React from "react" + +import { Button } from "@/registry/default/ui/button" +import { Label } from "@/registry/default/ui/label" +import { RadioGroup, RadioGroupItem } from "@/registry/default/ui/radio-group" +import { defineStepper } from "@/registry/default/ui/stepper" + +type LabelOrientation = "horizontal" | "vertical" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperVariants() { + const [labelOrientation, setLabelOrientation] = + React.useState("horizontal") + return ( +
+ + setLabelOrientation(value as LabelOrientation) + } + > +
+ + +
+
+ + +
+
+ + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + +
+ ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

Content for {id}

+
+ ) +} diff --git a/apps/www/registry/default/examples/stepper-responsive-variant.tsx b/apps/www/registry/default/examples/stepper-responsive-variant.tsx new file mode 100644 index 00000000000..b30513e34e2 --- /dev/null +++ b/apps/www/registry/default/examples/stepper-responsive-variant.tsx @@ -0,0 +1,89 @@ +import * as React from "react" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/registry/default/ui/button" +import { defineStepper } from "@/registry/default/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperResponsiveVariant() { + const isMobile = useMediaQuery("(max-width: 768px)") + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {isMobile && + methods.when(step.id, (step) => ( + +

+ Content for {step.id} +

+
+ ))} +
+ ))} +
+ {!isMobile && + methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + +
+ )} +
+ ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

Content for {id}

+
+ ) +} diff --git a/apps/www/registry/default/examples/stepper-tracking.tsx b/apps/www/registry/default/examples/stepper-tracking.tsx new file mode 100644 index 00000000000..64e21363d61 --- /dev/null +++ b/apps/www/registry/default/examples/stepper-tracking.tsx @@ -0,0 +1,109 @@ +import * as React from "react" + +import { Button } from "@/registry/default/ui/button" +import { Label } from "@/registry/default/ui/label" +import { RadioGroup, RadioGroupItem } from "@/registry/default/ui/radio-group" +import { defineStepper } from "@/registry/default/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + }, + { + id: "step-4", + title: "Step 4", + }, + { + id: "step-5", + title: "Step 5", + }, + { + id: "step-6", + title: "Step 6", + } +) + +export default function StepperVerticalFollow() { + const [tracking, setTracking] = React.useState(false) + return ( +
+ setTracking(value === "true")} + > +
+ + +
+
+ + +
+
+ + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {methods.when(step.id, () => ( + +
+

+ Content for {step.id} +

+
+ + {!methods.isLast && ( + + )} + + +
+ ))} +
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/apps/www/registry/default/examples/stepper-variants.tsx b/apps/www/registry/default/examples/stepper-variants.tsx new file mode 100644 index 00000000000..7194b457950 --- /dev/null +++ b/apps/www/registry/default/examples/stepper-variants.tsx @@ -0,0 +1,185 @@ +import * as React from "react" + +import { Button } from "@/registry/default/ui/button" +import { Label } from "@/registry/default/ui/label" +import { RadioGroup, RadioGroupItem } from "@/registry/default/ui/radio-group" +import { defineStepper } from "@/registry/default/ui/stepper" + +type Variant = "horizontal" | "vertical" | "circle" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperVariants() { + const [variant, setVariant] = React.useState("horizontal") + return ( +
+ setVariant(value as Variant)} + > +
+ + +
+
+ + +
+
+ + +
+
+ {variant === "horizontal" && } + {variant === "vertical" && } + {variant === "circle" && } +
+ ) +} + +const HorizontalStepper = () => { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

Content for {id}

+
+ ) +} + +const VerticalStepper = () => { + return ( + + {({ methods }) => ( + <> + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {methods.when(step.id, () => ( + +

Content for {step.id}

+
+ ))} +
+ ))} +
+ + {!methods.isLast && ( + + )} + + + + )} +
+ ) +} + +const CircleStepper = () => { + return ( + + {({ methods }) => ( + + + + {methods.current.title} + + + {methods.when(methods.current.id, () => ( + +

+ Content for {methods.current.id} +

+
+ ))} + + {!methods.isLast && ( + + )} + + +
+ )} +
+ ) +} diff --git a/apps/www/registry/default/ui/stepper.tsx b/apps/www/registry/default/ui/stepper.tsx new file mode 100644 index 00000000000..8ece956042d --- /dev/null +++ b/apps/www/registry/default/ui/stepper.tsx @@ -0,0 +1,544 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import * as Stepperize from "@stepperize/react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/registry/default/ui/button" + +//#region Types +type StepperVariant = "horizontal" | "vertical" | "circle" +type StepperLabelOrientation = "horizontal" | "vertical" + +type StepperConfig = { + variant?: StepperVariant + labelOrientation?: StepperLabelOrientation + tracking?: boolean +} + +type DefineStepperProps = Omit< + Stepperize.StepperReturn, + "Scoped" +> & { + StepperProvider: ( + props: Omit, "children"> & + Omit, "children"> & + StepperConfig & { + children: + | React.ReactNode + | ((props: { methods: Stepperize.Stepper }) => React.ReactNode) + } + ) => React.ReactElement + StepperNavigation: (props: React.ComponentProps<"nav">) => React.ReactElement + StepperStep: ( + props: React.ComponentProps<"button"> & { + of: Stepperize.Get.Id + icon?: React.ReactNode + } + ) => React.ReactElement + StepperTitle: ( + props: React.ComponentProps<"h4"> & { asChild?: boolean } + ) => React.ReactElement + StepperDescription: ( + props: React.ComponentProps<"p"> & { asChild?: boolean } + ) => React.ReactElement + StepperPanel: ( + props: React.ComponentProps<"div"> & { asChild?: boolean } + ) => React.ReactElement + StepperControls: ( + props: React.ComponentProps<"div"> & { asChild?: boolean } + ) => React.ReactElement +} + +type CircleStepIndicatorProps = { + currentStep: number + totalSteps: number + size?: number + strokeWidth?: number +} + +//#endregion Types + +//#region Context + +const StepperContext = React.createContext(null) + +const useStepperProvider = (): StepperConfig => { + const context = React.useContext(StepperContext) + if (!context) { + throw new Error("useStepper must be used within a StepperProvider.") + } + return context +} + +//#endregion Context + +//#region Define Stepper + +const defineStepper = ( + ...steps: Steps +): DefineStepperProps => { + const { Scoped, useStepper, ...rest } = Stepperize.defineStepper(...steps) + + const StepperContainer = ({ + children, + className, + ...props + }: Omit, "children"> & { + children: + | React.ReactNode + | ((props: { methods: Stepperize.Stepper }) => React.ReactNode) + }) => { + const methods = useStepper() + + return ( +
+ {typeof children === "function" ? children({ methods }) : children} +
+ ) + } + + return { + ...rest, + useStepper, + StepperProvider: ({ + variant = "horizontal", + labelOrientation = "horizontal", + tracking = false, + children, + className, + ...props + }) => { + return ( + + + + {children} + + + + ) + }, + StepperNavigation: ({ + children, + className, + "aria-label": ariaLabel = "Stepper Navigation", + ...props + }) => { + const { variant } = useStepperProvider() + return ( + + ) + }, + StepperStep: ({ children, className, icon, ...props }) => { + const { variant, labelOrientation } = useStepperProvider() + const { current } = useStepper() + + const utils = rest.utils + const steps = rest.steps + + const stepIndex = utils.getIndex(props.of) + const step = steps[stepIndex] + const currentIndex = utils.getIndex(current.id) + + const isLast = utils.getLast().id === props.of + const isActive = current.id === props.of + + const dataState = getStepState(currentIndex, stepIndex) + const childMap = useStepChildren(children) + + const title = childMap.get("title") + const description = childMap.get("description") + const panel = childMap.get("panel") + + if (variant === "circle") { + return ( +
  • + +
    + {title} + {description} +
    +
  • + ) + } + + return ( + <> +
  • + + {variant === "horizontal" && labelOrientation === "vertical" && ( + + )} +
    + {title} + {description} +
    +
  • + + {variant === "horizontal" && labelOrientation === "horizontal" && ( + + )} + + {variant === "vertical" && ( +
    + {!isLast && ( +
    + +
    + )} +
    {panel}
    +
    + )} + + ) + }, + StepperTitle, + StepperDescription, + StepperPanel: ({ children, className, asChild, ...props }) => { + const Comp = asChild ? Slot : "div" + const { tracking } = useStepperProvider() + + return ( + scrollIntoStepperPanel(node, tracking)} + {...props} + > + {children} + + ) + }, + StepperControls: ({ children, className, asChild, ...props }) => { + const Comp = asChild ? Slot : "div" + return ( + + {children} + + ) + }, + } +} + +//#endregion Define Stepper + +//#region Stepper Title + +const StepperTitle = ({ + children, + className, + asChild, + ...props +}: React.ComponentProps<"h4"> & { asChild?: boolean }) => { + const Comp = asChild ? Slot : "h4" + + return ( + + {children} + + ) +} + +//#endregion Stepper Title + +//#region Stepper Description + +const StepperDescription = ({ + children, + className, + asChild, + ...props +}: React.ComponentProps<"p"> & { asChild?: boolean }) => { + const Comp = asChild ? Slot : "p" + + return ( + + {children} + + ) +} + +//#endregion Stepper Description + +//#region Stepper Separator + +const StepperSeparator = ({ + orientation, + isLast, + labelOrientation, + state, + disabled, +}: { + isLast: boolean + state: string + disabled?: boolean +} & VariantProps) => { + if (isLast) { + return null + } + return ( +
    + ) +} + +//#endregion Stepper Separator + +//#region Circle Indicator + +const CircleStepIndicator = ({ + currentStep, + totalSteps, + size = 80, + strokeWidth = 6, +}: CircleStepIndicatorProps) => { + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const fillPercentage = (currentStep / totalSteps) * 100 + const dashOffset = circumference - (circumference * fillPercentage) / 100 + return ( +
    + + Step Indicator + + + +
    + + {currentStep} of {totalSteps} + +
    +
    + ) +} + +//#endregion Circle Indicator + +//#region Styles + +const listVariants = cva("stepper-navigation-list flex gap-2", { + variants: { + variant: { + horizontal: "flex-row items-center justify-between", + vertical: "flex-col", + circle: "flex-row items-center justify-between", + }, + }, +}) + +const classForSeparator = cva( + [ + "bg-muted", + "data-[state=completed]:bg-primary data-[disabled]:opacity-50", + "transition-all duration-300 ease-in-out", + ], + { + variants: { + orientation: { + horizontal: "h-0.5 flex-1", + vertical: "h-full w-0.5", + }, + labelOrientation: { + vertical: + "absolute left-[calc(50%+30px)] right-[calc(-50%+20px)] top-5 block shrink-0", + }, + }, + } +) + +//#endregion Styles + +//#region Utils + +function scrollIntoStepperPanel( + node: HTMLDivElement | null, + tracking?: boolean +) { + if (tracking) { + node?.scrollIntoView({ behavior: "smooth", block: "center" }) + } +} + +const useStepChildren = (children: React.ReactNode) => { + return React.useMemo(() => extractChildren(children), [children]) +} + +const extractChildren = (children: React.ReactNode) => { + const childrenArray = React.Children.toArray(children) + const map = new Map() + + for (const child of childrenArray) { + if (React.isValidElement(child)) { + if (child.type === StepperTitle) { + map.set("title", child) + } else if (child.type === StepperDescription) { + map.set("description", child) + } else { + map.set("panel", child) + } + } + } + + return map +} + +const onStepKeyDown = ( + e: React.KeyboardEvent, + nextStep: Stepperize.Step, + prevStep: Stepperize.Step +) => { + const { key } = e + const directions = { + next: ["ArrowRight", "ArrowDown"], + prev: ["ArrowLeft", "ArrowUp"], + } + + if (directions.next.includes(key) || directions.prev.includes(key)) { + const direction = directions.next.includes(key) ? "next" : "prev" + const step = direction === "next" ? nextStep : prevStep + + if (!step) { + return + } + + const stepElement = document.getElementById(`step-${step.id}`) + if (!stepElement) { + return + } + + const isActive = + stepElement.parentElement?.getAttribute("data-state") !== "inactive" + if (isActive || direction === "prev") { + stepElement.focus() + } + } +} + +const getStepState = (currentIndex: number, stepIndex: number) => { + if (currentIndex === stepIndex) { + return "active" + } + if (currentIndex > stepIndex) { + return "completed" + } + return "inactive" +} + +//#endregion Utils + +export { defineStepper } diff --git a/apps/www/registry/new-york/examples/stepper-demo.tsx b/apps/www/registry/new-york/examples/stepper-demo.tsx new file mode 100644 index 00000000000..496be1c7310 --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-demo.tsx @@ -0,0 +1,75 @@ +import * as React from "react" + +import { Button } from "@/registry/new-york/ui/button" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperDemo() { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

    Content for {id}

    +
    + ) +} diff --git a/apps/www/registry/new-york/examples/stepper-description.tsx b/apps/www/registry/new-york/examples/stepper-description.tsx new file mode 100644 index 00000000000..4a6ba8ef8d9 --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-description.tsx @@ -0,0 +1,80 @@ +import * as React from "react" + +import { Button } from "@/registry/new-york/ui/button" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, + StepperDescription, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + description: "This is the first step", + }, + { + id: "step-2", + title: "Step 2", + description: "This is the second step", + }, + { + id: "step-3", + title: "Step 3", + description: "This is the third step", + } +) + +export default function StepperDemo() { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {step.description} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

    Content for {id}

    +
    + ) +} diff --git a/apps/www/registry/new-york/examples/stepper-form.tsx b/apps/www/registry/new-york/examples/stepper-form.tsx new file mode 100644 index 00000000000..2961f8b74fd --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-form.tsx @@ -0,0 +1,261 @@ +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFormContext } from "react-hook-form" +import { z } from "zod" + +import { Button } from "@/registry/new-york/ui/button" +import { Form } from "@/registry/new-york/ui/form" +import { Input } from "@/registry/new-york/ui/input" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +const shippingSchema = z.object({ + address: z.string().min(1, "Address is required"), + city: z.string().min(1, "City is required"), + postalCode: z.string().min(5, "Postal code is required"), +}) + +const paymentSchema = z.object({ + cardNumber: z.string().min(16, "Card number is required"), + expirationDate: z.string().min(5, "Expiration date is required"), + cvv: z.string().min(3, "CVV is required"), +}) + +type ShippingFormValues = z.infer +type PaymentFormValues = z.infer + +const ShippingForm = () => { + const { + register, + formState: { errors }, + } = useFormContext() + + return ( +
    +
    + + + {errors.address && ( + + {errors.address.message} + + )} +
    +
    + + + {errors.city && ( + + {errors.city.message} + + )} +
    +
    + + + {errors.postalCode && ( + + {errors.postalCode.message} + + )} +
    +
    + ) +} + +function PaymentForm() { + const { + register, + formState: { errors }, + } = useFormContext() + + return ( +
    +
    + + + {errors.cardNumber && ( + + {errors.cardNumber.message} + + )} +
    +
    + + + {errors.expirationDate && ( + + {errors.expirationDate.message} + + )} +
    +
    + + + {errors.cvv && ( + {errors.cvv.message} + )} +
    +
    + ) +} + +function CompleteComponent() { + return
    Thank you! Your order is complete.
    +} + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperStep, + StepperTitle, + useStepper, +} = defineStepper( + { + id: "shipping", + title: "Shipping", + schema: shippingSchema, + Component: ShippingForm, + }, + { + id: "payment", + title: "Payment", + schema: paymentSchema, + Component: PaymentForm, + }, + { + id: "complete", + title: "Complete", + schema: z.object({}), + Component: CompleteComponent, + } +) + +export default function StepperForm() { + return ( + + + + ) +} + +const FormStepperComponent = () => { + const methods = useStepper() + + const form = useForm({ + mode: "onTouched", + resolver: zodResolver(methods.current.schema), + }) + + const onSubmit = (values: z.infer) => { + console.log(`Form values for step ${methods.current.id}:`, values) + } + + return ( +
    + + + {methods.all.map((step) => ( + { + const valid = await form.trigger() + if (!valid) return + methods.goTo(step.id) + }} + > + {step.title} + + ))} + + {methods.switch({ + shipping: ({ Component }) => , + payment: ({ Component }) => , + complete: ({ Component }) => , + })} + + {!methods.isLast && ( + + )} + + + + + ) +} diff --git a/apps/www/registry/new-york/examples/stepper-icon.tsx b/apps/www/registry/new-york/examples/stepper-icon.tsx new file mode 100644 index 00000000000..20fe380cade --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-icon.tsx @@ -0,0 +1,80 @@ +import * as React from "react" +import { HomeIcon, SettingsIcon, UserIcon } from "lucide-react" + +import { Button } from "@/registry/new-york/ui/button" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + icon: , + }, + { + id: "step-2", + title: "Step 2", + icon: , + }, + { + id: "step-3", + title: "Step 3", + icon: , + } +) + +export default function StepperDemo() { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + icon={step.icon} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

    Content for {id}

    +
    + ) +} diff --git a/apps/www/registry/new-york/examples/stepper-label-orientation.tsx b/apps/www/registry/new-york/examples/stepper-label-orientation.tsx new file mode 100644 index 00000000000..0fb80b08806 --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-label-orientation.tsx @@ -0,0 +1,102 @@ +import * as React from "react" + +import { Button } from "@/registry/new-york/ui/button" +import { Label } from "@/registry/new-york/ui/label" +import { RadioGroup, RadioGroupItem } from "@/registry/new-york/ui/radio-group" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +type LabelOrientation = "horizontal" | "vertical" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperVariants() { + const [labelOrientation, setLabelOrientation] = + React.useState("horizontal") + return ( +
    + + setLabelOrientation(value as LabelOrientation) + } + > +
    + + +
    +
    + + +
    +
    + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + +
    + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

    Content for {id}

    +
    + ) +} diff --git a/apps/www/registry/new-york/examples/stepper-responsive-variant.tsx b/apps/www/registry/new-york/examples/stepper-responsive-variant.tsx new file mode 100644 index 00000000000..9934f24f584 --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-responsive-variant.tsx @@ -0,0 +1,89 @@ +import * as React from "react" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/registry/new-york/ui/button" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperResponsiveVariant() { + const isMobile = useMediaQuery("(max-width: 768px)") + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {isMobile && + methods.when(step.id, (step) => ( + +

    + Content for {step.id} +

    +
    + ))} +
    + ))} +
    + {!isMobile && + methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + +
    + )} +
    + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

    Content for {id}

    +
    + ) +} diff --git a/apps/www/registry/new-york/examples/stepper-tracking.tsx b/apps/www/registry/new-york/examples/stepper-tracking.tsx new file mode 100644 index 00000000000..e4bee5cc2fd --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-tracking.tsx @@ -0,0 +1,109 @@ +import * as React from "react" + +import { Button } from "@/registry/new-york/ui/button" +import { Label } from "@/registry/new-york/ui/label" +import { RadioGroup, RadioGroupItem } from "@/registry/new-york/ui/radio-group" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + }, + { + id: "step-4", + title: "Step 4", + }, + { + id: "step-5", + title: "Step 5", + }, + { + id: "step-6", + title: "Step 6", + } +) + +export default function StepperVerticalFollow() { + const [tracking, setTracking] = React.useState(false) + return ( +
    + setTracking(value === "true")} + > +
    + + +
    +
    + + +
    +
    + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {methods.when(step.id, () => ( + +
    +

    + Content for {step.id} +

    +
    + + {!methods.isLast && ( + + )} + + +
    + ))} +
    + ))} +
    +
    + )} +
    +
    + ) +} diff --git a/apps/www/registry/new-york/examples/stepper-variants.tsx b/apps/www/registry/new-york/examples/stepper-variants.tsx new file mode 100644 index 00000000000..2824422fd38 --- /dev/null +++ b/apps/www/registry/new-york/examples/stepper-variants.tsx @@ -0,0 +1,185 @@ +import * as React from "react" + +import { Button } from "@/registry/new-york/ui/button" +import { Label } from "@/registry/new-york/ui/label" +import { RadioGroup, RadioGroupItem } from "@/registry/new-york/ui/radio-group" +import { defineStepper } from "@/registry/new-york/ui/stepper" + +type Variant = "horizontal" | "vertical" | "circle" + +const { + StepperProvider, + StepperControls, + StepperNavigation, + StepperPanel, + StepperStep, + StepperTitle, +} = defineStepper( + { + id: "step-1", + title: "Step 1", + }, + { + id: "step-2", + title: "Step 2", + }, + { + id: "step-3", + title: "Step 3", + } +) + +export default function StepperVariants() { + const [variant, setVariant] = React.useState("horizontal") + return ( +
    + setVariant(value as Variant)} + > +
    + + +
    +
    + + +
    +
    + + +
    +
    + {variant === "horizontal" && } + {variant === "vertical" && } + {variant === "circle" && } +
    + ) +} + +const HorizontalStepper = () => { + return ( + + {({ methods }) => ( + + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + + ))} + + {methods.switch({ + "step-1": (step) => , + "step-2": (step) => , + "step-3": (step) => , + })} + + {!methods.isLast && ( + + )} + + + + )} + + ) +} + +const Content = ({ id }: { id: string }) => { + return ( + +

    Content for {id}

    +
    + ) +} + +const VerticalStepper = () => { + return ( + + {({ methods }) => ( + <> + + {methods.all.map((step) => ( + methods.goTo(step.id)} + > + {step.title} + {methods.when(step.id, () => ( + +

    Content for {step.id}

    +
    + ))} +
    + ))} +
    + + {!methods.isLast && ( + + )} + + + + )} +
    + ) +} + +const CircleStepper = () => { + return ( + + {({ methods }) => ( + + + + {methods.current.title} + + + {methods.when(methods.current.id, () => ( + +

    + Content for {methods.current.id} +

    +
    + ))} + + {!methods.isLast && ( + + )} + + +
    + )} +
    + ) +} diff --git a/apps/www/registry/new-york/ui/stepper.tsx b/apps/www/registry/new-york/ui/stepper.tsx new file mode 100644 index 00000000000..1075aac8303 --- /dev/null +++ b/apps/www/registry/new-york/ui/stepper.tsx @@ -0,0 +1,544 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import * as Stepperize from "@stepperize/react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/registry/new-york/ui/button" + +//#region Types +type StepperVariant = "horizontal" | "vertical" | "circle" +type StepperLabelOrientation = "horizontal" | "vertical" + +type StepperConfig = { + variant?: StepperVariant + labelOrientation?: StepperLabelOrientation + tracking?: boolean +} + +type DefineStepperProps = Omit< + Stepperize.StepperReturn, + "Scoped" +> & { + StepperProvider: ( + props: Omit, "children"> & + Omit, "children"> & + StepperConfig & { + children: + | React.ReactNode + | ((props: { methods: Stepperize.Stepper }) => React.ReactNode) + } + ) => React.ReactElement + StepperNavigation: (props: React.ComponentProps<"nav">) => React.ReactElement + StepperStep: ( + props: React.ComponentProps<"button"> & { + of: Stepperize.Get.Id + icon?: React.ReactNode + } + ) => React.ReactElement + StepperTitle: ( + props: React.ComponentProps<"h4"> & { asChild?: boolean } + ) => React.ReactElement + StepperDescription: ( + props: React.ComponentProps<"p"> & { asChild?: boolean } + ) => React.ReactElement + StepperPanel: ( + props: React.ComponentProps<"div"> & { asChild?: boolean } + ) => React.ReactElement + StepperControls: ( + props: React.ComponentProps<"div"> & { asChild?: boolean } + ) => React.ReactElement +} + +type CircleStepIndicatorProps = { + currentStep: number + totalSteps: number + size?: number + strokeWidth?: number +} + +//#endregion Types + +//#region Context + +const StepperContext = React.createContext(null) + +const useStepperProvider = (): StepperConfig => { + const context = React.useContext(StepperContext) + if (!context) { + throw new Error("useStepper must be used within a StepperProvider.") + } + return context +} + +//#endregion Context + +//#region Define Stepper + +const defineStepper = ( + ...steps: Steps +): DefineStepperProps => { + const { Scoped, useStepper, ...rest } = Stepperize.defineStepper(...steps) + + const StepperContainer = ({ + children, + className, + ...props + }: Omit, "children"> & { + children: + | React.ReactNode + | ((props: { methods: Stepperize.Stepper }) => React.ReactNode) + }) => { + const methods = useStepper() + + return ( +
    + {typeof children === "function" ? children({ methods }) : children} +
    + ) + } + + return { + ...rest, + useStepper, + StepperProvider: ({ + variant = "horizontal", + labelOrientation = "horizontal", + tracking = false, + children, + className, + ...props + }) => { + return ( + + + + {children} + + + + ) + }, + StepperNavigation: ({ + children, + className, + "aria-label": ariaLabel = "Stepper Navigation", + ...props + }) => { + const { variant } = useStepperProvider() + return ( + + ) + }, + StepperStep: ({ children, className, icon, ...props }) => { + const { variant, labelOrientation } = useStepperProvider() + const { current } = useStepper() + + const utils = rest.utils + const steps = rest.steps + + const stepIndex = utils.getIndex(props.of) + const step = steps[stepIndex] + const currentIndex = utils.getIndex(current.id) + + const isLast = utils.getLast().id === props.of + const isActive = current.id === props.of + + const dataState = getStepState(currentIndex, stepIndex) + const childMap = useStepChildren(children) + + const title = childMap.get("title") + const description = childMap.get("description") + const panel = childMap.get("panel") + + if (variant === "circle") { + return ( +
  • + +
    + {title} + {description} +
    +
  • + ) + } + + return ( + <> +
  • + + {variant === "horizontal" && labelOrientation === "vertical" && ( + + )} +
    + {title} + {description} +
    +
  • + + {variant === "horizontal" && labelOrientation === "horizontal" && ( + + )} + + {variant === "vertical" && ( +
    + {!isLast && ( +
    + +
    + )} +
    {panel}
    +
    + )} + + ) + }, + StepperTitle, + StepperDescription, + StepperPanel: ({ children, className, asChild, ...props }) => { + const Comp = asChild ? Slot : "div" + const { tracking } = useStepperProvider() + + return ( + scrollIntoStepperPanel(node, tracking)} + {...props} + > + {children} + + ) + }, + StepperControls: ({ children, className, asChild, ...props }) => { + const Comp = asChild ? Slot : "div" + return ( + + {children} + + ) + }, + } +} + +//#endregion Define Stepper + +//#region Stepper Title + +const StepperTitle = ({ + children, + className, + asChild, + ...props +}: React.ComponentProps<"h4"> & { asChild?: boolean }) => { + const Comp = asChild ? Slot : "h4" + + return ( + + {children} + + ) +} + +//#endregion Stepper Title + +//#region Stepper Description + +const StepperDescription = ({ + children, + className, + asChild, + ...props +}: React.ComponentProps<"p"> & { asChild?: boolean }) => { + const Comp = asChild ? Slot : "p" + + return ( + + {children} + + ) +} + +//#endregion Stepper Description + +//#region Stepper Separator + +const StepperSeparator = ({ + orientation, + isLast, + labelOrientation, + state, + disabled, +}: { + isLast: boolean + state: string + disabled?: boolean +} & VariantProps) => { + if (isLast) { + return null + } + return ( +
    + ) +} + +//#endregion Stepper Separator + +//#region Circle Indicator + +const CircleStepIndicator = ({ + currentStep, + totalSteps, + size = 80, + strokeWidth = 6, +}: CircleStepIndicatorProps) => { + const radius = (size - strokeWidth) / 2 + const circumference = radius * 2 * Math.PI + const fillPercentage = (currentStep / totalSteps) * 100 + const dashOffset = circumference - (circumference * fillPercentage) / 100 + return ( +
    + + Step Indicator + + + +
    + + {currentStep} of {totalSteps} + +
    +
    + ) +} + +//#endregion Circle Indicator + +//#region Styles + +const listVariants = cva("stepper-navigation-list flex gap-2", { + variants: { + variant: { + horizontal: "flex-row items-center justify-between", + vertical: "flex-col", + circle: "flex-row items-center justify-between", + }, + }, +}) + +const classForSeparator = cva( + [ + "bg-muted", + "data-[state=completed]:bg-primary data-[disabled]:opacity-50", + "transition-all duration-300 ease-in-out", + ], + { + variants: { + orientation: { + horizontal: "h-0.5 flex-1", + vertical: "h-full w-0.5", + }, + labelOrientation: { + vertical: + "absolute left-[calc(50%+30px)] right-[calc(-50%+20px)] top-5 block shrink-0", + }, + }, + } +) + +//#endregion Styles + +//#region Utils + +function scrollIntoStepperPanel( + node: HTMLDivElement | null, + tracking?: boolean +) { + if (tracking) { + node?.scrollIntoView({ behavior: "smooth", block: "center" }) + } +} + +const useStepChildren = (children: React.ReactNode) => { + return React.useMemo(() => extractChildren(children), [children]) +} + +const extractChildren = (children: React.ReactNode) => { + const childrenArray = React.Children.toArray(children) + const map = new Map() + + for (const child of childrenArray) { + if (React.isValidElement(child)) { + if (child.type === StepperTitle) { + map.set("title", child) + } else if (child.type === StepperDescription) { + map.set("description", child) + } else { + map.set("panel", child) + } + } + } + + return map +} + +const onStepKeyDown = ( + e: React.KeyboardEvent, + nextStep: Stepperize.Step, + prevStep: Stepperize.Step +) => { + const { key } = e + const directions = { + next: ["ArrowRight", "ArrowDown"], + prev: ["ArrowLeft", "ArrowUp"], + } + + if (directions.next.includes(key) || directions.prev.includes(key)) { + const direction = directions.next.includes(key) ? "next" : "prev" + const step = direction === "next" ? nextStep : prevStep + + if (!step) { + return + } + + const stepElement = document.getElementById(`step-${step.id}`) + if (!stepElement) { + return + } + + const isActive = + stepElement.parentElement?.getAttribute("data-state") !== "inactive" + if (isActive || direction === "prev") { + stepElement.focus() + } + } +} + +const getStepState = (currentIndex: number, stepIndex: number) => { + if (currentIndex === stepIndex) { + return "active" + } + if (currentIndex > stepIndex) { + return "completed" + } + return "inactive" +} + +//#endregion Utils + +export { defineStepper } diff --git a/apps/www/registry/registry-examples.ts b/apps/www/registry/registry-examples.ts index 4f07b12ed65..60cc670c378 100644 --- a/apps/www/registry/registry-examples.ts +++ b/apps/www/registry/registry-examples.ts @@ -1094,6 +1094,94 @@ export const examples: Registry["items"] = [ }, ], }, + { + name: "stepper-demo", + type: "registry:example", + registryDependencies: ["stepper"], + files: [ + { + path: "examples/stepper-demo.tsx", + type: "registry:example", + }, + ], + }, + { + name: "stepper-variants", + type: "registry:example", + registryDependencies: ["stepper"], + files: [ + { + path: "examples/stepper-variants.tsx", + type: "registry:example", + }, + ], + }, + { + name: "stepper-responsive-variant", + type: "registry:example", + registryDependencies: ["stepper"], + files: [ + { + path: "examples/stepper-responsive-variant.tsx", + type: "registry:example", + }, + ], + }, + { + name: "stepper-description", + type: "registry:example", + registryDependencies: ["stepper"], + files: [ + { + path: "examples/stepper-description.tsx", + type: "registry:example", + }, + ], + }, + { + name: "stepper-label-orientation", + type: "registry:example", + registryDependencies: ["stepper"], + files: [ + { + path: "examples/stepper-label-orientation.tsx", + type: "registry:example", + }, + ], + }, + { + name: "stepper-tracking", + type: "registry:example", + registryDependencies: ["stepper"], + files: [ + { + path: "examples/stepper-tracking.tsx", + type: "registry:example", + }, + ], + }, + { + name: "stepper-icon", + type: "registry:example", + registryDependencies: ["stepper"], + files: [ + { + path: "examples/stepper-icon.tsx", + type: "registry:example", + }, + ], + }, + { + name: "stepper-form", + type: "registry:example", + registryDependencies: ["stepper", "form"], + files: [ + { + path: "examples/stepper-form.tsx", + type: "registry:example", + }, + ], + }, { name: "switch-demo", type: "registry:example", diff --git a/apps/www/registry/registry-ui.ts b/apps/www/registry/registry-ui.ts index a644339f4ee..d631a5cecf0 100644 --- a/apps/www/registry/registry-ui.ts +++ b/apps/www/registry/registry-ui.ts @@ -514,6 +514,22 @@ export const ui: Registry["items"] = [ }, ], }, + { + name: "stepper", + type: "registry:ui", + dependencies: [ + "@radix-ui/react-slot", + "@stepperize/react", + "class-variance-authority", + ], + registryDependencies: ["button"], + files: [ + { + path: "ui/stepper.tsx", + type: "registry:ui", + }, + ], + }, { name: "switch", type: "registry:ui", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56e6fa86e03..bbfd3946a19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.0.6 version: 1.0.6(@types/react-dom@18.2.22)(@types/react@18.2.67)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@stepperize/react': + specifier: ^5.0.1 + version: 5.0.1(react@18.2.0) '@tanstack/react-table': specifier: ^8.9.1 version: 8.9.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1381,7 +1384,6 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -1389,7 +1391,6 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead '@ianvs/prettier-plugin-sort-imports@3.7.2': resolution: {integrity: sha512-bVckKToJM8XV2wTOG1VpeXrSmfAG49esVrikbxeFbY51RJdNke9AdMANJtGuACB59uo+pGlz0wBdWFrRzWyO1A==} @@ -2756,6 +2757,14 @@ packages: resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==} engines: {node: '>=6'} + '@stepperize/core@1.1.1': + resolution: {integrity: sha512-FICvHfLELE7Sf/UU7F20igsA9Q55u7rFv7zZpBIU+wiTItLbisANqy7Fk850gUeK5iKkDGyOdJ2gR0Av6rjaHw==} + + '@stepperize/react@5.0.1': + resolution: {integrity: sha512-VFUnE18qNC5FDtQ7s0Il8FGnIleYuXvuUrLh5L0PVVRjtvLhqCUKZ7+1WrC3S3HcPWvfGX7uq2cTNmDFBC2fHg==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + '@swc/helpers@0.5.11': resolution: {integrity: sha512-YNlnKRWF2sVojTpIyzwou9XoTNbzbzONwRhOoniEioF1AtaitTvVZblaQRrAzChWQ1bLYyYSWzM18y4WwgzJ+A==} @@ -4014,7 +4023,6 @@ packages: eslint@8.44.0: resolution: {integrity: sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@9.6.1: @@ -4318,11 +4326,9 @@ packages: glob@7.1.7: resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} - deprecated: Glob versions prior to v9 are no longer supported glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported global-dirs@0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} @@ -4556,7 +4562,6 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4572,7 +4577,6 @@ packages: input-otp@1.2.2: resolution: {integrity: sha512-9x6UurPuc9Tb+ywWFcFrG4ryvScSmfLyj8D35dl/HNpSr9jZNtWiXufU65kaDHD/KYUop7hDFH+caZCUKdYNsg==} - deprecated: 'please run: npm install input-otp@latest' peerDependencies: react: ^16.8 || ^17.0 || ^18.0 react-dom: ^16.8 || ^17.0 || ^18.0 @@ -5879,10 +5883,6 @@ packages: q@1.5.1: resolution: {integrity: sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==} engines: {node: '>=0.6.0', teleport: '>=0.2.0'} - deprecated: |- - You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6154,13 +6154,11 @@ packages: rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@4.1.3: resolution: {integrity: sha512-iyzalDLo3l5FZxxaIGUY7xI4Bf90Xt7pCipc1Mr7RsdU7H3538z+M0tlsUDrz0aHeGS9uNqiKHUJyTewwRP91Q==} engines: {node: '>=14'} - deprecated: Please upgrade to 4.3.1 or higher to fix a potentially damaging issue regarding symbolic link following. See https://github.com/isaacs/rimraf/issues/259 for details. hasBin: true rimraf@6.0.1: @@ -9540,6 +9538,13 @@ snapshots: '@sindresorhus/is@0.14.0': {} + '@stepperize/core@1.1.1': {} + + '@stepperize/react@5.0.1(react@18.2.0)': + dependencies: + '@stepperize/core': 1.1.1 + react: 18.2.0 + '@swc/helpers@0.5.11': dependencies: tslib: 2.6.2