mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-20 22:31:35 +00:00
Compare commits
208 Commits
shadcn@3.4
...
shadcn@3.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d28e02be1b | ||
|
|
6699158a22 | ||
|
|
142cd8ef13 | ||
|
|
bdedce2750 | ||
|
|
4cb283d68e | ||
|
|
480a6cdb37 | ||
|
|
8ba883738e | ||
|
|
b022c24825 | ||
|
|
3587477865 | ||
|
|
05143a80e6 | ||
|
|
728d2003b7 | ||
|
|
12c9e6b0b5 | ||
|
|
56cd757c45 | ||
|
|
9eb784054f | ||
|
|
824577692b | ||
|
|
6be68df08c | ||
|
|
cc48808a0d | ||
|
|
a56b3720d1 | ||
|
|
334db11234 | ||
|
|
8a5027a0cd | ||
|
|
803206305d | ||
|
|
d0fb73ac0e | ||
|
|
62218c1c0c | ||
|
|
dd1563d57d | ||
|
|
0538384860 | ||
|
|
d43b437abc | ||
|
|
8fbfacd243 | ||
|
|
778cee31ee | ||
|
|
73d8b8a817 | ||
|
|
55ab069aca | ||
|
|
c39925a9be | ||
|
|
51179ccd64 | ||
|
|
dcfa05e392 | ||
|
|
541f55df04 | ||
|
|
69010e0230 | ||
|
|
a8025c866e | ||
|
|
6e34ec7280 | ||
|
|
10ccb244a1 | ||
|
|
16fdb07ccc | ||
|
|
49da1fae79 | ||
|
|
a2244d42f7 | ||
|
|
c2075e2a8b | ||
|
|
dd2d8d7ead | ||
|
|
b6a93b7ec6 | ||
|
|
4899d3f0da | ||
|
|
3d04cb099a | ||
|
|
cde343916c | ||
|
|
c877df07b8 | ||
|
|
65e5c1c3cf | ||
|
|
8a7f05f670 | ||
|
|
db004ce4c0 | ||
|
|
e23698a897 | ||
|
|
5813ef20a3 | ||
|
|
515024b69e | ||
|
|
f7284c5cc3 | ||
|
|
c02d00aafc | ||
|
|
df497ad236 | ||
|
|
1e468e33ac | ||
|
|
ff91c31a71 | ||
|
|
25d6a18f6f | ||
|
|
c0309510b6 | ||
|
|
a3a1574668 | ||
|
|
65d581ea5a | ||
|
|
fdf80a1d49 | ||
|
|
86c494c452 | ||
|
|
eb158686b9 | ||
|
|
134cd46edb | ||
|
|
47b0efb20c | ||
|
|
bd4d09d33e | ||
|
|
14d6265580 | ||
|
|
68805d29a1 | ||
|
|
c100d5841a | ||
|
|
7a71da5218 | ||
|
|
e18902039a | ||
|
|
559af6c245 | ||
|
|
8971be484f | ||
|
|
ad6a3c6367 | ||
|
|
befa56b5be | ||
|
|
5d1770e36d | ||
|
|
653521725a | ||
|
|
7c0618bf43 | ||
|
|
854641cea1 | ||
|
|
3a72007f61 | ||
|
|
6b53b238fb | ||
|
|
b398fea304 | ||
|
|
f22174a77f | ||
|
|
c9a39f1007 | ||
|
|
a8ad21f81f | ||
|
|
504503c638 | ||
|
|
f8df5c95cb | ||
|
|
2bfc1c82ba | ||
|
|
84bd724d97 | ||
|
|
39fdf94550 | ||
|
|
08479cc3db | ||
|
|
02d5ce85ec | ||
|
|
c0329c86b9 | ||
|
|
3b1491f908 | ||
|
|
ca4c1c43ec | ||
|
|
1e840eb53c | ||
|
|
96ac92e63f | ||
|
|
e11546e692 | ||
|
|
0b4d62f95c | ||
|
|
dae80dad65 | ||
|
|
abc09809e8 | ||
|
|
8a40fe0ead | ||
|
|
b3ab304a00 | ||
|
|
bb45fd83c3 | ||
|
|
84678ee1c0 | ||
|
|
33ffb0419c | ||
|
|
a2f6c031e2 | ||
|
|
ac098d8cf0 | ||
|
|
8160610410 | ||
|
|
c7901e3a41 | ||
|
|
d73ac361b3 | ||
|
|
ebad2901ce | ||
|
|
4f617d59b8 | ||
|
|
ed0e103bd6 | ||
|
|
9cab0c9b18 | ||
|
|
d80e084814 | ||
|
|
efcf9728c2 | ||
|
|
8835bacc8b | ||
|
|
f2556d2386 | ||
|
|
75a0000075 | ||
|
|
ac306c60f5 | ||
|
|
5e2ef1f8bd | ||
|
|
7d9b8aefff | ||
|
|
58208e3802 | ||
|
|
a16a77446a | ||
|
|
39032bb390 | ||
|
|
d7e0dc3ec8 | ||
|
|
6bddba986d | ||
|
|
b70059b25b | ||
|
|
37bc2eec1f | ||
|
|
bb048fb532 | ||
|
|
9c373dbd27 | ||
|
|
d75b092c61 | ||
|
|
be49662bf5 | ||
|
|
b2b2e3fc98 | ||
|
|
188b746074 | ||
|
|
6f093a0f3f | ||
|
|
f18f1eaff7 | ||
|
|
9ac1b5c0a5 | ||
|
|
f63b70b413 | ||
|
|
54e725d986 | ||
|
|
62dbad36bb | ||
|
|
a707424fa2 | ||
|
|
e2bfa6bd85 | ||
|
|
6292464d90 | ||
|
|
6617167d6f | ||
|
|
ca28857d40 | ||
|
|
343bc941b1 | ||
|
|
c9311f26fa | ||
|
|
4e0871f426 | ||
|
|
cb769b7059 | ||
|
|
93037dca94 | ||
|
|
ed9d5939e6 | ||
|
|
b52ec12f1e | ||
|
|
2ab9bff4bb | ||
|
|
2f6b51fa0a | ||
|
|
8a4764ed91 | ||
|
|
e934d4645b | ||
|
|
08b8e499d8 | ||
|
|
69402b3579 | ||
|
|
679c852254 | ||
|
|
d478412e44 | ||
|
|
d5c8a25150 | ||
|
|
26433a651c | ||
|
|
c3da716e94 | ||
|
|
b2572d0287 | ||
|
|
b83f042416 | ||
|
|
6567897393 | ||
|
|
2675fa3941 | ||
|
|
fbda67c88c | ||
|
|
e8674ee848 | ||
|
|
adb66f4d43 | ||
|
|
3afb46eaf6 | ||
|
|
7cd019ad36 | ||
|
|
bea7d30536 | ||
|
|
40c3ff513a | ||
|
|
89ebfdce47 | ||
|
|
b83023034a | ||
|
|
6a534d7954 | ||
|
|
ef1987ded9 | ||
|
|
77bf7d28b4 | ||
|
|
41f4f7357d | ||
|
|
bc99818e04 | ||
|
|
162ba7b13c | ||
|
|
f12db1e3a2 | ||
|
|
ce3e2b1df8 | ||
|
|
dcfe911b33 | ||
|
|
7210a4919a | ||
|
|
d198908510 | ||
|
|
b0b1cd1f0d | ||
|
|
f3d70724b6 | ||
|
|
407e9c6802 | ||
|
|
c67e630521 | ||
|
|
f494411953 | ||
|
|
a43c1d1342 | ||
|
|
607a6fd127 | ||
|
|
fbcc665b49 | ||
|
|
7ddcf31e43 | ||
|
|
3e39163b08 | ||
|
|
e311fdae04 | ||
|
|
26640d9d88 | ||
|
|
3e20c228da | ||
|
|
0810c0e1a2 | ||
|
|
1205ea5445 | ||
|
|
4430ab8bab |
@@ -7,5 +7,5 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["www", "v4", "tests"]
|
||||
"ignore": ["v4", "tests"]
|
||||
}
|
||||
|
||||
63
.github/ISSUE_TEMPLATE/registry_directory.yml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/registry_directory.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Add registry to directory
|
||||
description: Add your registry to the directory
|
||||
title: "[Registry Directory]: "
|
||||
labels: ["registry", "directory"]
|
||||
assignees: []
|
||||
body:
|
||||
- type: input
|
||||
id: name
|
||||
attributes:
|
||||
label: Name
|
||||
description: The name of your registry. This is also the namespace.
|
||||
placeholder: e.g., "@acme"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: url
|
||||
attributes:
|
||||
label: URL
|
||||
description: The URL to your registry index. Use {name} placeholder.
|
||||
placeholder: https://ui.acme.com/r/{name}.json
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: homepage
|
||||
attributes:
|
||||
label: Homepage
|
||||
description: The URL to your registry homepage. This is where users can browse your registry.
|
||||
placeholder: https://ui.acme.com
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Briefly describe what is your registry and what type of components or code it distributes.
|
||||
placeholder:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logo
|
||||
attributes:
|
||||
label: Logo
|
||||
description: Add your SVG logo here.
|
||||
placeholder:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Verify that your registry meets the following requirements.
|
||||
options:
|
||||
- label: The registry must be open source and publicly accessible.
|
||||
- label: The registry must be a valid JSON file that conforms to the [registry schema](https://ui.shadcn.com/docs/registry/registry-json) specification.
|
||||
- label: The `files` array, if present on your registry items, must NOT include a `content` property.
|
||||
- label: I've attached a square SVG logo to this issue
|
||||
validations:
|
||||
required: true
|
||||
78
.github/workflows/deprecated.yml
vendored
Normal file
78
.github/workflows/deprecated.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Deprecated
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
deprecated:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
apps/www/**
|
||||
files_ignore: |
|
||||
apps/www/public/r/**
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
sha: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Comment on PR if www files changed
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' ');
|
||||
const wwwFiles = changedFiles.filter(file =>
|
||||
file.startsWith('apps/www/') &&
|
||||
!file.startsWith('apps/www/public/r/') &&
|
||||
file !== 'apps/www/package.json'
|
||||
);
|
||||
|
||||
if (wwwFiles.length > 0) {
|
||||
const comment = `Looks like this PR modifies files in \`apps/www\`, which is deprecated.
|
||||
|
||||
Consider applying the change to \`apps/v4\` if relevant.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
// Add deprecated label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['deprecated']
|
||||
});
|
||||
} else {
|
||||
// Remove deprecated label if no www files are changed
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'deprecated'
|
||||
});
|
||||
} catch (error) {
|
||||
// Label doesn't exist, which is fine
|
||||
console.log('Deprecated label not found, skipping removal');
|
||||
}
|
||||
}
|
||||
4
.github/workflows/prerelease.yml
vendored
4
.github/workflows/prerelease.yml
vendored
@@ -27,10 +27,10 @@ jobs:
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Use Node.js 18
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -23,11 +23,11 @@ jobs:
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Use Node.js 18
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
version: 9.0.6
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
|
||||
@@ -3,5 +3,5 @@ node_modules
|
||||
.next
|
||||
build
|
||||
.contentlayer
|
||||
apps/www/pages/api/registry.json
|
||||
**/fixtures
|
||||
deprecated
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -10,6 +10,6 @@
|
||||
"**/fixtures/**"
|
||||
],
|
||||
"files.exclude": {
|
||||
"apps/www": true
|
||||
"deprecated": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,28 +20,25 @@ This repository is structured as follows:
|
||||
|
||||
```
|
||||
apps
|
||||
└── www
|
||||
└── v4
|
||||
├── app
|
||||
├── components
|
||||
├── content
|
||||
└── registry
|
||||
├── default
|
||||
│ ├── example
|
||||
│ └── ui
|
||||
└── new-york
|
||||
└── new-york-v4
|
||||
├── example
|
||||
└── ui
|
||||
packages
|
||||
└── cli
|
||||
└── shadcn
|
||||
```
|
||||
|
||||
| Path | Description |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| `apps/www/app` | The Next.js application for the website. |
|
||||
| `apps/www/components` | The React components for the website. |
|
||||
| `apps/www/content` | The content for the website. |
|
||||
| `apps/www/registry` | The registry for the components. |
|
||||
| `packages/cli` | The `shadcn-ui` package. |
|
||||
| Path | Description |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| `apps/v4/app` | The Next.js application for the website. |
|
||||
| `apps/v4/components` | The React components for the website. |
|
||||
| `apps/v4/content` | The content for the website. |
|
||||
| `apps/v4/registry` | The registry for the components. |
|
||||
| `packages/shadcn` | The `shadcn` package. |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -82,32 +79,26 @@ You can use the `pnpm --filter=[WORKSPACE]` command to start the development pro
|
||||
1. To run the `ui.shadcn.com` website:
|
||||
|
||||
```bash
|
||||
pnpm --filter=www dev
|
||||
pnpm --filter=v4 dev
|
||||
```
|
||||
|
||||
2. To run the `shadcn-ui` package:
|
||||
2. To run the `shadcn` package:
|
||||
|
||||
```bash
|
||||
pnpm --filter=shadcn-ui dev
|
||||
pnpm --filter=shadcn dev
|
||||
```
|
||||
|
||||
## Running the CLI Locally
|
||||
|
||||
To run the CLI locally, you can follow the workflow:
|
||||
|
||||
1. Start by running the registry (main site) to make sure the components are up to date:
|
||||
1. Start by running the dev server:
|
||||
|
||||
```bash
|
||||
pnpm v4:dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
2. Run the development script for the CLI:
|
||||
|
||||
```bash
|
||||
pnpm shadcn:dev
|
||||
```
|
||||
|
||||
3. In another terminal tab, test the CLI by running:
|
||||
2. In another terminal tab, test the CLI by running:
|
||||
|
||||
```bash
|
||||
pnpm shadcn
|
||||
@@ -119,36 +110,27 @@ To run the CLI locally, you can follow the workflow:
|
||||
pnpm shadcn <init | add | ...> -c ~/Desktop/my-app
|
||||
```
|
||||
|
||||
4. To run the tests for the CLI:
|
||||
|
||||
```bash
|
||||
pnpm --filter=shadcn test
|
||||
```
|
||||
|
||||
This workflow ensures that you are running the most recent version of the registry and testing the CLI properly in your local environment.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation for this project is located in the `www` workspace. You can run the documentation locally by running the following command:
|
||||
The documentation for this project is located in the `v4` workspace. You can run the documentation locally by running the following command:
|
||||
|
||||
```bash
|
||||
pnpm --filter=www dev
|
||||
pnpm --filter=v4 dev
|
||||
```
|
||||
|
||||
Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/www/content/docs` directory.
|
||||
Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/v4/content/docs` directory.
|
||||
|
||||
## Components
|
||||
|
||||
We use a registry system for developing components. You can find the source code for the components under `apps/www/registry`. The components are organized by styles.
|
||||
We use a registry system for developing components. You can find the source code for the components under `apps/v4/registry`. The components are organized by styles.
|
||||
|
||||
```bash
|
||||
apps
|
||||
└── www
|
||||
└── v4
|
||||
└── registry
|
||||
├── default
|
||||
│ ├── example
|
||||
│ └── ui
|
||||
└── new-york
|
||||
└── new-york-v4
|
||||
├── example
|
||||
└── ui
|
||||
```
|
||||
@@ -157,7 +139,7 @@ When adding or modifying components, please ensure that:
|
||||
|
||||
1. You make the changes for every style.
|
||||
2. You update the documentation.
|
||||
3. You run `pnpm build:registry` to update the registry.
|
||||
3. You run `pnpm registry:build` to update the registry.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -196,9 +178,9 @@ If you have a request for a new component, please open a discussion on GitHub. W
|
||||
|
||||
## CLI
|
||||
|
||||
The `shadcn-ui` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.shadcn.com/docs/cli).
|
||||
The `shadcn` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.shadcn.com/docs/cli).
|
||||
|
||||
Any changes to the CLI should be made in the `packages/cli` directory. If you can, it would be great if you could add tests for your changes.
|
||||
Any changes to the CLI should be made in the `packages/shadcn` directory. If you can, it would be great if you could add tests for your changes.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# shadcn/ui
|
||||
|
||||
Accessible and customizable components that you can copy and paste into your apps. Free. Open Source. **Use this to build your own component library**.
|
||||
A set of beautifully designed components that you can customize, extend, and build on. Start here then make it your own. Open Source. Open Code. **Use this to build your own component library**.
|
||||
|
||||

|
||||

|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -6,4 +6,4 @@ We will investigate all legitimate reports and do our best to quickly fix the pr
|
||||
|
||||
Our preference is that you make use of GitHub's private vulnerability reporting feature to disclose potential security vulnerabilities in our Open Source Software.
|
||||
|
||||
To do this, please visit the security tab of the repository and click the "Report a vulnerability" button.
|
||||
To do this, please visit the security tab of the repository and click the [Report a vulnerability](https://github.com/shadcn-ui/ui/security/advisories/new) button.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconMinus, IconPlus } from "@tabler/icons-react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { useThemeConfig } from "@/components/active-theme"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import { ButtonGroup } from "@/registry/new-york-v4/ui/button-group"
|
||||
import {
|
||||
@@ -18,34 +17,31 @@ import {
|
||||
FieldTitle,
|
||||
} from "@/registry/new-york-v4/ui/field"
|
||||
import { Input } from "@/registry/new-york-v4/ui/input"
|
||||
import { Label } from "@/registry/new-york-v4/ui/label"
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/registry/new-york-v4/ui/radio-group"
|
||||
import { Switch } from "@/registry/new-york-v4/ui/switch"
|
||||
|
||||
const accents = [
|
||||
{
|
||||
name: "Blue",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
name: "Amber",
|
||||
value: "amber",
|
||||
},
|
||||
{
|
||||
name: "Green",
|
||||
value: "green",
|
||||
},
|
||||
{
|
||||
name: "Rose",
|
||||
value: "rose",
|
||||
},
|
||||
]
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { activeTheme, setActiveTheme } = useThemeConfig()
|
||||
const [gpuCount, setGpuCount] = React.useState(8)
|
||||
|
||||
const handleGpuAdjustment = React.useCallback((adjustment: number) => {
|
||||
setGpuCount((prevCount) =>
|
||||
Math.max(1, Math.min(99, prevCount + adjustment))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleGpuInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10)
|
||||
if (!isNaN(value) && value >= 1 && value <= 99) {
|
||||
setGpuCount(value)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldGroup>
|
||||
@@ -90,37 +86,6 @@ export function AppearanceSettings() {
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
<FieldSeparator />
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldTitle>Accent</FieldTitle>
|
||||
<FieldDescription>Select the accent color.</FieldDescription>
|
||||
</FieldContent>
|
||||
<FieldSet aria-label="Accent">
|
||||
<RadioGroup
|
||||
className="flex flex-wrap gap-2"
|
||||
value={activeTheme}
|
||||
onValueChange={setActiveTheme}
|
||||
>
|
||||
{accents.map((accent) => (
|
||||
<Label
|
||||
htmlFor={accent.value}
|
||||
key={accent.value}
|
||||
data-theme={accent.value}
|
||||
className="flex size-6 items-center justify-center rounded-full data-[theme=amber]:bg-amber-600 data-[theme=blue]:bg-blue-700 data-[theme=green]:bg-green-600 data-[theme=rose]:bg-rose-600"
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={accent.value}
|
||||
value={accent.value}
|
||||
aria-label={accent.name}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<CheckIcon className="hidden size-4 stroke-white peer-data-[state=checked]:block" />
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldSet>
|
||||
</Field>
|
||||
<FieldSeparator />
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="number-of-gpus-f6l">Number of GPUs</FieldLabel>
|
||||
@@ -129,7 +94,8 @@ export function AppearanceSettings() {
|
||||
<ButtonGroup>
|
||||
<Input
|
||||
id="number-of-gpus-f6l"
|
||||
placeholder="8"
|
||||
value={gpuCount}
|
||||
onChange={handleGpuInputChange}
|
||||
size={3}
|
||||
className="h-8 !w-14 font-mono"
|
||||
maxLength={3}
|
||||
@@ -139,6 +105,8 @@ export function AppearanceSettings() {
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
aria-label="Decrement"
|
||||
onClick={() => handleGpuAdjustment(-1)}
|
||||
disabled={gpuCount <= 1}
|
||||
>
|
||||
<IconMinus />
|
||||
</Button>
|
||||
@@ -147,6 +115,8 @@ export function AppearanceSettings() {
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
aria-label="Increment"
|
||||
onClick={() => handleGpuAdjustment(1)}
|
||||
disabled={gpuCount >= 99}
|
||||
>
|
||||
<IconPlus />
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function RootComponents() {
|
||||
<div className="flex flex-col gap-6 *:[div]:w-full *:[div]:max-w-full">
|
||||
<InputGroupButtonExample />
|
||||
<ItemDemo />
|
||||
<FieldSeparator>Appearance Settings</FieldSeparator>
|
||||
<FieldSeparator className="my-4">Appearance Settings</FieldSeparator>
|
||||
<AppearanceSettings />
|
||||
</div>
|
||||
<div className="order-first flex flex-col gap-6 lg:hidden xl:order-last xl:flex *:[div]:w-full *:[div]:max-w-full">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Spinner } from "@/registry/new-york-v4/ui/spinner"
|
||||
|
||||
export function SpinnerBadge() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 [--radius:1.2rem]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>
|
||||
<Spinner />
|
||||
Syncing
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getAllBlockIds } from "@/lib/blocks"
|
||||
import { registryCategories } from "@/lib/categories"
|
||||
import { BlockDisplay } from "@/components/block-display"
|
||||
import { registryCategories } from "@/registry/registry-categories"
|
||||
import { getActiveStyle } from "@/registry/styles"
|
||||
|
||||
export const revalidate = false
|
||||
export const dynamic = "force-static"
|
||||
@@ -17,13 +18,16 @@ export default async function BlocksPage({
|
||||
}: {
|
||||
params: Promise<{ categories?: string[] }>
|
||||
}) {
|
||||
const { categories = [] } = await params
|
||||
const [{ categories = [] }, activeStyle] = await Promise.all([
|
||||
params,
|
||||
getActiveStyle(),
|
||||
])
|
||||
const blocks = await getAllBlockIds(["registry:block"], categories)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 md:gap-24">
|
||||
{blocks.map((name) => (
|
||||
<BlockDisplay name={name} key={name} />
|
||||
<BlockDisplay name={name} key={name} styleName={activeStyle.name} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link"
|
||||
|
||||
import { BlockDisplay } from "@/components/block-display"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import { getActiveStyle } from "@/registry/styles"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
export const revalidate = false
|
||||
@@ -15,10 +16,12 @@ const FEATURED_BLOCKS = [
|
||||
]
|
||||
|
||||
export default async function BlocksPage() {
|
||||
const activeStyle = await getActiveStyle()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-12 md:gap-24">
|
||||
{FEATURED_BLOCKS.map((name) => (
|
||||
<BlockDisplay name={name} key={name} />
|
||||
<BlockDisplay name={name} key={name} styleName={activeStyle.name} />
|
||||
))}
|
||||
<div className="container-wrapper">
|
||||
<div className="container flex justify-center py-6">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChartDisplay } from "@/components/chart-display"
|
||||
import { getActiveStyle } from "@/registry/styles"
|
||||
import { charts } from "@/app/(app)/charts/charts"
|
||||
|
||||
export const revalidate = false
|
||||
@@ -41,6 +42,7 @@ export default async function ChartPage({ params }: ChartPageProps) {
|
||||
|
||||
const chartType = type as ChartType
|
||||
const chartList = charts[chartType]
|
||||
const activeStyle = await getActiveStyle()
|
||||
|
||||
return (
|
||||
<div className="grid flex-1 gap-12 lg:gap-24">
|
||||
@@ -54,6 +56,7 @@ export default async function ChartPage({ params }: ChartPageProps) {
|
||||
<ChartDisplay
|
||||
key={chart.id}
|
||||
name={chart.id}
|
||||
styleName={activeStyle.name}
|
||||
className={cn(chart.fullWidth && "md:col-span-2 lg:col-span-3")}
|
||||
>
|
||||
<chart.component />
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
IconArrowRight,
|
||||
IconArrowUpRight,
|
||||
} from "@tabler/icons-react"
|
||||
import { findNeighbour } from "fumadocs-core/server"
|
||||
import fm from "front-matter"
|
||||
import { findNeighbour } from "fumadocs-core/page-tree"
|
||||
import z from "zod"
|
||||
|
||||
import { source } from "@/lib/source"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
@@ -25,7 +27,7 @@ export function generateStaticParams() {
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>
|
||||
params: Promise<{ slug: string[] }>
|
||||
}) {
|
||||
const params = await props.params
|
||||
const page = source.getPage(params.slug)
|
||||
@@ -73,7 +75,7 @@ export async function generateMetadata(props: {
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>
|
||||
params: Promise<{ slug: string[] }>
|
||||
}) {
|
||||
const params = await props.params
|
||||
const page = source.getPage(params.slug)
|
||||
@@ -82,18 +84,24 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
const doc = page.data
|
||||
// @ts-expect-error - revisit fumadocs types.
|
||||
const MDX = doc.body
|
||||
const neighbours = await findNeighbour(source.pageTree, page.url)
|
||||
const neighbours = findNeighbour(source.pageTree, page.url)
|
||||
|
||||
// @ts-expect-error - revisit fumadocs types.
|
||||
const links = doc.links
|
||||
const raw = await page.data.getText("raw")
|
||||
const { attributes } = fm(raw)
|
||||
const { links } = z
|
||||
.object({
|
||||
links: z
|
||||
.object({
|
||||
doc: z.string().optional(),
|
||||
api: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.parse(attributes)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="docs"
|
||||
className="flex items-stretch text-[1.05rem] sm:text-[15px] xl:w-full"
|
||||
>
|
||||
<div className="flex items-stretch text-[1.05rem] sm:text-[15px] xl:w-full">
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="h-(--top-spacing) shrink-0" />
|
||||
<div className="mx-auto flex w-full max-w-2xl min-w-0 flex-1 flex-col gap-8 px-4 py-6 text-neutral-800 md:px-0 lg:py-8 dark:text-neutral-300">
|
||||
@@ -104,11 +112,7 @@ export default async function Page(props: {
|
||||
{doc.title}
|
||||
</h1>
|
||||
<div className="docs-nav bg-background/80 border-border/50 fixed inset-x-0 bottom-0 isolate z-50 flex items-center gap-2 border-t px-6 py-4 backdrop-blur-sm sm:static sm:z-0 sm:border-t-0 sm:bg-transparent sm:px-0 sm:pt-1.5 sm:backdrop-blur-none">
|
||||
<DocsCopyPage
|
||||
// @ts-expect-error - revisit fumadocs types.
|
||||
page={doc.content}
|
||||
url={absoluteUrl(page.url)}
|
||||
/>
|
||||
<DocsCopyPage page={raw} url={absoluteUrl(page.url)} />
|
||||
{neighbours.previous && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -195,10 +199,8 @@ export default async function Page(props: {
|
||||
</div>
|
||||
<div className="sticky top-[calc(var(--header-height)+1px)] z-30 ml-auto hidden h-[calc(100svh-var(--footer-height)+2rem)] w-72 flex-col gap-4 overflow-hidden overscroll-none pb-8 xl:flex">
|
||||
<div className="h-(--top-spacing) shrink-0" />
|
||||
{/* @ts-expect-error - revisit fumadocs types. */}
|
||||
{doc.toc?.length ? (
|
||||
<div className="no-scrollbar overflow-y-auto px-8">
|
||||
{/* @ts-expect-error - revisit fumadocs types. */}
|
||||
<DocsTableOfContents toc={doc.toc} />
|
||||
<div className="h-12" />
|
||||
</div>
|
||||
|
||||
@@ -142,13 +142,7 @@ const chartConfig = {
|
||||
|
||||
export function ChartAreaInteractive() {
|
||||
const isMobile = useIsMobile()
|
||||
const [timeRange, setTimeRange] = React.useState("90d")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setTimeRange("7d")
|
||||
}
|
||||
}, [isMobile])
|
||||
const [timeRange, setTimeRange] = React.useState("7d")
|
||||
|
||||
const filteredData = chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
|
||||
@@ -16,8 +16,9 @@ import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
export const dynamic = "force-static"
|
||||
export const revalidate = false
|
||||
|
||||
const title = "Examples"
|
||||
const description = "Check out some examples app built using the components."
|
||||
const title = "The Foundation for your Design System"
|
||||
const description =
|
||||
"A set of beautifully designed components that you can customize, extend, and build on. Start here then make it your own. Open Source. Open Code."
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
@@ -52,24 +53,20 @@ export default function ExamplesLayout({
|
||||
<>
|
||||
<PageHeader>
|
||||
<Announcement />
|
||||
<PageHeaderHeading>Build your Component Library</PageHeaderHeading>
|
||||
<PageHeaderDescription>
|
||||
A set of beautifully-designed, accessible components and a code
|
||||
distribution platform. Works with your favorite frameworks. Open
|
||||
Source. Open Code.
|
||||
</PageHeaderDescription>
|
||||
<PageHeaderHeading className="max-w-4xl">{title}</PageHeaderHeading>
|
||||
<PageHeaderDescription>{description}</PageHeaderDescription>
|
||||
<PageActions>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/docs">Get Started</Link>
|
||||
<Link href="/docs/installation">Get Started</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href="/blocks">Browse Blocks</Link>
|
||||
<Link href="/docs/components">View Components</Link>
|
||||
</Button>
|
||||
</PageActions>
|
||||
</PageHeader>
|
||||
<PageNav id="examples">
|
||||
<PageNav id="examples" className="hidden md:flex">
|
||||
<ExamplesNav className="[&>a:first-child]:text-primary flex-1 overflow-hidden" />
|
||||
<ThemeSelector className="mr-4 hidden md:block" />
|
||||
<ThemeSelector className="mr-4 hidden md:flex" />
|
||||
</PageNav>
|
||||
<div className="container-wrapper section-soft flex flex-1 flex-col pb-6">
|
||||
<div className="theme-container container flex flex-1 scroll-mt-20 flex-col">
|
||||
|
||||
@@ -3,7 +3,10 @@ import { SiteHeader } from "@/components/site-header"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-background relative z-10 flex min-h-svh flex-col">
|
||||
<div
|
||||
data-slot="layout"
|
||||
className="bg-background relative z-10 flex min-h-svh flex-col"
|
||||
>
|
||||
<SiteHeader />
|
||||
<main className="flex flex-1 flex-col">{children}</main>
|
||||
<SiteFooter />
|
||||
|
||||
@@ -3,22 +3,26 @@ import { NextResponse, type NextRequest } from "next/server"
|
||||
|
||||
import { processMdxForLLMs } from "@/lib/llm"
|
||||
import { source } from "@/lib/source"
|
||||
import { getActiveStyle } from "@/registry/styles"
|
||||
|
||||
export const revalidate = false
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
{ params }: { params: Promise<{ slug?: string[] }> }
|
||||
) {
|
||||
const slug = (await params).slug
|
||||
const [{ slug }, activeStyle] = await Promise.all([params, getActiveStyle()])
|
||||
|
||||
const page = source.getPage(slug)
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// @ts-expect-error - revisit fumadocs types.
|
||||
const processedContent = processMdxForLLMs(page.data.content)
|
||||
const processedContent = processMdxForLLMs(
|
||||
await page.data.getText("raw"),
|
||||
activeStyle.name
|
||||
)
|
||||
|
||||
return new NextResponse(processedContent, {
|
||||
headers: {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ItemDemo } from "./components/item-demo"
|
||||
import { KbdDemo } from "./components/kbd-demo"
|
||||
import { LabelDemo } from "./components/label-demo"
|
||||
import { MenubarDemo } from "./components/menubar-demo"
|
||||
import { NativeSelectDemo } from "./components/native-select-demo"
|
||||
import { NavigationMenuDemo } from "./components/navigation-menu-demo"
|
||||
import { PaginationDemo } from "./components/pagination-demo"
|
||||
import { PopoverDemo } from "./components/popover-demo"
|
||||
@@ -279,6 +280,13 @@ export const componentRegistry: Record<string, ComponentConfig> = {
|
||||
type: "registry:ui",
|
||||
href: "/sink/navigation-menu",
|
||||
},
|
||||
"native-select": {
|
||||
name: "Native Select",
|
||||
component: NativeSelectDemo,
|
||||
type: "registry:ui",
|
||||
href: "/sink/native-select",
|
||||
label: "New",
|
||||
},
|
||||
pagination: {
|
||||
name: "Pagination",
|
||||
component: PaginationDemo,
|
||||
|
||||
135
apps/v4/app/(internal)/sink/components/native-select-demo.tsx
Normal file
135
apps/v4/app/(internal)/sink/components/native-select-demo.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOptGroup,
|
||||
NativeSelectOption,
|
||||
} from "@/registry/new-york-v4/ui/native-select"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/registry/new-york-v4/ui/select"
|
||||
|
||||
export function NativeSelectDemo() {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
Basic Select
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<NativeSelect>
|
||||
<NativeSelectOption value="">Select a fruit</NativeSelectOption>
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana">Banana</NativeSelectOption>
|
||||
<NativeSelectOption value="blueberry">Blueberry</NativeSelectOption>
|
||||
<NativeSelectOption value="grapes" disabled>
|
||||
Grapes
|
||||
</NativeSelectOption>
|
||||
<NativeSelectOption value="pineapple">Pineapple</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a fruit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="blueberry">Blueberry</SelectItem>
|
||||
<SelectItem value="grapes" disabled>
|
||||
Grapes
|
||||
</SelectItem>
|
||||
<SelectItem value="pineapple">Pineapple</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
With Groups
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<NativeSelect>
|
||||
<NativeSelectOption value="">Select a food</NativeSelectOption>
|
||||
<NativeSelectOptGroup label="Fruits">
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana">Banana</NativeSelectOption>
|
||||
<NativeSelectOption value="blueberry">
|
||||
Blueberry
|
||||
</NativeSelectOption>
|
||||
</NativeSelectOptGroup>
|
||||
<NativeSelectOptGroup label="Vegetables">
|
||||
<NativeSelectOption value="carrot">Carrot</NativeSelectOption>
|
||||
<NativeSelectOption value="broccoli">Broccoli</NativeSelectOption>
|
||||
<NativeSelectOption value="spinach">Spinach</NativeSelectOption>
|
||||
</NativeSelectOptGroup>
|
||||
</NativeSelect>
|
||||
<Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a food" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Fruits</SelectLabel>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
<SelectItem value="blueberry">Blueberry</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Vegetables</SelectLabel>
|
||||
<SelectItem value="carrot">Carrot</SelectItem>
|
||||
<SelectItem value="broccoli">Broccoli</SelectItem>
|
||||
<SelectItem value="spinach">Spinach</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
Disabled State
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<NativeSelect disabled>
|
||||
<NativeSelectOption value="">Disabled</NativeSelectOption>
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana">Banana</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Select disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Disabled" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
Error State
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<NativeSelect aria-invalid="true">
|
||||
<NativeSelectOption value="">Error state</NativeSelectOption>
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana">Banana</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<Select>
|
||||
<SelectTrigger aria-invalid="true">
|
||||
<SelectValue placeholder="Error state" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="apple">Apple</SelectItem>
|
||||
<SelectItem value="banana">Banana</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react"
|
||||
import {
|
||||
BoldIcon,
|
||||
BookmarkIcon,
|
||||
HeartIcon,
|
||||
ItalicIcon,
|
||||
StarIcon,
|
||||
UnderlineIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
ToggleGroup,
|
||||
@@ -8,7 +15,7 @@ import {
|
||||
export function ToggleGroupDemo() {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start gap-4">
|
||||
<ToggleGroup type="multiple">
|
||||
<ToggleGroup type="multiple" spacing={2}>
|
||||
<ToggleGroupItem value="bold" aria-label="Toggle bold">
|
||||
<BoldIcon />
|
||||
</ToggleGroupItem>
|
||||
@@ -54,12 +61,7 @@ export function ToggleGroupDemo() {
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
defaultValue="last-24-hours"
|
||||
className="*:data-[slot=toggle-group-item]:px-3"
|
||||
>
|
||||
<ToggleGroup type="single" size="sm" defaultValue="last-24-hours">
|
||||
<ToggleGroupItem
|
||||
value="last-24-hours"
|
||||
aria-label="Toggle last 24 hours"
|
||||
@@ -70,6 +72,68 @@ export function ToggleGroupDemo() {
|
||||
Last 7 days
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup type="single" size="sm" defaultValue="top" variant="outline">
|
||||
<ToggleGroupItem value="top" aria-label="Toggle top">
|
||||
Top
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="bottom" aria-label="Toggle bottom">
|
||||
Bottom
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="left" aria-label="Toggle left">
|
||||
Left
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right" aria-label="Toggle right">
|
||||
Right
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
defaultValue="top"
|
||||
variant="outline"
|
||||
spacing={2}
|
||||
>
|
||||
<ToggleGroupItem value="top" aria-label="Toggle top">
|
||||
Top
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="bottom" aria-label="Toggle bottom">
|
||||
Bottom
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="left" aria-label="Toggle left">
|
||||
Left
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="right" aria-label="Toggle right">
|
||||
Right
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<ToggleGroup type="multiple" variant="outline" spacing={2} size="sm">
|
||||
<ToggleGroupItem
|
||||
value="star"
|
||||
aria-label="Toggle star"
|
||||
className="data-[state=on]:bg-transparent data-[state=on]:*:[svg]:fill-yellow-500 data-[state=on]:*:[svg]:stroke-yellow-500"
|
||||
>
|
||||
<StarIcon />
|
||||
Star
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="heart"
|
||||
aria-label="Toggle heart"
|
||||
className="data-[state=on]:bg-transparent data-[state=on]:*:[svg]:fill-red-500 data-[state=on]:*:[svg]:stroke-red-500"
|
||||
>
|
||||
<HeartIcon />
|
||||
Heart
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="bookmark"
|
||||
aria-label="Toggle bookmark"
|
||||
className="data-[state=on]:bg-transparent data-[state=on]:*:[svg]:fill-blue-500 data-[state=on]:*:[svg]:stroke-blue-500"
|
||||
>
|
||||
<BookmarkIcon />
|
||||
Bookmark
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
105
apps/v4/app/(sandbox)/sandbox/[style]/page.tsx
Normal file
105
apps/v4/app/(sandbox)/sandbox/[style]/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Metadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { siteConfig } from "@/lib/config"
|
||||
import { getRegistryComponent, getRegistryItems } from "@/lib/registry"
|
||||
import { absoluteUrl, cn } from "@/lib/utils"
|
||||
import { getStyle, STYLES } from "@/registry/styles"
|
||||
|
||||
export const revalidate = false
|
||||
export const dynamic = "force-static"
|
||||
export const dynamicParams = false
|
||||
|
||||
const allowedTypes = ["registry:example"]
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{
|
||||
style: string
|
||||
}>
|
||||
}): Promise<Metadata> {
|
||||
const { style: styleName } = await params
|
||||
const style = getStyle(styleName)
|
||||
|
||||
if (!style) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const title = style.title
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
type: "article",
|
||||
url: absoluteUrl(`/sandbox/${style.name}`),
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteConfig.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
images: [siteConfig.ogImage],
|
||||
creator: "@shadcn",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return STYLES.map((style) => ({
|
||||
style: style.name,
|
||||
}))
|
||||
}
|
||||
|
||||
export default async function BlockPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{
|
||||
style: string
|
||||
}>
|
||||
}) {
|
||||
const { style: styleName } = await params
|
||||
const style = getStyle(styleName)
|
||||
|
||||
if (!style) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const items = await getRegistryItems(style.name, (item) =>
|
||||
allowedTypes.includes(item.type)
|
||||
)
|
||||
|
||||
if (items.length === 0) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("grid gap-6")}>
|
||||
{items
|
||||
.filter((item) => item !== null)
|
||||
.map((item) => {
|
||||
const Component = getRegistryComponent(item.name, style.name)
|
||||
if (!Component) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
className={cn("bg-background", item.meta?.container)}
|
||||
>
|
||||
<Component />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,39 @@
|
||||
/* eslint-disable react-hooks/static-components */
|
||||
import * as React from "react"
|
||||
import { Metadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
import { registryItemSchema } from "shadcn/schema"
|
||||
import { z } from "zod"
|
||||
|
||||
import { siteConfig } from "@/lib/config"
|
||||
import { getRegistryComponent, getRegistryItem } from "@/lib/registry"
|
||||
import { absoluteUrl, cn } from "@/lib/utils"
|
||||
import { getStyle, STYLES, type Style } from "@/registry/styles"
|
||||
|
||||
export const revalidate = false
|
||||
export const dynamic = "force-static"
|
||||
export const dynamicParams = false
|
||||
|
||||
const getCachedRegistryItem = React.cache(async (name: string) => {
|
||||
return await getRegistryItem(name)
|
||||
})
|
||||
const getCachedRegistryItem = React.cache(
|
||||
async (name: string, styleName: Style["name"]) => {
|
||||
return await getRegistryItem(name, styleName)
|
||||
}
|
||||
)
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{
|
||||
style: string
|
||||
name: string
|
||||
}>
|
||||
}): Promise<Metadata> {
|
||||
const { name } = await params
|
||||
const item = await getCachedRegistryItem(name)
|
||||
const { style: styleName, name } = await params
|
||||
const style = getStyle(styleName)
|
||||
|
||||
if (!style) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const item = await getCachedRegistryItem(name, style.name)
|
||||
|
||||
if (!item) {
|
||||
return {}
|
||||
@@ -34,13 +43,13 @@ export async function generateMetadata({
|
||||
const description = item.description
|
||||
|
||||
return {
|
||||
title: item.description,
|
||||
title: item.name,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: "article",
|
||||
url: absoluteUrl(`/view/${item.name}`),
|
||||
url: absoluteUrl(`/view/${style.name}/${item.name}`),
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.ogImage,
|
||||
@@ -62,32 +71,52 @@ export async function generateMetadata({
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const { Index } = await import("@/registry/__index__")
|
||||
const index = z.record(registryItemSchema).parse(Index)
|
||||
const params: Array<{ style: string; name: string }> = []
|
||||
|
||||
return Object.values(index)
|
||||
.filter((block) =>
|
||||
[
|
||||
"registry:block",
|
||||
"registry:component",
|
||||
"registry:example",
|
||||
"registry:internal",
|
||||
].includes(block.type)
|
||||
)
|
||||
.map((block) => ({
|
||||
name: block.name,
|
||||
}))
|
||||
for (const style of STYLES) {
|
||||
if (!Index[style.name]) {
|
||||
continue
|
||||
}
|
||||
|
||||
const styleIndex = Index[style.name]
|
||||
for (const itemName in styleIndex) {
|
||||
const item = styleIndex[itemName]
|
||||
if (
|
||||
[
|
||||
"registry:block",
|
||||
"registry:component",
|
||||
"registry:example",
|
||||
"registry:internal",
|
||||
].includes(item.type)
|
||||
) {
|
||||
params.push({
|
||||
style: style.name,
|
||||
name: item.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
export default async function BlockPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{
|
||||
style: string
|
||||
name: string
|
||||
}>
|
||||
}) {
|
||||
const { name } = await params
|
||||
const item = await getCachedRegistryItem(name)
|
||||
const Component = getRegistryComponent(name)
|
||||
const { style: styleName, name } = await params
|
||||
const style = getStyle(styleName)
|
||||
|
||||
if (!style) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const item = await getCachedRegistryItem(name, style.name)
|
||||
const Component = getRegistryComponent(name, style.name)
|
||||
|
||||
if (!item || !Component) {
|
||||
return notFound()
|
||||
5
apps/v4/app/api/search/route.ts
Normal file
5
apps/v4/app/api/search/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createFromSource } from "fumadocs-core/search/server"
|
||||
|
||||
import { source } from "@/lib/source"
|
||||
|
||||
export const { GET } = createFromSource(source)
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next"
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app"
|
||||
|
||||
import { META_THEME_COLORS, siteConfig } from "@/lib/config"
|
||||
import { fontVariables } from "@/lib/fonts"
|
||||
@@ -84,18 +85,20 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
"text-foreground group/body theme-blue overscroll-none font-sans antialiased [--footer-height:calc(var(--spacing)*14)] [--header-height:calc(var(--spacing)*14)] xl:[--footer-height:calc(var(--spacing)*24)]",
|
||||
"group/body overscroll-none antialiased [--footer-height:calc(var(--spacing)*14)] [--header-height:calc(var(--spacing)*14)] xl:[--footer-height:calc(var(--spacing)*24)]",
|
||||
fontVariables
|
||||
)}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<LayoutProvider>
|
||||
<ActiveThemeProvider initialTheme="blue">
|
||||
{children}
|
||||
<TailwindIndicator />
|
||||
<Toaster position="top-center" />
|
||||
<Analytics />
|
||||
</ActiveThemeProvider>
|
||||
<NuqsAdapter>
|
||||
<ActiveThemeProvider>
|
||||
{children}
|
||||
<TailwindIndicator />
|
||||
<Toaster position="top-center" />
|
||||
<Analytics />
|
||||
</ActiveThemeProvider>
|
||||
</NuqsAdapter>
|
||||
</LayoutProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
const DEFAULT_THEME = "blue"
|
||||
const DEFAULT_THEME = "default"
|
||||
|
||||
type ThemeContextType = {
|
||||
activeTheme: string
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||
|
||||
export function Announcement() {
|
||||
return (
|
||||
<Badge asChild variant="secondary" className="rounded-full">
|
||||
<Badge asChild variant="secondary" className="bg-transparent">
|
||||
<Link href="/docs/changelog">
|
||||
<span className="flex size-2 rounded-full bg-blue-500" title="New" />
|
||||
New Components: Field, Input Group, Item and more <ArrowRightIcon />
|
||||
|
||||
@@ -10,9 +10,16 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BlockViewer } from "@/components/block-viewer"
|
||||
import { ComponentPreview } from "@/components/component-preview"
|
||||
import { type Style } from "@/registry/styles"
|
||||
|
||||
export async function BlockDisplay({ name }: { name: string }) {
|
||||
const item = await getCachedRegistryItem(name)
|
||||
export async function BlockDisplay({
|
||||
name,
|
||||
styleName,
|
||||
}: {
|
||||
name: string
|
||||
styleName: Style["name"]
|
||||
}) {
|
||||
const item = await getCachedRegistryItem(name, styleName)
|
||||
|
||||
if (!item?.files) {
|
||||
return null
|
||||
@@ -24,9 +31,15 @@ export async function BlockDisplay({ name }: { name: string }) {
|
||||
])
|
||||
|
||||
return (
|
||||
<BlockViewer item={item} tree={tree} highlightedFiles={highlightedFiles}>
|
||||
<BlockViewer
|
||||
item={item}
|
||||
tree={tree}
|
||||
highlightedFiles={highlightedFiles}
|
||||
styleName={styleName}
|
||||
>
|
||||
<ComponentPreview
|
||||
name={item.name}
|
||||
styleName={styleName}
|
||||
hideCode
|
||||
className={cn(
|
||||
"my-0 **:[.preview]:h-auto **:[.preview]:p-4 **:[.preview>.p-6]:p-0",
|
||||
@@ -37,9 +50,11 @@ export async function BlockDisplay({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
const getCachedRegistryItem = React.cache(async (name: string) => {
|
||||
return await getRegistryItem(name)
|
||||
})
|
||||
const getCachedRegistryItem = React.cache(
|
||||
async (name: string, styleName: Style["name"]) => {
|
||||
return await getRegistryItem(name, styleName)
|
||||
}
|
||||
)
|
||||
|
||||
const getCachedFileTree = React.cache(
|
||||
async (files: Array<{ path: string; target?: string }>) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@/registry/new-york-v4/ui/toggle-group"
|
||||
import { type Style } from "@/registry/styles"
|
||||
|
||||
type BlockViewerContext = {
|
||||
item: z.infer<typeof registryItemSchema>
|
||||
@@ -128,7 +129,15 @@ function BlockViewerProvider({
|
||||
)
|
||||
}
|
||||
|
||||
function BlockViewerToolbar() {
|
||||
type BlockViewerProps = Pick<
|
||||
BlockViewerContext,
|
||||
"item" | "tree" | "highlightedFiles"
|
||||
> & {
|
||||
children: React.ReactNode
|
||||
styleName: Style["name"]
|
||||
}
|
||||
|
||||
function BlockViewerToolbar({ styleName }: { styleName: Style["name"] }) {
|
||||
const { setView, view, item, resizablePanelRef, setIframeKey } =
|
||||
useBlockViewer()
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard()
|
||||
@@ -181,7 +190,7 @@ function BlockViewerToolbar() {
|
||||
asChild
|
||||
title="Open in New Tab"
|
||||
>
|
||||
<Link href={`/view/${item.name}`} target="_blank">
|
||||
<Link href={`/view/${styleName}/${item.name}`} target="_blank">
|
||||
<span className="sr-only">Open in New Tab</span>
|
||||
<Fullscreen />
|
||||
</Link>
|
||||
@@ -222,13 +231,19 @@ function BlockViewerToolbar() {
|
||||
)
|
||||
}
|
||||
|
||||
function BlockViewerIframe({ className }: { className?: string }) {
|
||||
function BlockViewerIframe({
|
||||
className,
|
||||
styleName,
|
||||
}: {
|
||||
className?: string
|
||||
styleName: Style["name"]
|
||||
}) {
|
||||
const { item, iframeKey } = useBlockViewer()
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
src={`/view/${item.name}`}
|
||||
src={`/view/${styleName}/${item.name}`}
|
||||
height={item.meta?.iframeHeight ?? 930}
|
||||
loading="lazy"
|
||||
className={cn(
|
||||
@@ -239,7 +254,7 @@ function BlockViewerIframe({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BlockViewerView() {
|
||||
function BlockViewerView({ styleName }: { styleName: Style["name"] }) {
|
||||
const { resizablePanelRef } = useBlockViewer()
|
||||
|
||||
return (
|
||||
@@ -256,7 +271,7 @@ function BlockViewerView() {
|
||||
defaultSize={100}
|
||||
minSize={30}
|
||||
>
|
||||
<BlockViewerIframe />
|
||||
<BlockViewerIframe styleName={styleName} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="after:bg-border relative hidden w-3 bg-transparent p-0 after:absolute after:top-1/2 after:right-0 after:h-8 after:w-[6px] after:translate-x-[-1px] after:-translate-y-1/2 after:rounded-full after:transition-all after:hover:h-10 md:block" />
|
||||
<ResizablePanel defaultSize={0} minSize={0} />
|
||||
@@ -471,10 +486,9 @@ function BlockViewer({
|
||||
tree,
|
||||
highlightedFiles,
|
||||
children,
|
||||
styleName,
|
||||
...props
|
||||
}: Pick<BlockViewerContext, "item" | "tree" | "highlightedFiles"> & {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
}: BlockViewerProps) {
|
||||
return (
|
||||
<BlockViewerProvider
|
||||
item={item}
|
||||
@@ -482,8 +496,8 @@ function BlockViewer({
|
||||
highlightedFiles={highlightedFiles}
|
||||
{...props}
|
||||
>
|
||||
<BlockViewerToolbar />
|
||||
<BlockViewerView />
|
||||
<BlockViewerToolbar styleName={styleName} />
|
||||
<BlockViewerView styleName={styleName} />
|
||||
<BlockViewerCode />
|
||||
<BlockViewerMobile>{children}</BlockViewerMobile>
|
||||
</BlockViewerProvider>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { registryCategories } from "@/lib/categories"
|
||||
import { ScrollArea, ScrollBar } from "@/registry/new-york-v4/ui/scroll-area"
|
||||
import { registryCategories } from "@/registry/registry-categories"
|
||||
|
||||
export function BlocksNav() {
|
||||
const pathname = usePathname()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react"
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
|
||||
import { Event, trackEvent } from "@/lib/events"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -54,7 +54,7 @@ export function ChartCopyButton({
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <CheckIcon /> : <ClipboardIcon />}
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-black text-white">Copy code</TooltipContent>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { highlightCode } from "@/lib/highlight-code"
|
||||
import { getRegistryItem } from "@/lib/registry"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChartToolbar } from "@/components/chart-toolbar"
|
||||
import { type Style } from "@/registry/styles"
|
||||
|
||||
export type Chart = z.infer<typeof registryItemSchema> & {
|
||||
highlightedCode: string
|
||||
@@ -13,10 +14,14 @@ export type Chart = z.infer<typeof registryItemSchema> & {
|
||||
|
||||
export async function ChartDisplay({
|
||||
name,
|
||||
styleName,
|
||||
children,
|
||||
className,
|
||||
}: { name: string } & React.ComponentProps<"div">) {
|
||||
const chart = await getCachedRegistryItem(name)
|
||||
}: {
|
||||
name: string
|
||||
styleName: Style["name"]
|
||||
} & React.ComponentProps<"div">) {
|
||||
const chart = await getCachedRegistryItem(name, styleName)
|
||||
const highlightedCode = await getChartHighlightedCode(
|
||||
chart?.files?.[0]?.content ?? ""
|
||||
)
|
||||
@@ -45,9 +50,11 @@ export async function ChartDisplay({
|
||||
)
|
||||
}
|
||||
|
||||
const getCachedRegistryItem = React.cache(async (name: string) => {
|
||||
return await getRegistryItem(name)
|
||||
})
|
||||
const getCachedRegistryItem = React.cache(
|
||||
async (name: string, styleName: Style["name"]) => {
|
||||
return await getRegistryItem(name, styleName)
|
||||
}
|
||||
)
|
||||
|
||||
const getChartHighlightedCode = React.cache(async (content: string) => {
|
||||
return await highlightCode(content)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ClipboardIcon, TerminalIcon } from "lucide-react"
|
||||
import { IconCheck, IconCopy, IconTerminal } from "@tabler/icons-react"
|
||||
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
@@ -80,7 +80,7 @@ export function CodeBlockCommand({
|
||||
>
|
||||
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-1">
|
||||
<div className="bg-foreground flex size-4 items-center justify-center rounded-[1px] opacity-70">
|
||||
<TerminalIcon className="text-code size-3" />
|
||||
<IconTerminal className="text-code size-3" />
|
||||
</div>
|
||||
<TabsList className="rounded-none bg-transparent p-0">
|
||||
{Object.entries(tabs).map(([key]) => {
|
||||
@@ -123,7 +123,7 @@ export function CodeBlockCommand({
|
||||
onClick={copyCommand}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <CheckIcon /> : <ClipboardIcon />}
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@@ -4,14 +4,15 @@ import * as React from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { IconArrowRight } from "@tabler/icons-react"
|
||||
import { useDocsSearch } from "fumadocs-core/search/client"
|
||||
import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react"
|
||||
|
||||
import { type Color, type ColorPalette } from "@/lib/colors"
|
||||
import { trackEvent } from "@/lib/events"
|
||||
import { showMcpDocs } from "@/lib/flags"
|
||||
import { source } from "@/lib/source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useConfig } from "@/hooks/use-config"
|
||||
import { useIsMac } from "@/hooks/use-is-mac"
|
||||
import { useMutationObserver } from "@/hooks/use-mutation-observer"
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from "@/registry/new-york-v4/ui/dialog"
|
||||
import { Kbd, KbdGroup } from "@/registry/new-york-v4/ui/kbd"
|
||||
import { Separator } from "@/registry/new-york-v4/ui/separator"
|
||||
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
|
||||
|
||||
export function CommandMenu({
|
||||
tree,
|
||||
@@ -47,15 +49,63 @@ export function CommandMenu({
|
||||
navItems?: { href: string; label: string }[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const isMac = useIsMac()
|
||||
const [config] = useConfig()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [selectedType, setSelectedType] = React.useState<
|
||||
"color" | "page" | "component" | "block" | null
|
||||
>(null)
|
||||
const [copyPayload, setCopyPayload] = React.useState("")
|
||||
|
||||
const { search, setSearch, query } = useDocsSearch({
|
||||
type: "fetch",
|
||||
})
|
||||
const packageManager = config.packageManager || "pnpm"
|
||||
|
||||
// Track search queries with debouncing to avoid excessive tracking.
|
||||
const searchTimeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined)
|
||||
const lastTrackedQueryRef = React.useRef<string>("")
|
||||
|
||||
const trackSearchQuery = React.useCallback((query: string) => {
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
// Only track if the query is different from the last tracked query and has content.
|
||||
if (trimmedQuery && trimmedQuery !== lastTrackedQueryRef.current) {
|
||||
lastTrackedQueryRef.current = trimmedQuery
|
||||
trackEvent({
|
||||
name: "search_query",
|
||||
properties: {
|
||||
query: trimmedQuery,
|
||||
query_length: trimmedQuery.length,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSearchChange = React.useCallback(
|
||||
(value: string) => {
|
||||
// Clear existing timeout.
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Set new timeout to debounce both search and tracking.
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
setSearch(value)
|
||||
trackSearchQuery(value)
|
||||
}, 500)
|
||||
},
|
||||
[setSearch, trackSearchQuery]
|
||||
)
|
||||
|
||||
// Cleanup timeout on unmount.
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePageHighlight = React.useCallback(
|
||||
(isComponent: boolean, item: { url: string; name?: React.ReactNode }) => {
|
||||
if (isComponent) {
|
||||
@@ -154,7 +204,7 @@ export function CommandMenu({
|
||||
<span className="inline-flex lg:hidden">Search...</span>
|
||||
<div className="absolute top-1.5 right-1.5 hidden gap-1 sm:flex">
|
||||
<KbdGroup>
|
||||
<Kbd className="border">{isMac ? "⌘" : "Ctrl"}</Kbd>
|
||||
<Kbd className="border">⌘</Kbd>
|
||||
<Kbd className="border">K</Kbd>
|
||||
</KbdGroup>
|
||||
</div>
|
||||
@@ -171,6 +221,7 @@ export function CommandMenu({
|
||||
<Command
|
||||
className="**:data-[slot=command-input-wrapper]:bg-input/50 **:data-[slot=command-input-wrapper]:border-input rounded-none bg-transparent **:data-[slot=command-input]:!h-9 **:data-[slot=command-input]:py-0 **:data-[slot=command-input-wrapper]:mb-0 **:data-[slot=command-input-wrapper]:!h-9 **:data-[slot=command-input-wrapper]:rounded-md **:data-[slot=command-input-wrapper]:border"
|
||||
filter={(value, search, keywords) => {
|
||||
handleSearchChange(search)
|
||||
const extendValue = value + " " + (keywords?.join(" ") || "")
|
||||
if (extendValue.toLowerCase().includes(search.toLowerCase())) {
|
||||
return 1
|
||||
@@ -178,10 +229,17 @@ export function CommandMenu({
|
||||
return 0
|
||||
}}
|
||||
>
|
||||
<CommandInput placeholder="Search documentation..." />
|
||||
<div className="relative">
|
||||
<CommandInput placeholder="Search documentation..." />
|
||||
{query.isLoading && (
|
||||
<div className="pointer-events-none absolute top-1/2 right-3 z-10 flex -translate-y-1/2 items-center justify-center">
|
||||
<Spinner className="text-muted-foreground size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommandList className="no-scrollbar min-h-80 scroll-pt-2 scroll-pb-1.5">
|
||||
<CommandEmpty className="text-muted-foreground py-12 text-center text-sm">
|
||||
No results found.
|
||||
{query.isLoading ? "Searching..." : "No results found."}
|
||||
</CommandEmpty>
|
||||
{navItems && navItems.length > 0 && (
|
||||
<CommandGroup
|
||||
@@ -322,6 +380,12 @@ export function CommandMenu({
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
<SearchResults
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
query={query}
|
||||
search={search}
|
||||
/>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<div className="text-muted-foreground absolute inset-x-0 bottom-0 z-20 flex h-10 items-center gap-2 rounded-b-xl border-t border-t-neutral-100 bg-neutral-50 px-4 text-xs font-medium dark:border-t-neutral-700 dark:bg-neutral-800">
|
||||
@@ -338,7 +402,7 @@ export function CommandMenu({
|
||||
<>
|
||||
<Separator orientation="vertical" className="!h-4" />
|
||||
<div className="flex items-center gap-1">
|
||||
<CommandMenuKbd>{isMac ? "⌘" : "Ctrl"}</CommandMenuKbd>
|
||||
<CommandMenuKbd>⌘</CommandMenuKbd>
|
||||
<CommandMenuKbd>C</CommandMenuKbd>
|
||||
{copyPayload}
|
||||
</div>
|
||||
@@ -399,3 +463,66 @@ function CommandMenuKbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type Query = Awaited<ReturnType<typeof useDocsSearch>>["query"]
|
||||
|
||||
function SearchResults({
|
||||
setOpen,
|
||||
query,
|
||||
search,
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
query: Query
|
||||
search: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
|
||||
const uniqueResults =
|
||||
query.data && Array.isArray(query.data)
|
||||
? query.data.filter(
|
||||
(item, index, self) =>
|
||||
!(
|
||||
item.type === "text" &&
|
||||
item.content.trim().split(/\s+/).length <= 1
|
||||
) && index === self.findIndex((t) => t.content === item.content)
|
||||
)
|
||||
: []
|
||||
|
||||
if (!search.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!query.data || query.data === "empty") {
|
||||
return null
|
||||
}
|
||||
|
||||
if (query.data && uniqueResults.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandGroup
|
||||
className="!px-0 [&_[cmdk-group-heading]]:scroll-mt-16 [&_[cmdk-group-heading]]:!p-3 [&_[cmdk-group-heading]]:!pb-1"
|
||||
heading="Search Results"
|
||||
>
|
||||
{uniqueResults.map((item) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
data-type={item.type}
|
||||
onSelect={() => {
|
||||
router.push(item.url)
|
||||
setOpen(false)
|
||||
}}
|
||||
className="data-[selected=true]:border-input data-[selected=true]:bg-input/50 h-9 rounded-md border border-transparent !px-3 font-normal"
|
||||
keywords={[item.content]}
|
||||
value={`${item.content} ${item.type}`}
|
||||
>
|
||||
<div className="line-clamp-1 text-sm">{item.content}</div>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,77 +3,48 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/registry/new-york-v4/ui/tabs"
|
||||
|
||||
export function ComponentPreviewTabs({
|
||||
className,
|
||||
align = "center",
|
||||
hideCode = false,
|
||||
chromeLessOnMobile = false,
|
||||
component,
|
||||
source,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
align?: "center" | "start" | "end"
|
||||
hideCode?: boolean
|
||||
chromeLessOnMobile?: boolean
|
||||
component: React.ReactNode
|
||||
source: React.ReactNode
|
||||
}) {
|
||||
const [tab, setTab] = React.useState("preview")
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("group relative mt-4 mb-12 flex flex-col gap-2", className)}
|
||||
className={cn(
|
||||
"group relative mt-4 mb-12 flex flex-col gap-2 rounded-lg border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Tabs
|
||||
className="relative mr-auto w-full"
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{!hideCode && (
|
||||
<TabsList className="justify-start gap-4 rounded-none bg-transparent px-2 md:px-0">
|
||||
<TabsTrigger
|
||||
value="preview"
|
||||
className="text-muted-foreground data-[state=active]:text-foreground px-0 text-base data-[state=active]:shadow-none dark:data-[state=active]:border-transparent dark:data-[state=active]:bg-transparent"
|
||||
>
|
||||
Preview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="code"
|
||||
className="text-muted-foreground data-[state=active]:text-foreground px-0 text-base data-[state=active]:shadow-none dark:data-[state=active]:border-transparent dark:data-[state=active]:bg-transparent"
|
||||
>
|
||||
Code
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div data-slot="preview">
|
||||
<div
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"preview flex w-full justify-center data-[align=center]:items-center data-[align=end]:items-end data-[align=start]:items-start",
|
||||
chromeLessOnMobile ? "sm:p-10" : "h-[450px] p-10"
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
<div
|
||||
data-tab={tab}
|
||||
className="data-[tab=code]:border-code relative rounded-lg border md:-mx-1"
|
||||
>
|
||||
<div
|
||||
data-slot="preview"
|
||||
data-active={tab === "preview"}
|
||||
className="invisible data-[active=true]:visible"
|
||||
>
|
||||
{component}
|
||||
</div>
|
||||
{!hideCode && (
|
||||
<div
|
||||
data-align={align}
|
||||
className={cn(
|
||||
"preview flex h-[450px] w-full justify-center p-10 data-[align=center]:items-center data-[align=end]:items-end data-[align=start]:items-start"
|
||||
)}
|
||||
data-slot="code"
|
||||
className="overflow-hidden [&_[data-rehype-pretty-code-figure]]:!m-0 [&_[data-rehype-pretty-code-figure]]:rounded-t-none [&_[data-rehype-pretty-code-figure]]:border-t [&_pre]:max-h-[400px]"
|
||||
>
|
||||
{component}
|
||||
{source}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="code"
|
||||
data-active={tab === "code"}
|
||||
className="absolute inset-0 hidden overflow-hidden data-[active=true]:block **:[figure]:!m-0 **:[pre]:h-[450px]"
|
||||
>
|
||||
{source}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,26 +3,31 @@ import Image from "next/image"
|
||||
import { ComponentPreviewTabs } from "@/components/component-preview-tabs"
|
||||
import { ComponentSource } from "@/components/component-source"
|
||||
import { Index } from "@/registry/__index__"
|
||||
import { type Style } from "@/registry/styles"
|
||||
|
||||
export function ComponentPreview({
|
||||
name,
|
||||
styleName = "new-york-v4",
|
||||
type,
|
||||
className,
|
||||
align = "center",
|
||||
hideCode = false,
|
||||
chromeLessOnMobile = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
name: string
|
||||
styleName?: Style["name"]
|
||||
align?: "center" | "start" | "end"
|
||||
description?: string
|
||||
hideCode?: boolean
|
||||
type?: "block" | "component" | "example"
|
||||
chromeLessOnMobile?: boolean
|
||||
}) {
|
||||
const Component = Index[name]?.component
|
||||
const Component = Index[styleName]?.[name]?.component
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground mt-6 text-sm">
|
||||
Component{" "}
|
||||
<code className="bg-muted relative rounded px-[0.3rem] py-[0.2rem] font-mono text-sm">
|
||||
{name}
|
||||
@@ -50,7 +55,7 @@ export function ComponentPreview({
|
||||
className="bg-background absolute top-0 left-0 z-20 hidden w-[970px] max-w-none sm:w-[1280px] md:hidden dark:block md:dark:hidden"
|
||||
/>
|
||||
<div className="bg-background absolute inset-0 hidden w-[1600px] md:block">
|
||||
<iframe src={`/view/${name}`} className="size-full" />
|
||||
<iframe src={`/view/${styleName}/${name}`} className="size-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -62,7 +67,14 @@ export function ComponentPreview({
|
||||
align={align}
|
||||
hideCode={hideCode}
|
||||
component={<Component />}
|
||||
source={<ComponentSource name={name} collapsible={false} />}
|
||||
source={
|
||||
<ComponentSource
|
||||
name={name}
|
||||
collapsible={false}
|
||||
styleName={styleName}
|
||||
/>
|
||||
}
|
||||
chromeLessOnMobile={chromeLessOnMobile}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"
|
||||
import { CodeCollapsibleWrapper } from "@/components/code-collapsible-wrapper"
|
||||
import { CopyButton } from "@/components/copy-button"
|
||||
import { getIconForLanguageExtension } from "@/components/icons"
|
||||
import { type Style } from "@/registry/styles"
|
||||
|
||||
export async function ComponentSource({
|
||||
name,
|
||||
@@ -16,12 +17,14 @@ export async function ComponentSource({
|
||||
language,
|
||||
collapsible = true,
|
||||
className,
|
||||
styleName = "new-york-v4",
|
||||
}: React.ComponentProps<"div"> & {
|
||||
name?: string
|
||||
src?: string
|
||||
title?: string
|
||||
language?: string
|
||||
collapsible?: boolean
|
||||
styleName?: Style["name"]
|
||||
}) {
|
||||
if (!name && !src) {
|
||||
return null
|
||||
@@ -30,7 +33,7 @@ export async function ComponentSource({
|
||||
let code: string | undefined
|
||||
|
||||
if (name) {
|
||||
const item = await getRegistryItem(name)
|
||||
const item = await getRegistryItem(name, styleName)
|
||||
code = item?.files?.[0]?.content
|
||||
}
|
||||
|
||||
@@ -43,6 +46,14 @@ export async function ComponentSource({
|
||||
return null
|
||||
}
|
||||
|
||||
// Fix imports.
|
||||
// Replace @/registry/${style}/ with @/components/.
|
||||
code = code.replaceAll(`@/registry/${styleName}/`, "@/components/")
|
||||
|
||||
// Replace export default with export.
|
||||
code = code.replaceAll("export default", "export")
|
||||
code = code.replaceAll("/* eslint-disable react/no-children-prop */\n", "")
|
||||
|
||||
const lang = language ?? title?.split(".").pop() ?? "tsx"
|
||||
const highlightedCode = await highlightCode(code, lang)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react"
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
|
||||
import { Event, trackEvent } from "@/lib/events"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -24,11 +24,13 @@ export function CopyButton({
|
||||
className,
|
||||
variant = "ghost",
|
||||
event,
|
||||
tooltip = "Copy to Clipboard",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & {
|
||||
value: string
|
||||
src?: string
|
||||
event?: Event["name"]
|
||||
tooltip?: string
|
||||
}) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
|
||||
@@ -43,6 +45,7 @@ export function CopyButton({
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-slot="copy-button"
|
||||
data-copied={hasCopied}
|
||||
size="icon"
|
||||
variant={variant}
|
||||
className={cn(
|
||||
@@ -66,12 +69,10 @@ export function CopyButton({
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <CheckIcon /> : <ClipboardIcon />}
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasCopied ? "Copied" : "Copy to Clipboard"}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{hasCopied ? "Copied" : tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
157
apps/v4/components/directory-add-button.tsx
Normal file
157
apps/v4/components/directory-add-button.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { IconCheck } from "@tabler/icons-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { CopyButton } from "@/components/copy-button"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/registry/new-york-v4/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/registry/new-york-v4/ui/drawer"
|
||||
|
||||
export function DirectoryAddButton({
|
||||
registry,
|
||||
}: {
|
||||
registry: {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
}) {
|
||||
const { copyToClipboard, isCopied } = useCopyToClipboard()
|
||||
const isMobile = useIsMobile()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const jsonValue = `{
|
||||
"registries": {
|
||||
"${registry.name}": "${registry.url}"
|
||||
}
|
||||
}`
|
||||
|
||||
const Trigger = (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="relative z-10"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<IconCheck />
|
||||
) : (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Model Context Protocol</title>
|
||||
<path
|
||||
d="M13.85 0a4.16 4.16 0 0 0-2.95 1.217L1.456 10.66a.835.835 0 0 0 0 1.18.835.835 0 0 0 1.18 0l9.442-9.442a2.49 2.49 0 0 1 3.541 0 2.49 2.49 0 0 1 0 3.541L8.59 12.97l-.1.1a.835.835 0 0 0 0 1.18.835.835 0 0 0 1.18 0l.1-.098 7.03-7.034a2.49 2.49 0 0 1 3.542 0l.049.05a2.49 2.49 0 0 1 0 3.54l-8.54 8.54a1.96 1.96 0 0 0 0 2.755l1.753 1.753a.835.835 0 0 0 1.18 0 .835.835 0 0 0 0-1.18l-1.753-1.753a.266.266 0 0 1 0-.394l8.54-8.54a4.185 4.185 0 0 0 0-5.9l-.05-.05a4.16 4.16 0 0 0-2.95-1.218c-.2 0-.401.02-.6.048a4.17 4.17 0 0 0-1.17-3.552A4.16 4.16 0 0 0 13.85 0m0 3.333a.84.84 0 0 0-.59.245L6.275 10.56a4.186 4.186 0 0 0 0 5.902 4.186 4.186 0 0 0 5.902 0L19.16 9.48a.835.835 0 0 0 0-1.18.835.835 0 0 0-1.18 0l-6.985 6.984a2.49 2.49 0 0 1-3.54 0 2.49 2.49 0 0 1 0-3.54l6.983-6.985a.835.835 0 0 0 0-1.18.84.84 0 0 0-.59-.245"
|
||||
className="fill-foreground"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
MCP
|
||||
</Button>
|
||||
)
|
||||
|
||||
const Content = (
|
||||
<>
|
||||
<figure
|
||||
data-rehype-pretty-code-figure
|
||||
className={cn(
|
||||
"group relative mt-0",
|
||||
!isMobile &&
|
||||
"dark:bg-background dark:[&_[data-line]:not([data-highlighted-line]):before]:bg-background!"
|
||||
)}
|
||||
>
|
||||
<CopyButton
|
||||
value={jsonValue}
|
||||
className="top-3 right-2"
|
||||
tooltip="Copy Code"
|
||||
/>
|
||||
<div data-rehype-pretty-code-title>components.json</div>
|
||||
<pre className="no-scrollbar min-w-0 overflow-x-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0">
|
||||
<code data-line-numbers data-language="json">
|
||||
<span data-line>{"{"}</span>
|
||||
<span data-line>{' "registries": {'}</span>
|
||||
<span
|
||||
data-line
|
||||
data-highlighted-line
|
||||
>{` "${registry.name}": "${registry.url}"`}</span>
|
||||
<span data-line>{" }"}</span>
|
||||
<span data-line>{"}"}</span>
|
||||
</code>
|
||||
</pre>
|
||||
</figure>
|
||||
</>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Configure MCP</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Copy and paste the following code into your project's
|
||||
components.json.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-6">{Content}</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button size="sm">Close</Button>
|
||||
</DrawerClose>
|
||||
<Button size="sm" asChild variant="outline">
|
||||
<Link href="/docs/mcp">Read the docs</Link>
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{Trigger}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="rounded-xl border-none bg-clip-padding shadow-2xl ring-4 ring-neutral-200/80 sm:max-w-[600px] dark:bg-neutral-900 dark:ring-neutral-800"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure MCP</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy and paste the following code into your project's
|
||||
components.json.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{Content}
|
||||
<DialogFooter className="justify-between!">
|
||||
<Button size="sm" asChild variant="ghost">
|
||||
<Link href="/docs/mcp">Read the docs</Link>
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
90
apps/v4/components/directory-list.tsx
Normal file
90
apps/v4/components/directory-list.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconArrowUpRight } from "@tabler/icons-react"
|
||||
|
||||
import { useSearchRegistry } from "@/hooks/use-search-registry"
|
||||
import { DirectoryAddButton } from "@/components/directory-add-button"
|
||||
import globalRegistries from "@/registry/directory.json"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
} from "@/registry/new-york-v4/ui/item"
|
||||
|
||||
import { SearchDirectory } from "./search-directory"
|
||||
|
||||
function getHomepageUrl(homepage: string) {
|
||||
const url = new URL(homepage)
|
||||
url.searchParams.set("utm_source", "ui.shadcn.com")
|
||||
url.searchParams.set("utm_medium", "referral")
|
||||
url.searchParams.set("utm_campaign", "directory")
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function DirectoryList() {
|
||||
const { registries } = useSearchRegistry()
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<SearchDirectory />
|
||||
<ItemGroup className="my-8">
|
||||
{registries.map((registry, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Item className="group/item relative gap-6 px-0">
|
||||
<ItemMedia
|
||||
variant="image"
|
||||
dangerouslySetInnerHTML={{ __html: registry.logo }}
|
||||
className="*:[svg]:fill-foreground grayscale *:[svg]:size-8"
|
||||
/>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<a
|
||||
href={getHomepageUrl(registry.homepage)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external"
|
||||
>
|
||||
{registry.name}
|
||||
</a>
|
||||
</ItemTitle>
|
||||
{registry.description && (
|
||||
<ItemDescription className="text-pretty">
|
||||
{registry.description}
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions className="relative z-10 hidden self-start sm:flex">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a
|
||||
href={getHomepageUrl(registry.homepage)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer external"
|
||||
>
|
||||
View <IconArrowUpRight />
|
||||
</a>
|
||||
</Button>
|
||||
<DirectoryAddButton registry={registry} />
|
||||
</ItemActions>
|
||||
<ItemFooter className="justify-start pl-16 sm:hidden">
|
||||
<Button size="sm" variant="outline">
|
||||
View <IconArrowUpRight />
|
||||
</Button>
|
||||
<DirectoryAddButton registry={registry} />
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
{index < globalRegistries.length - 1 && (
|
||||
<ItemSeparator className="my-1" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Fragment } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useBreadcrumb } from "fumadocs-core/breadcrumb"
|
||||
import type { PageTree } from "fumadocs-core/server"
|
||||
import type { Root } from "fumadocs-core/page-tree"
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -19,7 +19,7 @@ export function DocsBreadcrumb({
|
||||
tree,
|
||||
className,
|
||||
}: {
|
||||
tree: PageTree.Root
|
||||
tree: Root
|
||||
className?: string
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
|
||||
@@ -24,13 +24,17 @@ const TOP_LEVEL_SECTIONS = [
|
||||
href: "/docs/components",
|
||||
},
|
||||
{
|
||||
name: "Registry",
|
||||
href: "/docs/registry",
|
||||
name: "Directory",
|
||||
href: "/docs/directory",
|
||||
},
|
||||
{
|
||||
name: "MCP Server",
|
||||
href: "/docs/mcp",
|
||||
},
|
||||
{
|
||||
name: "Forms",
|
||||
href: "/docs/forms",
|
||||
},
|
||||
{
|
||||
name: "Changelog",
|
||||
href: "/docs/changelog",
|
||||
@@ -47,12 +51,12 @@ export function DocsSidebar({
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className="sticky top-[calc(var(--header-height)+1px)] z-30 hidden h-[calc(100svh-var(--footer-height)+2rem)] bg-transparent lg:flex"
|
||||
className="sticky top-[calc(var(--header-height)+1px)] z-30 hidden h-[calc(100svh-var(--footer-height)-4rem)] overscroll-none bg-transparent lg:flex"
|
||||
collapsible="none"
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent className="no-scrollbar overflow-x-hidden px-2 pb-12">
|
||||
<div className="h-(--top-spacing) shrink-0" />
|
||||
<SidebarContent className="no-scrollbar overflow-x-hidden px-2">
|
||||
<div className="from-background via-background/80 to-background/50 sticky -top-1 z-10 h-8 shrink-0 bg-gradient-to-b blur-xs" />
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="text-muted-foreground font-medium">
|
||||
Sections
|
||||
@@ -137,6 +141,7 @@ export function DocsSidebar({
|
||||
</SidebarGroup>
|
||||
)
|
||||
})}
|
||||
<div className="from-background via-background/80 to-background/50 sticky -bottom-1 z-10 h-16 shrink-0 bg-gradient-to-t blur-xs" />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ export function GitHubLink() {
|
||||
<Button asChild size="sm" variant="ghost" className="h-8 shadow-none">
|
||||
<Link href={siteConfig.links.github} target="_blank" rel="noreferrer">
|
||||
<Icons.gitHub />
|
||||
<React.Suspense fallback={<Skeleton className="h-4 w-8" />}>
|
||||
<React.Suspense fallback={<Skeleton className="h-4 w-[42px]" />}>
|
||||
<StarsCount />
|
||||
</React.Suspense>
|
||||
</Link>
|
||||
@@ -21,15 +21,20 @@ export function GitHubLink() {
|
||||
|
||||
export async function StarsCount() {
|
||||
const data = await fetch("https://api.github.com/repos/shadcn-ui/ui", {
|
||||
next: { revalidate: 86400 }, // Cache for 1 day (86400 seconds)
|
||||
next: { revalidate: 86400 },
|
||||
})
|
||||
const json = await data.json()
|
||||
|
||||
const formattedCount =
|
||||
json.stargazers_count >= 1000
|
||||
? json.stargazers_count % 1000 === 0
|
||||
? `${Math.floor(json.stargazers_count / 1000)}k`
|
||||
: `${(json.stargazers_count / 1000).toFixed(1)}k`
|
||||
: json.stargazers_count.toLocaleString()
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground w-8 text-xs tabular-nums">
|
||||
{json.stargazers_count >= 1000
|
||||
? `${(json.stargazers_count / 1000).toFixed(1)}k`
|
||||
: json.stargazers_count.toLocaleString()}
|
||||
<span className="text-muted-foreground w-fit text-xs tabular-nums">
|
||||
{formattedCount.replace(".0k", "k")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function MainNav({
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className={cn("items-center gap-0.5", className)} {...props}>
|
||||
<nav className={cn("items-center", className)} {...props}>
|
||||
{items.map((item) => (
|
||||
<Button key={item.href} variant="ghost" asChild size="sm">
|
||||
<Link
|
||||
|
||||
@@ -22,13 +22,17 @@ const TOP_LEVEL_SECTIONS = [
|
||||
href: "/docs/components",
|
||||
},
|
||||
{
|
||||
name: "Registry",
|
||||
href: "/docs/registry",
|
||||
name: "Directory",
|
||||
href: "/docs/directory",
|
||||
},
|
||||
{
|
||||
name: "MCP Server",
|
||||
href: "/docs/mcp",
|
||||
},
|
||||
{
|
||||
name: "Forms",
|
||||
href: "/docs/forms",
|
||||
},
|
||||
{
|
||||
name: "Changelog",
|
||||
href: "/docs/changelog",
|
||||
|
||||
49
apps/v4/components/search-directory.tsx
Normal file
49
apps/v4/components/search-directory.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { useSearchRegistry } from "@/hooks/use-search-registry"
|
||||
import { Field } from "@/registry/new-york-v4/ui/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/registry/new-york-v4/ui/input-group"
|
||||
|
||||
export const SearchDirectory = () => {
|
||||
const { query, setQuery } = useSearchRegistry()
|
||||
|
||||
const onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
/>
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
data-disabled={!query.length}
|
||||
className="data-[disabled=true]:hidden"
|
||||
>
|
||||
<InputGroupButton
|
||||
aria-label="Clear"
|
||||
title="Clear"
|
||||
size="icon-xs"
|
||||
onClick={() => setQuery(null)}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function SiteHeader() {
|
||||
return (
|
||||
<header className="bg-background sticky top-0 z-50 w-full">
|
||||
<div className="container-wrapper 3xl:fixed:px-0 px-6">
|
||||
<div className="3xl:fixed:container flex h-(--header-height) items-center gap-2 **:data-[slot=separator]:!h-4">
|
||||
<div className="3xl:fixed:container flex h-(--header-height) items-center **:data-[slot=separator]:!h-4">
|
||||
<MobileNav
|
||||
tree={pageTree}
|
||||
items={siteConfig.navItems}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react"
|
||||
import template from "lodash/template"
|
||||
import { CheckIcon, ClipboardIcon } from "lucide-react"
|
||||
|
||||
import { THEMES } from "@/lib/themes"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useThemeConfig } from "@/components/active-theme"
|
||||
import { copyToClipboardWithMeta } from "@/components/copy-button"
|
||||
import { Icons } from "@/components/icons"
|
||||
import { BaseColor, baseColors, baseColorsOKLCH } from "@/registry/base-colors"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -41,21 +43,12 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/registry/new-york-v4/ui/tabs"
|
||||
import {
|
||||
BaseColor,
|
||||
baseColors,
|
||||
baseColorsOKLCH,
|
||||
} from "@/registry/registry-base-colors"
|
||||
|
||||
interface BaseColorOKLCH {
|
||||
light: Record<string, string>
|
||||
dark: Record<string, string>
|
||||
}
|
||||
|
||||
const THEMES = baseColors.filter(
|
||||
(theme) => !["slate", "stone", "gray", "zinc"].includes(theme.name)
|
||||
)
|
||||
|
||||
export function ThemeCustomizer({ className }: React.ComponentProps<"div">) {
|
||||
const { activeTheme = "neutral", setActiveTheme } = useThemeConfig()
|
||||
|
||||
@@ -131,9 +124,7 @@ export function CopyCodeButton({
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-auto">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="capitalize">
|
||||
{activeThemeName === "neutral" ? "Default" : activeThemeName}
|
||||
</DrawerTitle>
|
||||
<DrawerTitle className="capitalize">{activeThemeName}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Copy and paste the following code into your CSS file.
|
||||
</DrawerDescription>
|
||||
@@ -143,15 +134,20 @@ export function CopyCodeButton({
|
||||
</Drawer>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className={cn("hidden sm:flex", className)} {...props}>
|
||||
Copy Code
|
||||
<Button
|
||||
data-size={props.size}
|
||||
className={cn("group/button hidden sm:flex", className)}
|
||||
{...props}
|
||||
>
|
||||
<IconCopy />
|
||||
<span className="group-data-[size=icon-sm]/button:sr-only">
|
||||
Copy Code
|
||||
</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="outline-none md:max-w-3xl">
|
||||
<DialogContent className="rounded-xl border-none bg-clip-padding shadow-2xl ring-4 ring-neutral-200/80 outline-none md:max-w-2xl dark:bg-neutral-800 dark:ring-neutral-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="capitalize">
|
||||
{activeThemeName === "neutral" ? "Default" : activeThemeName}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="capitalize">{activeThemeName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy and paste the following code into your CSS file.
|
||||
</DialogDescription>
|
||||
@@ -165,7 +161,7 @@ export function CopyCodeButton({
|
||||
|
||||
function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const [tailwindVersion, setTailwindVersion] = React.useState("v4")
|
||||
const [tailwindVersion, setTailwindVersion] = React.useState("v4-oklch")
|
||||
const activeTheme = React.useMemo(
|
||||
() => baseColors.find((theme) => theme.name === themeName),
|
||||
[themeName]
|
||||
@@ -191,10 +187,11 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
className="min-w-0 px-4 pb-4 md:p-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="v4">Tailwind v4</TabsTrigger>
|
||||
<TabsTrigger value="v4-oklch">OKLCH</TabsTrigger>
|
||||
<TabsTrigger value="v4-hsl">HSL</TabsTrigger>
|
||||
<TabsTrigger value="v3">Tailwind v3</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="v4">
|
||||
<TabsContent value="v4-oklch">
|
||||
<figure
|
||||
data-rehype-pretty-code-figure
|
||||
className="!mx-0 mt-0 rounded-lg"
|
||||
@@ -216,14 +213,12 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
className="bg-code text-code-foreground absolute top-3 right-2 z-10 size-7 shadow-none hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => {
|
||||
copyToClipboardWithMeta(
|
||||
tailwindVersion === "v3"
|
||||
? getThemeCode(activeTheme, 0.65)
|
||||
: getThemeCodeOKLCH(activeThemeOKLCH, 0.65),
|
||||
getThemeCodeOKLCH(activeThemeOKLCH, 0.65),
|
||||
{
|
||||
name: "copy_theme_code",
|
||||
properties: {
|
||||
theme: themeName,
|
||||
radius: 0.5,
|
||||
radius: 0.65,
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -231,7 +226,7 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <CheckIcon /> : <ClipboardIcon />}
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
</Button>
|
||||
<code data-line-numbers data-language="css">
|
||||
<span data-line className="line text-code-foreground">
|
||||
@@ -246,7 +241,8 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}: {value};
|
||||
--{key}: <ColorIndicator color={value} />{" "}
|
||||
{value};
|
||||
</span>
|
||||
))}
|
||||
<span data-line className="line text-code-foreground">
|
||||
@@ -264,7 +260,8 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}: {value};
|
||||
--{key}: <ColorIndicator color={value} />{" "}
|
||||
{value};
|
||||
</span>
|
||||
))}
|
||||
<span data-line className="line text-code-foreground">
|
||||
@@ -274,6 +271,90 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
</pre>
|
||||
</figure>
|
||||
</TabsContent>
|
||||
<TabsContent value="v4-hsl">
|
||||
<figure
|
||||
data-rehype-pretty-code-figure
|
||||
className="!mx-0 mt-0 rounded-lg"
|
||||
>
|
||||
<figcaption
|
||||
className="text-code-foreground [&_svg]:text-code-foreground flex items-center gap-2 [&_svg]:size-4 [&_svg]:opacity-70"
|
||||
data-rehype-pretty-code-title=""
|
||||
data-language="css"
|
||||
data-theme="github-dark github-light-default"
|
||||
>
|
||||
<Icons.css className="fill-foreground" />
|
||||
app/globals.css
|
||||
</figcaption>
|
||||
<pre className="no-scrollbar max-h-[300px] min-w-0 overflow-x-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0 md:max-h-[450px]">
|
||||
<Button
|
||||
data-slot="copy-button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="bg-code text-code-foreground absolute top-3 right-2 z-10 size-7 shadow-none hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => {
|
||||
copyToClipboardWithMeta(
|
||||
getThemeCodeHSLV4(activeTheme, 0.65),
|
||||
{
|
||||
name: "copy_theme_code",
|
||||
properties: {
|
||||
theme: themeName,
|
||||
radius: 0.65,
|
||||
},
|
||||
}
|
||||
)
|
||||
setHasCopied(true)
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
</Button>
|
||||
<code data-line-numbers data-language="css">
|
||||
<span data-line className="line text-code-foreground">
|
||||
:root {
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
--radius: 0.65rem;
|
||||
</span>
|
||||
{Object.entries(activeTheme?.cssVars.light || {}).map(
|
||||
([key, value]) => (
|
||||
<span
|
||||
data-line
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}:{" "}
|
||||
<ColorIndicator color={`hsl(${value})`} /> hsl({value});
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span data-line className="line text-code-foreground">
|
||||
}
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
|
||||
</span>
|
||||
<span data-line className="line text-code-foreground">
|
||||
.dark {
|
||||
</span>
|
||||
{Object.entries(activeTheme?.cssVars.dark || {}).map(
|
||||
([key, value]) => (
|
||||
<span
|
||||
data-line
|
||||
className="line text-code-foreground"
|
||||
key={key}
|
||||
>
|
||||
--{key}:{" "}
|
||||
<ColorIndicator color={`hsl(${value})`} /> hsl({value});
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
<span data-line className="line text-code-foreground">
|
||||
}
|
||||
</span>
|
||||
</code>
|
||||
</pre>
|
||||
</figure>
|
||||
</TabsContent>
|
||||
<TabsContent value="v3">
|
||||
<figure
|
||||
data-rehype-pretty-code-figure
|
||||
@@ -295,23 +376,18 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
variant="ghost"
|
||||
className="bg-code text-code-foreground absolute top-3 right-2 z-10 size-7 shadow-none hover:opacity-100 focus-visible:opacity-100"
|
||||
onClick={() => {
|
||||
copyToClipboardWithMeta(
|
||||
tailwindVersion === "v3"
|
||||
? getThemeCode(activeTheme, 0.65)
|
||||
: getThemeCodeOKLCH(activeThemeOKLCH, 0.65),
|
||||
{
|
||||
name: "copy_theme_code",
|
||||
properties: {
|
||||
theme: themeName,
|
||||
radius: 0.5,
|
||||
},
|
||||
}
|
||||
)
|
||||
copyToClipboardWithMeta(getThemeCode(activeTheme, 0.5), {
|
||||
name: "copy_theme_code",
|
||||
properties: {
|
||||
theme: themeName,
|
||||
radius: 0.5,
|
||||
},
|
||||
})
|
||||
setHasCopied(true)
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Copy</span>
|
||||
{hasCopied ? <CheckIcon /> : <ClipboardIcon />}
|
||||
{hasCopied ? <IconCheck /> : <IconCopy />}
|
||||
</Button>
|
||||
<code data-line-numbers data-language="css">
|
||||
<span data-line className="line">
|
||||
@@ -322,10 +398,16 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--background:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["background"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["background"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--foreground:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["foreground"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["foreground"]};
|
||||
</span>
|
||||
{[
|
||||
@@ -340,6 +422,13 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
<React.Fragment key={prefix}>
|
||||
<span data-line className="line">
|
||||
--{prefix}:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${
|
||||
activeTheme?.cssVars.light[
|
||||
prefix as keyof typeof activeTheme.cssVars.light
|
||||
]
|
||||
})`}
|
||||
/>{" "}
|
||||
{
|
||||
activeTheme?.cssVars.light[
|
||||
prefix as keyof typeof activeTheme.cssVars.light
|
||||
@@ -349,6 +438,13 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--{prefix}-foreground:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${
|
||||
activeTheme?.cssVars.light[
|
||||
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.light
|
||||
]
|
||||
})`}
|
||||
/>{" "}
|
||||
{
|
||||
activeTheme?.cssVars.light[
|
||||
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.light
|
||||
@@ -360,14 +456,23 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
))}
|
||||
<span data-line className="line">
|
||||
--border:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["border"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["border"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--input:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["input"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["input"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--ring:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.light["ring"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.light["ring"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
@@ -378,6 +483,13 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
<React.Fragment key={prefix}>
|
||||
<span data-line className="line">
|
||||
--{prefix}:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${
|
||||
activeTheme?.cssVars.light[
|
||||
prefix as keyof typeof activeTheme.cssVars.light
|
||||
]
|
||||
})`}
|
||||
/>{" "}
|
||||
{
|
||||
activeTheme?.cssVars.light[
|
||||
prefix as keyof typeof activeTheme.cssVars.light
|
||||
@@ -399,10 +511,16 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--background:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["background"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["background"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--foreground:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["foreground"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["foreground"]};
|
||||
</span>
|
||||
{[
|
||||
@@ -417,6 +535,13 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
<React.Fragment key={prefix}>
|
||||
<span data-line className="line">
|
||||
--{prefix}:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${
|
||||
activeTheme?.cssVars.dark[
|
||||
prefix as keyof typeof activeTheme.cssVars.dark
|
||||
]
|
||||
})`}
|
||||
/>{" "}
|
||||
{
|
||||
activeTheme?.cssVars.dark[
|
||||
prefix as keyof typeof activeTheme.cssVars.dark
|
||||
@@ -426,6 +551,13 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--{prefix}-foreground:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${
|
||||
activeTheme?.cssVars.dark[
|
||||
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.dark
|
||||
]
|
||||
})`}
|
||||
/>{" "}
|
||||
{
|
||||
activeTheme?.cssVars.dark[
|
||||
`${prefix}-foreground` as keyof typeof activeTheme.cssVars.dark
|
||||
@@ -437,14 +569,23 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
))}
|
||||
<span data-line className="line">
|
||||
--border:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["border"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["border"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--input:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["input"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["input"]};
|
||||
</span>
|
||||
<span data-line className="line">
|
||||
--ring:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${activeTheme?.cssVars.dark["ring"]})`}
|
||||
/>{" "}
|
||||
{activeTheme?.cssVars.dark["ring"]};
|
||||
</span>
|
||||
{["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"].map(
|
||||
@@ -452,6 +593,13 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
<React.Fragment key={prefix}>
|
||||
<span data-line className="line">
|
||||
--{prefix}:{" "}
|
||||
<ColorIndicator
|
||||
color={`hsl(${
|
||||
activeTheme?.cssVars.dark[
|
||||
prefix as keyof typeof activeTheme.cssVars.dark
|
||||
]
|
||||
})`}
|
||||
/>{" "}
|
||||
{
|
||||
activeTheme?.cssVars.dark[
|
||||
prefix as keyof typeof activeTheme.cssVars.dark
|
||||
@@ -477,6 +625,15 @@ function CustomizerCode({ themeName }: { themeName: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ColorIndicator({ color }: { color: string }) {
|
||||
return (
|
||||
<span
|
||||
className="border-border/50 inline-block size-3 border"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getThemeCodeOKLCH(theme: BaseColorOKLCH | undefined, radius: number) {
|
||||
if (!theme) {
|
||||
return ""
|
||||
@@ -509,6 +666,27 @@ function getThemeCode(theme: BaseColor | undefined, radius: number) {
|
||||
})
|
||||
}
|
||||
|
||||
function getThemeCodeHSLV4(theme: BaseColor | undefined, radius: number) {
|
||||
if (!theme) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const rootSection =
|
||||
":root {\n --radius: " +
|
||||
radius +
|
||||
"rem;\n" +
|
||||
Object.entries(theme.cssVars.light)
|
||||
.map((entry) => " --" + entry[0] + ": hsl(" + entry[1] + ");")
|
||||
.join("\n") +
|
||||
"\n}\n\n.dark {\n" +
|
||||
Object.entries(theme.cssVars.dark)
|
||||
.map((entry) => " --" + entry[0] + ": hsl(" + entry[1] + ");")
|
||||
.join("\n") +
|
||||
"\n}\n"
|
||||
|
||||
return rootSection
|
||||
}
|
||||
|
||||
const BASE_STYLES_WITH_VARIABLES = `
|
||||
@layer base {
|
||||
:root {
|
||||
|
||||
@@ -1,74 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { THEMES } from "@/lib/themes"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useThemeConfig } from "@/components/active-theme"
|
||||
import { Label } from "@/registry/new-york-v4/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/registry/new-york-v4/ui/select"
|
||||
|
||||
const DEFAULT_THEMES = [
|
||||
{
|
||||
name: "Default",
|
||||
value: "default",
|
||||
},
|
||||
{
|
||||
name: "Scaled",
|
||||
value: "scaled",
|
||||
},
|
||||
{
|
||||
name: "Mono",
|
||||
value: "mono",
|
||||
},
|
||||
]
|
||||
|
||||
const COLOR_THEMES = [
|
||||
{
|
||||
name: "Blue",
|
||||
value: "blue",
|
||||
},
|
||||
{
|
||||
name: "Green",
|
||||
value: "green",
|
||||
},
|
||||
{
|
||||
name: "Amber",
|
||||
value: "amber",
|
||||
},
|
||||
{
|
||||
name: "Rose",
|
||||
value: "rose",
|
||||
},
|
||||
{
|
||||
name: "Purple",
|
||||
value: "purple",
|
||||
},
|
||||
{
|
||||
name: "Orange",
|
||||
value: "orange",
|
||||
},
|
||||
{
|
||||
name: "Teal",
|
||||
value: "teal",
|
||||
},
|
||||
]
|
||||
import { CopyCodeButton } from "./theme-customizer"
|
||||
|
||||
export function ThemeSelector({ className }: React.ComponentProps<"div">) {
|
||||
const { activeTheme, setActiveTheme } = useThemeConfig()
|
||||
|
||||
const value = activeTheme === "default" ? "neutral" : activeTheme
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<Label htmlFor="theme-selector" className="sr-only">
|
||||
Theme
|
||||
</Label>
|
||||
<Select value={activeTheme} onValueChange={setActiveTheme}>
|
||||
<Select value={value} onValueChange={setActiveTheme}>
|
||||
<SelectTrigger
|
||||
id="theme-selector"
|
||||
size="sm"
|
||||
@@ -78,32 +34,18 @@ export function ThemeSelector({ className }: React.ComponentProps<"div">) {
|
||||
<SelectValue placeholder="Select a theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectGroup>
|
||||
{DEFAULT_THEMES.map((theme) => (
|
||||
<SelectItem
|
||||
key={theme.name}
|
||||
value={theme.value}
|
||||
className="data-[state=checked]:opacity-50"
|
||||
>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Colors</SelectLabel>
|
||||
{COLOR_THEMES.map((theme) => (
|
||||
<SelectItem
|
||||
key={theme.name}
|
||||
value={theme.value}
|
||||
className="data-[state=checked]:opacity-50"
|
||||
>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{THEMES.map((theme) => (
|
||||
<SelectItem
|
||||
key={theme.name}
|
||||
value={theme.name}
|
||||
className="data-[state=checked]:opacity-50"
|
||||
>
|
||||
{theme.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CopyCodeButton variant="secondary" size="icon-sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,15 +34,24 @@ import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
Here's what it looks like:
|
||||
|
||||
<ComponentPreview name="spinner-basic" className="[&_.preview]:h-[250px]" />
|
||||
<ComponentPreview
|
||||
name="spinner-basic"
|
||||
className="[&_.preview]:h-[250px] [&_pre]:!h-[250px]"
|
||||
/>
|
||||
|
||||
Here's what it looks like in a button:
|
||||
|
||||
<ComponentPreview name="spinner-button" className="[&_.preview]:h-[250px]" />
|
||||
<ComponentPreview
|
||||
name="spinner-button"
|
||||
className="[&_.preview]:h-[250px] [&_pre]:!h-[250px]"
|
||||
/>
|
||||
|
||||
You can edit the code and replace it with your own spinner.
|
||||
|
||||
<ComponentPreview name="spinner-custom" className="[&_.preview]:h-[250px]" />
|
||||
<ComponentPreview
|
||||
name="spinner-custom"
|
||||
className="[&_.preview]:h-[250px] [&_pre]:!h-[250px]"
|
||||
/>
|
||||
|
||||
### Kbd
|
||||
|
||||
@@ -65,7 +74,10 @@ Use `KbdGroup` to group keyboard keys together.
|
||||
</KbdGroup>
|
||||
```
|
||||
|
||||
<ComponentPreview name="kbd-demo" className="[&_.preview]:h-[250px]" />
|
||||
<ComponentPreview
|
||||
name="kbd-demo"
|
||||
className="[&_.preview]:h-[250px] [&_pre]:!h-[250px]"
|
||||
/>
|
||||
|
||||
You can add it to buttons, tooltips, input groups, and more.
|
||||
|
||||
@@ -73,7 +85,10 @@ You can add it to buttons, tooltips, input groups, and more.
|
||||
|
||||
I got a lot of requests for this one: Button Group. It's a container that groups related buttons together with consistent styling. Great for action groups, split buttons, and more.
|
||||
|
||||
<ComponentPreview name="button-group-demo" className="[&_.preview]:h-[250px]" />
|
||||
<ComponentPreview
|
||||
name="button-group-demo"
|
||||
className="[&_.preview]:h-[250px] [&_pre]:!h-[250px]"
|
||||
/>
|
||||
|
||||
Here's the code:
|
||||
|
||||
@@ -107,14 +122,14 @@ Use `ButtonGroupSeparator` to create split buttons. Classic dropdown pattern.
|
||||
|
||||
<ComponentPreview
|
||||
name="button-group-dropdown"
|
||||
className="[&_.preview]:h-[250px]"
|
||||
className="[&_.preview]:h-[250px] [&_pre]:!h-[250px]"
|
||||
/>
|
||||
|
||||
You can also use it to add prefix or suffix buttons and text to inputs.
|
||||
|
||||
<ComponentPreview
|
||||
name="button-group-select"
|
||||
className="[&_.preview]:h-[250px]"
|
||||
className="[&_.preview]:h-[250px] [&_pre]:!h-[250px]"
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers
|
||||
@@ -148,31 +163,37 @@ import {
|
||||
|
||||
Here's a preview with icons:
|
||||
|
||||
<ComponentPreview name="input-group-icon" className="[&_.preview]:h-[300px]" />
|
||||
<ComponentPreview
|
||||
name="input-group-icon"
|
||||
className="[&_.preview]:h-[300px] [&_pre]:!h-[300px]"
|
||||
/>
|
||||
|
||||
You can also add buttons to the input group.
|
||||
|
||||
<ComponentPreview
|
||||
name="input-group-button"
|
||||
className="[&_.preview]:h-[300px]"
|
||||
className="[&_.preview]:h-[300px] [&_pre]:!h-[300px]"
|
||||
/>
|
||||
|
||||
Or text, labels, tooltips,...
|
||||
|
||||
<ComponentPreview name="input-group-text" className="[&_.preview]:h-[350px]" />
|
||||
<ComponentPreview
|
||||
name="input-group-text"
|
||||
className="[&_.preview]:h-[350px] [&_pre]:!h-[350px]"
|
||||
/>
|
||||
|
||||
It also works with textareas so you can build really complex components with lots of knobs and dials or yet another prompt form.
|
||||
|
||||
<ComponentPreview
|
||||
name="input-group-textarea"
|
||||
className="[&_.preview]:h-[450px]"
|
||||
className="[&_.preview]:h-[450px] [&_pre]:!h-[450px]"
|
||||
/>
|
||||
|
||||
Oh here are some cool ones with spinners:
|
||||
|
||||
<ComponentPreview
|
||||
name="input-group-spinner"
|
||||
className="[&_.preview]:h-[350px]"
|
||||
className="[&_.preview]:h-[350px] [&_pre]:!h-[350px]"
|
||||
/>
|
||||
|
||||
### Field
|
||||
@@ -202,15 +223,24 @@ Here's a basic field with an input:
|
||||
</Field>
|
||||
```
|
||||
|
||||
<ComponentPreview name="field-input" className="[&_.preview]:h-[350px]" />
|
||||
<ComponentPreview
|
||||
name="field-input"
|
||||
className="[&_.preview]:h-[350px] [&_pre]:!h-[350px]"
|
||||
/>
|
||||
|
||||
It works with all form controls. Inputs, textareas, selects, checkboxes, radios, switches, sliders, you name it. Here's a full example:
|
||||
|
||||
<ComponentPreview name="field-demo" className="[&_.preview]:h-[850px]" />
|
||||
<ComponentPreview
|
||||
name="field-demo"
|
||||
className="[&_.preview]:h-[850px] [&_pre]:!h-[850px]"
|
||||
/>
|
||||
|
||||
Here are some checkbox fields:
|
||||
|
||||
<ComponentPreview name="field-checkbox" className="[&_.preview]:h-[500px]" />
|
||||
<ComponentPreview
|
||||
name="field-checkbox"
|
||||
className="[&_.preview]:h-[500px] [&_pre]:!h-[500px]"
|
||||
/>
|
||||
|
||||
You can group fields together using `FieldGroup` and `FieldSet`. Perfect for
|
||||
multi-section forms.
|
||||
@@ -225,16 +255,25 @@ multi-section forms.
|
||||
</FieldSet>
|
||||
```
|
||||
|
||||
<ComponentPreview name="field-fieldset" className="[&_.preview]:h-[500px]" />
|
||||
<ComponentPreview
|
||||
name="field-fieldset"
|
||||
className="[&_.preview]:h-[500px] [&_pre]:!h-[500px]"
|
||||
/>
|
||||
|
||||
Making it responsive is easy. Use `orientation="responsive"` and it switches
|
||||
between vertical and horizontal layouts based on container width. Done.
|
||||
|
||||
<ComponentPreview name="field-responsive" className="[&_.preview]:h-[600px]" />
|
||||
<ComponentPreview
|
||||
name="field-responsive"
|
||||
className="[&_.preview]:h-[600px] [&_pre]:!h-[600px]"
|
||||
/>
|
||||
|
||||
Wait here's more. Wrap your fields in `FieldLabel` to create a selectable field group. Really easy. And it looks great.
|
||||
|
||||
<ComponentPreview name="field-choice-card" className="[&_.preview]:h-[600px]" />
|
||||
<ComponentPreview
|
||||
name="field-choice-card"
|
||||
className="[&_.preview]:h-[600px] [&_pre]:!h-[600px]"
|
||||
/>
|
||||
|
||||
### Item
|
||||
|
||||
@@ -268,26 +307,26 @@ Here's a basic item:
|
||||
|
||||
<ComponentPreview
|
||||
name="item-demo"
|
||||
className="[&_.preview]:h-[300px] [&_.preview]:p-4"
|
||||
className="[&_.preview]:h-[300px] [&_.preview]:p-4 [&_pre]:!h-[300px]"
|
||||
/>
|
||||
|
||||
You can add icons, avatars, or images to the item.
|
||||
|
||||
<ComponentPreview
|
||||
name="item-icon"
|
||||
className="[&_.preview]:h-[300px] [&_.preview]:p-4"
|
||||
className="[&_.preview]:h-[300px] [&_.preview]:p-4 [&_pre]:!h-[300px]"
|
||||
/>
|
||||
|
||||
<ComponentPreview
|
||||
name="item-avatar"
|
||||
className="[&_.preview]:h-[300px] [&_.preview]:p-4"
|
||||
className="[&_.preview]:h-[300px] [&_.preview]:p-4 [&_pre]:!h-[300px]"
|
||||
/>
|
||||
|
||||
And here's what a list of items looks like with `ItemGroup`:
|
||||
|
||||
<ComponentPreview
|
||||
name="item-group"
|
||||
className="[&_.preview]:h-[500px] [&_.preview]:p-4"
|
||||
className="[&_.preview]:h-[500px] [&_.preview]:p-4 [&_pre]:!h-[500px]"
|
||||
/>
|
||||
|
||||
Need it as a link? Use the `asChild` prop:
|
||||
@@ -308,7 +347,7 @@ Need it as a link? Use the `asChild` prop:
|
||||
|
||||
<ComponentPreview
|
||||
name="item-link"
|
||||
className="[&_.preview]:h-[400px] [&_.preview]:p-4"
|
||||
className="[&_.preview]:h-[400px] [&_.preview]:p-4 [&_pre]:!h-[400px]"
|
||||
/>
|
||||
|
||||
### Empty
|
||||
@@ -342,16 +381,22 @@ Here's how you use it:
|
||||
|
||||
<ComponentPreview
|
||||
name="empty-demo"
|
||||
className="[&_.preview]:h-[400px] [&_.preview]:p-4"
|
||||
className="[&_.preview]:h-[400px] [&_.preview]:p-4 [&_pre]:!h-[400px]"
|
||||
/>
|
||||
|
||||
You can use it with avatars:
|
||||
|
||||
<ComponentPreview name="empty-avatar" className="[&_.preview]:h-[400px]" />
|
||||
<ComponentPreview
|
||||
name="empty-avatar"
|
||||
className="[&_.preview]:h-[400px] [&_pre]:!h-[400px]"
|
||||
/>
|
||||
|
||||
Or with input groups for things like search results or email subscriptions:
|
||||
|
||||
<ComponentPreview name="empty-input-group" className="[&_.preview]:h-[450px]" />
|
||||
<ComponentPreview
|
||||
name="empty-input-group"
|
||||
className="[&_.preview]:h-[450px] [&_pre]:!h-[450px]"
|
||||
/>
|
||||
|
||||
That's it. Seven new components. Works with all your libraries. Ready for your projects.
|
||||
|
||||
@@ -969,7 +1014,7 @@ It has support for infinite looping, autoplay, vertical orientation, and more.
|
||||
|
||||
### Drawer
|
||||
|
||||
Oh the drawer component 😍. Built on top of [Vaul](https://github.com/emilkowalski/vaul) by [emilkowalski\_](https://twitter.com/emilkowalski_).
|
||||
Oh the drawer component 😍. Built on top of [Vaul](https://github.com/emilkowalski/vaul) by [emilkowalski](https://twitter.com/emilkowalski).
|
||||
|
||||
Try opening the following drawer on mobile. It looks amazing!
|
||||
|
||||
@@ -991,7 +1036,7 @@ Build resizable panel groups and layouts with this `<Resizable />` component.
|
||||
|
||||
### Sonner
|
||||
|
||||
Another one by [emilkowalski\_](https://twitter.com/emilkowalski_). The last toast component you'll ever need. Sonner is now availabe in shadcn/ui.
|
||||
Another one by [emilkowalski](https://twitter.com/emilkowalski). The last toast component you'll ever need. Sonner is now availabe in shadcn/ui.
|
||||
|
||||
<ComponentPreview name="sonner-demo" />
|
||||
|
||||
|
||||
69
apps/v4/content/docs/(root)/directory.mdx
Normal file
69
apps/v4/content/docs/(root)/directory.mdx
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Registry Directory
|
||||
description: Discover community registries for shadcn/ui components and blocks.
|
||||
---
|
||||
|
||||
These registries are built into the CLI with no additional configuration required. To add a component, run: `npx shadcn add @<registry>/<component>`.
|
||||
|
||||
<DirectoryList />
|
||||
|
||||
Don't see a registry? Learn how to [add it here](/docs/registry/registry-index).
|
||||
|
||||
## Documentation
|
||||
|
||||
You can use the `shadcn` CLI to run your own code registry. Running your own registry allows you to distribute your custom components, hooks, pages, config, rules and other files to any project.
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<LinkedCard href="/docs/registry/getting-started" className="items-start text-sm md:p-6">
|
||||
<div className="font-medium">Getting Started</div>
|
||||
<div className="text-muted-foreground">
|
||||
Set up and build your own registry
|
||||
</div>
|
||||
</LinkedCard>
|
||||
|
||||
<LinkedCard
|
||||
href="/docs/registry/authentication"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Authentication</div>
|
||||
<div className="text-muted-foreground">
|
||||
Secure your registry with authentication
|
||||
</div>
|
||||
</LinkedCard>
|
||||
<LinkedCard
|
||||
href="/docs/registry/namespace"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Namespaces</div>
|
||||
<div className="text-muted-foreground">
|
||||
Configure registries with namespaces
|
||||
</div>
|
||||
</LinkedCard>
|
||||
<LinkedCard
|
||||
href="/docs/registry/registry-index"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Add a Registry</div>
|
||||
<div className="text-muted-foreground">
|
||||
Learn how to add a registry to the directory
|
||||
</div>
|
||||
</LinkedCard>
|
||||
<LinkedCard
|
||||
href="/docs/registry/examples"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Examples</div>
|
||||
<div className="text-muted-foreground">
|
||||
Registry item examples and configurations
|
||||
</div>
|
||||
</LinkedCard>
|
||||
<LinkedCard
|
||||
href="/docs/registry/registry-json"
|
||||
className="items-start text-sm md:p-6"
|
||||
>
|
||||
<div className="font-medium">Schema</div>
|
||||
<div className="text-muted-foreground">
|
||||
Schema specification for registry.json
|
||||
</div>
|
||||
</LinkedCard>
|
||||
</div>
|
||||
@@ -8,12 +8,13 @@ description: Every component recreated in Figma. With customizable props, typogr
|
||||
questions or feedback, please reach out to the Figma file maintainers.
|
||||
</Callout>
|
||||
|
||||
## Free
|
||||
|
||||
- [Obra shadcn/ui](https://www.figma.com/community/file/1514746685758799870/obra-shadcn-ui) by [Obra Studio](https://obra.studio/) - Carefully crafted kit designed in the philosophy of shadcn, tracks v4, MIT licensed
|
||||
- [shadcn/ui design system](https://www.figma.com/community/file/1203061493325953101) by [Pietro Schirano](https://twitter.com/skirano) - A design companion for shadcn/ui. Each component was painstakingly crafted to perfectly match the code implementation.
|
||||
|
||||
## Paid
|
||||
|
||||
- [shadcn/ui kit](https://shadcndesign.com) by [ Matt Wierzbicki](https://x.com/matsugfx) - A premium, always up-to-date UI kit for Figma - shadcn/ui compatible and optimized for smooth design-to-dev handoff.
|
||||
- [Shadcraft UI Kit](https://shadcraft.com) - The most advanced shadcn-compatible kit with instant theming via [tweakcn](https://tweakcn.com), a pro library of components and templates, and complete coverage of shadcn components and blocks.
|
||||
|
||||
## Free
|
||||
|
||||
- [shadcn/ui design system](https://www.figma.com/community/file/1203061493325953101) by [Pietro Schirano](https://twitter.com/skirano) - A design companion for shadcn/ui. Each component was painstakingly crafted to perfectly match the code implementation.
|
||||
- [Obra shadcn/ui](https://www.figma.com/community/file/1514746685758799870/obra-shadcn-ui) by [Obra Studio](https://obra.studio/) - Carefully crafted kit designed in the philosophy of shadcn, tracks v4, MIT licensed
|
||||
- [shadcn/studio UI Kit](https://shadcnstudio.com/figma) - Accelerate design & development with a shadcn/ui compatible Figma kit with updated components, 550+ blocks, 10+ templates, 20+ themes, and an AI tool that converts designs into shadcn/ui code.
|
||||
|
||||
@@ -9,20 +9,24 @@ However we provide a JavaScript version of the components as well. The JavaScrip
|
||||
|
||||
To opt-out of TypeScript, you can use the `tsx` flag in your `components.json` file.
|
||||
|
||||
```json {10} title="components.json" showLineNumbers
|
||||
```json {4} title="components.json" showLineNumbers
|
||||
{
|
||||
"style": "default",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
},
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"utils": "~/lib/utils",
|
||||
"components": "~/components"
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -182,7 +182,7 @@ To configure MCP in VS Code with GitHub Copilot, add the shadcn server to your p
|
||||
|
||||
```json title=".vscode/mcp.json" showLineNumbers
|
||||
{
|
||||
"mcpServers": {
|
||||
"servers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": ["shadcn@latest", "mcp"]
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"blocks",
|
||||
"figma",
|
||||
"changelog",
|
||||
"[llms.txt](/llms.txt)",
|
||||
"legacy"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ To create a new monorepo project, run the `init` command. You will be prompted
|
||||
to select the type of project you are creating.
|
||||
|
||||
```bash
|
||||
npx shadcn@canary init
|
||||
npx shadcn@latest init
|
||||
```
|
||||
|
||||
Select the `Next.js (Monorepo)` option.
|
||||
@@ -51,15 +51,15 @@ cd apps/web
|
||||
```
|
||||
|
||||
```bash
|
||||
npx shadcn@canary add [COMPONENT]
|
||||
npx shadcn@latest add [COMPONENT]
|
||||
```
|
||||
|
||||
The CLI will figure out what type of component you are adding and install the
|
||||
correct files to the correct path.
|
||||
|
||||
For example, if you run `npx shadcn@canary add button`, the CLI will install the button component under `packages/ui` and update the import path for components in `apps/web`.
|
||||
For example, if you run `npx shadcn@latest add button`, the CLI will install the button component under `packages/ui` and update the import path for components in `apps/web`.
|
||||
|
||||
If you run `npx shadcn@canary add login-01`, the CLI will install the `button`, `label`, `input` and `card` components under `packages/ui` and the `login-form` component under `apps/web/components`.
|
||||
If you run `npx shadcn@latest add login-01`, the CLI will install the `button`, `label`, `input` and `card` components under `packages/ui` and the `login-form` component under `apps/web/components`.
|
||||
|
||||
### Importing components
|
||||
|
||||
|
||||
@@ -3,17 +3,9 @@ title: Next.js 15 + React 19
|
||||
description: Using shadcn/ui with Next.js 15 and React 19.
|
||||
---
|
||||
|
||||
<Callout className="mb-6 border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950 [&_code]:bg-blue-100 dark:[&_code]:bg-blue-900">
|
||||
<Callout className="">
|
||||
**Update:** We have added full support for React 19 and Tailwind v4 in the
|
||||
`canary` release. See the docs for [Tailwind v4](/docs/tailwind-v4) for more
|
||||
information.
|
||||
</Callout>
|
||||
|
||||
<Callout>
|
||||
**The following guide applies to any framework that supports React 19**. I
|
||||
titled this page "Next.js 15 + React 19" to help people upgrading to Next.js
|
||||
15 find it. We are working with package maintainers to help upgrade to React
|
||||
19.
|
||||
`latest` release. **This guide might be outdated. Proceed with caution.**
|
||||
</Callout>
|
||||
|
||||
## TL;DR
|
||||
@@ -148,7 +140,7 @@ To make it easy for you track the progress of the upgrade, I've created a table
|
||||
| [react-day-picker](https://www.npmjs.com/package/react-day-picker) | ✅ | Works with flag for npm. Work to upgrade to v9 in progress. |
|
||||
| [input-otp](https://www.npmjs.com/package/input-otp) | ✅ | |
|
||||
| [vaul](https://www.npmjs.com/package/vaul) | ✅ | |
|
||||
| [@radix-ui/react-icons](https://www.npmjs.com/package/@radix-ui/react-icons) | 🚧 | See [PR #194](https://github.com/radix-ui/icons/pull/194) |
|
||||
| [@radix-ui/react-icons](https://www.npmjs.com/package/@radix-ui/react-icons) | ✅ | See [PR #194](https://github.com/radix-ui/icons/pull/194) |
|
||||
| [cmdk](https://www.npmjs.com/package/cmdk) | ✅ | |
|
||||
|
||||
If you have any questions, please [open an issue](https://github.com/shadcn/ui/issues) on GitHub.
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
---
|
||||
title: Styleguide
|
||||
description: A styleguide for writing documentation in mdx.
|
||||
---
|
||||
|
||||
The OpenAI API provides a simple interface to state-of-the-art AI models for text generation, natural language processing, computer vision, and more. This example generates text output from a prompt, as you might using ChatGPT.
|
||||
|
||||
## Analyze image inputs
|
||||
|
||||
You can provide image inputs to the model as well. Scan receipts, analyze screenshots, or find objects in the real world with [computer vision](/docs/installation/computer-vision). This is code in a `pre` tag and `npx` command in a `code` tag.
|
||||
|
||||
```bash
|
||||
npm install foo
|
||||
```
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init
|
||||
```
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Button>Click me</Button>
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
// With line numbers
|
||||
export default function Home() {
|
||||
return <div>Hello</div>
|
||||
}
|
||||
```
|
||||
|
||||
```tsx title="Button.tsx"
|
||||
export default function Button({ children }: { children: React.ReactNode }) {
|
||||
return <button>{children}</button>
|
||||
}
|
||||
```
|
||||
|
||||
This is a code block with a title.
|
||||
|
||||
## Line Numbers and Line Highlighting
|
||||
|
||||
Draw attention to a particular line of code.
|
||||
|
||||
```tsx {4} showLineNumbers
|
||||
import { useFloating } from "@floating-ui/react"
|
||||
|
||||
function MyComponent() {
|
||||
const { refs, floatingStyles } = useFloating()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={refs.setReference} />
|
||||
<div ref={refs.setFloating} style={floatingStyles} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Word Highlighting
|
||||
|
||||
Draw attention to a particular word or series of characters.
|
||||
|
||||
```tsx /floatingStyles/
|
||||
import { useFloating } from "@floating-ui/react"
|
||||
|
||||
function MyComponent() {
|
||||
const { refs, floatingStyles } = useFloating()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={refs.setReference} />
|
||||
<div ref={refs.setFloating} style={floatingStyles} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
How
|
||||
|
||||
```tsx title="apps/www/registry/registry-blocks.tsx"
|
||||
export const blocks = [
|
||||
// ...
|
||||
{
|
||||
name: "dashboard-01",
|
||||
author: "shadcn (https://ui.shadcn.com)",
|
||||
title: "Dashboard",
|
||||
description: "A simple dashboard with a hello world component.",
|
||||
type: "registry:block",
|
||||
registryDependencies: ["input", "button", "card"],
|
||||
dependencies: ["zod"],
|
||||
files: [
|
||||
{
|
||||
path: "blocks/dashboard-01/page.tsx",
|
||||
type: "registry:page",
|
||||
target: "app/dashboard/page.tsx",
|
||||
},
|
||||
{
|
||||
path: "blocks/dashboard-01/components/hello-world.tsx",
|
||||
type: "registry:component",
|
||||
},
|
||||
{
|
||||
path: "blocks/dashboard-01/components/example-card.tsx",
|
||||
type: "registry:component",
|
||||
},
|
||||
{
|
||||
path: "blocks/dashboard-01/hooks/use-hello-world.ts",
|
||||
type: "registry:hook",
|
||||
},
|
||||
{
|
||||
path: "blocks/dashboard-01/lib/format-date.ts",
|
||||
type: "registry:lib",
|
||||
},
|
||||
],
|
||||
categories: ["dashboard"],
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
```txt
|
||||
apps
|
||||
└── web # Your app goes here.
|
||||
├── app
|
||||
│ └── page.tsx
|
||||
├── components
|
||||
│ └── login-form.tsx
|
||||
├── components.json
|
||||
└── package.json
|
||||
packages
|
||||
└── ui # Your components and dependencies are installed here.
|
||||
├── src
|
||||
│ ├── components
|
||||
│ │ └── button.tsx
|
||||
│ ├── hooks
|
||||
│ ├── lib
|
||||
│ │ └── utils.ts
|
||||
│ └── styles
|
||||
│ └── globals.css
|
||||
├── components.json
|
||||
└── package.json
|
||||
package.json
|
||||
turbo.json
|
||||
```
|
||||
|
||||
```diff showLineNumbers
|
||||
- @plugin 'tailwindcss-animate';
|
||||
+ @import "tw-animate-css";
|
||||
```
|
||||
|
||||
## CSS Variables
|
||||
|
||||
```tsx /bg-background/ /text-foreground/
|
||||
<div className="bg-background text-foreground" />
|
||||
```
|
||||
|
||||
To use CSS variables for theming set `tailwind.cssVariables` to `true` in your `components.json` file.
|
||||
|
||||
```json {8} title="components.json"
|
||||
{
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
```
|
||||
|
||||
## Utility classes
|
||||
|
||||
```tsx /bg-zinc-950/ /text-zinc-50/ /dark:bg-white/ /dark:text-zinc-950/
|
||||
<div className="bg-zinc-950 dark:bg-white" />
|
||||
```
|
||||
|
||||
To use utility classes for theming set `tailwind.cssVariables` to `false` in your `components.json` file.
|
||||
|
||||
```json {8} title="components.json"
|
||||
{
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
```
|
||||
@@ -15,7 +15,7 @@ To use CSS variables for theming set `tailwind.cssVariables` to `true` in your `
|
||||
|
||||
```json {8} title="components.json" showLineNumbers
|
||||
{
|
||||
"style": "default",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
@@ -44,7 +44,7 @@ To use utility classes for theming set `tailwind.cssVariables` to `false` in you
|
||||
|
||||
```json {8} title="components.json" showLineNumbers
|
||||
{
|
||||
"style": "default",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
@@ -52,14 +52,14 @@ To use utility classes for theming set `tailwind.cssVariables` to `false` in you
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -163,7 +163,7 @@ Here's the list of variables available for customization:
|
||||
|
||||
## Adding new colors
|
||||
|
||||
To add new colors, you need to add them to your CSS file and to your `tailwind.config.js` file.
|
||||
To add new colors, you need to add them to your CSS file under the `:root` and `dark` pseudo-classes. Then, use the `@theme inline` directive to make the colors available as CSS variables.
|
||||
|
||||
```css title="app/globals.css" showLineNumbers
|
||||
:root {
|
||||
|
||||
@@ -49,7 +49,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Badge variant="default |outline | secondary | destructive">Badge</Badge>
|
||||
<Badge variant="default | outline | secondary | destructive">Badge</Badge>
|
||||
```
|
||||
|
||||
### Link
|
||||
@@ -57,7 +57,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
You can use the `asChild` prop to make another component look like a badge. Here's an example of a link that looks like a badge.
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Link } from "next/link"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ To use a custom link component from your routing library, you can use the `asChi
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers {1,8-10}
|
||||
import { Link } from "next/link"
|
||||
import Link from "next/link"
|
||||
|
||||
...
|
||||
|
||||
|
||||
@@ -71,7 +71,20 @@ import { Button } from "@/components/ui/button"
|
||||
<Button variant="outline">Button</Button>
|
||||
```
|
||||
|
||||
---
|
||||
## Cursor
|
||||
|
||||
Tailwind v4 [switched](https://tailwindcss.com/docs/upgrade-guide#buttons-use-the-default-cursor) from `cursor: pointer` to `cursor: default` for the button component.
|
||||
|
||||
If you want to keep the `cursor: pointer` behavior, add the following code to your CSS file:
|
||||
|
||||
```css showLineNumbers title="globals.css"
|
||||
@layer base {
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -265,7 +278,7 @@ To create a button group, use the `ButtonGroup` component. See the [Button Group
|
||||
You can use the `asChild` prop to make another component look like a button. Here's an example of a link that looks like a button.
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Link } from "next/link"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
|
||||
@@ -108,12 +108,40 @@ To use the Persian calendar, edit `components/ui/calendar.tsx` and replace `reac
|
||||
description="A Persian calendar."
|
||||
/>
|
||||
|
||||
## Selected Date (With TimeZone)
|
||||
|
||||
The Calendar component accepts a `timeZone` prop to ensure dates are displayed and selected in the user's local timezone.
|
||||
|
||||
```tsx showLineNumbers
|
||||
export function CalendarWithTimezone() {
|
||||
const [date, setDate] = React.useState<Date | undefined>(undefined)
|
||||
const [timeZone, setTimeZone] = React.useState<string | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** If you notice a selected date offset (for example, selecting the 20th highlights the 19th), make sure the `timeZone` prop is set to the user's local timezone.
|
||||
|
||||
**Why client-side?** The timezone is detected using `Intl.DateTimeFormat().resolvedOptions().timeZone` inside a `useEffect` to ensure compatibility with server-side rendering. Detecting the timezone during render would cause hydration mismatches, as the server and client may be in different timezones.
|
||||
|
||||
## Examples
|
||||
|
||||
### Range Calendar
|
||||
|
||||
<ComponentPreview
|
||||
name="calendar-02"
|
||||
name="calendar-05"
|
||||
title="Range Calendar"
|
||||
description="A calendar showing the current date and range selection."
|
||||
className="**:[.preview]:h-auto lg:**:[.preview]:h-[450px]"
|
||||
@@ -153,9 +181,36 @@ This component uses the `chrono-node` library to parse natural language dates.
|
||||
description="A calendar with natural language picker."
|
||||
/>
|
||||
|
||||
### Form
|
||||
### Custom Cell Size
|
||||
|
||||
<ComponentPreview name="calendar-form" />
|
||||
<ComponentPreview
|
||||
name="calendar-18"
|
||||
title="Custom Cell Size"
|
||||
description="A calendar with custom cell size that's responsive."
|
||||
className="**:[.preview]:h-[560px]"
|
||||
/>
|
||||
|
||||
You can customize the size of calendar cells using the `--cell-size` CSS variable. You can also make it responsive by using breakpoint-specific values:
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
className="rounded-lg border [--cell-size:--spacing(11)] md:[--cell-size:--spacing(12)]"
|
||||
/>
|
||||
```
|
||||
|
||||
Or use fixed values:
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
className="rounded-lg border [--cell-size:2.75rem] md:[--cell-size:3rem]"
|
||||
/>
|
||||
```
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
@@ -289,7 +344,10 @@ function Calendar({
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
@@ -417,3 +475,25 @@ npx shadcn@latest add calendar-02
|
||||
```
|
||||
|
||||
This will install the latest version of the calendar blocks.
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2025-10-26 Fixed day radius with week numbers
|
||||
|
||||
We have fixed an issue where the selected day's left border radius was not applied correctly when week numbers were displayed. The fix ensures that when `showWeekNumber` is enabled, the first day (which is the second child due to the week number column) correctly receives the rounded left border.
|
||||
|
||||
To apply this fix, edit `components/ui/calendar.tsx` and update the `day` class in `classNames`:
|
||||
|
||||
```tsx showLineNumbers title="components/ui/calendar.tsx" {5-7}
|
||||
classNames={{
|
||||
// ... other classNames
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
// ... other classNames
|
||||
}}
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ We designed the `chart` component with composition in mind. **You build your cha
|
||||
```tsx showLineNumbers /ChartContainer/ /ChartTooltipContent/
|
||||
import { Bar, BarChart } from "recharts"
|
||||
|
||||
import { ChartContainer, ChartTooltipContent } from "@/components/ui/charts"
|
||||
import { ChartContainer, ChartTooltipContent } from "@/components/ui/chart"
|
||||
|
||||
export function MyChart() {
|
||||
return (
|
||||
@@ -193,7 +193,7 @@ You can now build your chart using Recharts components.
|
||||
|
||||
<Callout className="mt-4 bg-amber-50 border-amber-200 dark:bg-amber-950/50 dark:border-amber-950">
|
||||
|
||||
**Important:** Remember to set a `min-h-[VALUE]` on the `ChartContainer` component. This is required for the chart be responsive.
|
||||
**Important:** Remember to set a `min-h-[VALUE]` on the `ChartContainer` component. This is required for the chart to be responsive.
|
||||
|
||||
</Callout>
|
||||
|
||||
@@ -370,7 +370,7 @@ The chart config is where you define the labels, icons and colors for a chart.
|
||||
|
||||
It is intentionally decoupled from chart data.
|
||||
|
||||
This allows you to share config and color tokens between charts. It can also works independently for cases where your data or color tokens live remotely or in a different format.
|
||||
This allows you to share config and color tokens between charts. It can also work independently for cases where your data or color tokens live remotely or in a different format.
|
||||
|
||||
```tsx showLineNumbers /ChartConfig/
|
||||
import { Monitor } from "lucide-react"
|
||||
@@ -394,7 +394,7 @@ const chartConfig = {
|
||||
|
||||
## Theming
|
||||
|
||||
Charts has built-in support for theming. You can use css variables (recommended) or color values in any color format, such as hex, hsl or oklch.
|
||||
Charts have built-in support for theming. You can use css variables (recommended) or color values in any color format, such as hex, hsl or oklch.
|
||||
|
||||
### CSS Variables
|
||||
|
||||
|
||||
@@ -56,9 +56,3 @@ import { Checkbox } from "@/components/ui/checkbox"
|
||||
```tsx
|
||||
<Checkbox />
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="checkbox-form-multiple" />
|
||||
|
||||
@@ -143,7 +143,3 @@ export function ExampleCombobox() {
|
||||
You can create a responsive combobox by using the `<Popover />` on desktop and the `<Drawer />` components on mobile.
|
||||
|
||||
<ComponentPreview name="combobox-responsive" />
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="combobox-form" />
|
||||
|
||||
@@ -94,7 +94,3 @@ This component uses the `chrono-node` library to parse natural language dates.
|
||||
title="Natural Language Picker"
|
||||
description="A calendar with natural language picker."
|
||||
/>
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="date-picker-form" />
|
||||
|
||||
@@ -10,7 +10,7 @@ links:
|
||||
|
||||
## About
|
||||
|
||||
Drawer is built on top of [Vaul](https://github.com/emilkowalski/vaul) by [emilkowalski\_](https://twitter.com/emilkowalski_).
|
||||
Drawer is built on top of [Vaul](https://github.com/emilkowalski/vaul) by [emilkowalski](https://twitter.com/emilkowalski).
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -93,3 +93,19 @@ import {
|
||||
name="dropdown-menu-radio-group"
|
||||
description="A dropdown menu with radio items."
|
||||
/>
|
||||
|
||||
### Dialog
|
||||
|
||||
This example shows how to open a dialog from a dropdown menu.
|
||||
|
||||
Use `modal={false}` on the `DropdownMenu` component.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">Actions</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</DropdownMenu>
|
||||
```
|
||||
|
||||
<ComponentPreview name="dropdown-menu-dialog" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Empty
|
||||
description: Use the Empty component to display a empty state.
|
||||
description: Use the Empty component to display an empty state.
|
||||
component: true
|
||||
---
|
||||
|
||||
@@ -57,9 +57,9 @@ import {
|
||||
<EmptyMedia variant="icon">
|
||||
<Icon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No data</EmptyTitle>
|
||||
<EmptyDescription>No data found</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyTitle>No data</EmptyTitle>
|
||||
<EmptyDescription>No data found</EmptyDescription>
|
||||
<EmptyContent>
|
||||
<Button>Add data</Button>
|
||||
</EmptyContent>
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
|
||||
### Outline
|
||||
|
||||
Use the `border` utility class to create a outline empty state.
|
||||
Use the `border` utility class to create an outline empty state.
|
||||
|
||||
<ComponentPreview
|
||||
name="empty-outline"
|
||||
|
||||
@@ -98,6 +98,10 @@ The `Field` family is designed for composing accessible forms. A typical field i
|
||||
- `FieldContent` is a flex column that groups label and description. Not required if you have no description.
|
||||
- Wrap related fields with `FieldGroup`, and use `FieldSet` with `FieldLegend` for semantic grouping.
|
||||
|
||||
## Form
|
||||
|
||||
See the [Form](/docs/forms) documentation for building forms with the `Field` component and [React Hook Form](/docs/forms/react-hook-form) or [Tanstack Form](/docs/forms/tanstack-form).
|
||||
|
||||
## Examples
|
||||
|
||||
### Input
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
---
|
||||
title: React Hook Form
|
||||
title: Form
|
||||
description: Building forms with React Hook Form and Zod.
|
||||
links:
|
||||
doc: https://react-hook-form.com
|
||||
---
|
||||
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
<Callout icon={<InfoIcon />} title="We are not actively developing this component anymore.">
|
||||
|
||||
The Form component is an abstraction over the `react-hook-form` library. Going forward, we recommend using the [`<Field />`](/docs/components/field) component to build forms. See the [Form](/docs/forms) documentation for more information.
|
||||
|
||||
</Callout>
|
||||
|
||||
Forms are tricky. They are one of the most common things you'll build in a web application, but also one of the most complex.
|
||||
|
||||
Well-designed HTML forms are:
|
||||
@@ -119,8 +127,6 @@ npm install @radix-ui/react-label @radix-ui/react-slot react-hook-form @hookform
|
||||
|
||||
## Usage
|
||||
|
||||
<Steps>
|
||||
|
||||
### Create a form schema
|
||||
|
||||
Define the shape of your form using a Zod schema. You can read more about using Zod in the [Zod documentation](https://zod.dev).
|
||||
@@ -233,23 +239,3 @@ export function ProfileForm() {
|
||||
### Done
|
||||
|
||||
That's it. You now have a fully accessible form that is type-safe with client-side validation.
|
||||
|
||||
<ComponentPreview
|
||||
name="input-form"
|
||||
className="[&_[role=tablist]]:hidden [&>div>div:first-child]:hidden"
|
||||
/>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Examples
|
||||
|
||||
See the following links for more examples on how to use the `<Form />` component with other components:
|
||||
|
||||
- [Checkbox](/docs/components/checkbox#form)
|
||||
- [Date Picker](/docs/components/date-picker#form)
|
||||
- [Input](/docs/components/input#form)
|
||||
- [Radio Group](/docs/components/radio-group#form)
|
||||
- [Select](/docs/components/select#form)
|
||||
- [Switch](/docs/components/switch#form)
|
||||
- [Textarea](/docs/components/textarea#form)
|
||||
- [Combobox](/docs/components/combobox#form)
|
||||
|
||||
@@ -4,3 +4,7 @@ description: Here you can find all the components available in the library. We a
|
||||
---
|
||||
|
||||
<ComponentsList />
|
||||
|
||||
---
|
||||
|
||||
Can't find what you need? Try the [registry directory](/docs/directory) for community-maintained components.
|
||||
|
||||
@@ -253,3 +253,9 @@ All other props are passed through to the underlying `<Textarea />` component.
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2025-10-06 `InputGroup`
|
||||
|
||||
Add the `min-w-0` class to the `InputGroup` component. See [diff](https://github.com/shadcn-ui/ui/pull/8341/files#diff-0e2ee95d0050ca4c5d82339df86c54e14a6739dc4638fdda0eec8f73aebc2da9).
|
||||
|
||||
@@ -94,10 +94,6 @@ import { Input } from "@/components/ui/input"
|
||||
description="An input component with a button."
|
||||
/>
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="input-form" />
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2025-09-18 Remove `flex` class
|
||||
|
||||
205
apps/v4/content/docs/components/native-select.mdx
Normal file
205
apps/v4/content/docs/components/native-select.mdx
Normal file
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: Native Select
|
||||
description: A styled native HTML select element with consistent design system integration.
|
||||
component: true
|
||||
---
|
||||
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
<Callout variant="info" icon={<InfoIcon className="!translate-y-[3px]" />}>
|
||||
For a styled select component, see the [Select](/docs/components/select)
|
||||
component.
|
||||
</Callout>
|
||||
|
||||
<ComponentPreview name="native-select-demo" />
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">CLI</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add native-select
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource name="native-select" title="components/ui/native-select.tsx" />
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
import {
|
||||
NativeSelect,
|
||||
NativeSelectOptGroup,
|
||||
NativeSelectOption,
|
||||
} from "@/components/ui/native-select"
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
<NativeSelect>
|
||||
<NativeSelectOption value="">Select a fruit</NativeSelectOption>
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana">Banana</NativeSelectOption>
|
||||
<NativeSelectOption value="blueberry">Blueberry</NativeSelectOption>
|
||||
<NativeSelectOption value="grapes" disabled>
|
||||
Grapes
|
||||
</NativeSelectOption>
|
||||
<NativeSelectOption value="pineapple">Pineapple</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### With Groups
|
||||
|
||||
Organize options using `NativeSelectOptGroup` for better categorization.
|
||||
|
||||
<ComponentPreview name="native-select-groups" />
|
||||
|
||||
```tsx showLineNumbers
|
||||
<NativeSelect>
|
||||
<NativeSelectOption value="">Select a food</NativeSelectOption>
|
||||
<NativeSelectOptGroup label="Fruits">
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana">Banana</NativeSelectOption>
|
||||
<NativeSelectOption value="blueberry">Blueberry</NativeSelectOption>
|
||||
</NativeSelectOptGroup>
|
||||
<NativeSelectOptGroup label="Vegetables">
|
||||
<NativeSelectOption value="carrot">Carrot</NativeSelectOption>
|
||||
<NativeSelectOption value="broccoli">Broccoli</NativeSelectOption>
|
||||
<NativeSelectOption value="spinach">Spinach</NativeSelectOption>
|
||||
</NativeSelectOptGroup>
|
||||
</NativeSelect>
|
||||
```
|
||||
|
||||
### Disabled State
|
||||
|
||||
Disable individual options or the entire select component.
|
||||
|
||||
<ComponentPreview name="native-select-disabled" />
|
||||
|
||||
### Invalid State
|
||||
|
||||
Show validation errors with the `aria-invalid` attribute and error styling.
|
||||
|
||||
<ComponentPreview name="native-select-invalid" />
|
||||
|
||||
```tsx showLineNumbers
|
||||
<NativeSelect aria-invalid="true">
|
||||
<NativeSelectOption value="">Select a country</NativeSelectOption>
|
||||
<NativeSelectOption value="us">United States</NativeSelectOption>
|
||||
<NativeSelectOption value="uk">United Kingdom</NativeSelectOption>
|
||||
<NativeSelectOption value="ca">Canada</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
```
|
||||
|
||||
### Form Integration
|
||||
|
||||
Use with form libraries like React Hook Form for controlled components.
|
||||
|
||||
<ComponentPreview name="native-select-form" />
|
||||
|
||||
### Input Group Integration
|
||||
|
||||
Combine with `InputGroup` for complex input layouts.
|
||||
|
||||
<ComponentPreview name="native-select-input-group" />
|
||||
|
||||
## Native Select vs Select
|
||||
|
||||
- Use `NativeSelect` when you need native browser behavior, better performance, or mobile-optimized dropdowns.
|
||||
- Use `Select` when you need custom styling, animations, or complex interactions.
|
||||
|
||||
The `NativeSelect` component provides native HTML select functionality with consistent styling that matches your design system.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- The component maintains all native HTML select accessibility features.
|
||||
- Screen readers can navigate through options using arrow keys.
|
||||
- The chevron icon is marked as `aria-hidden="true"` to avoid duplication.
|
||||
- Use `aria-label` or `aria-labelledby` for additional context when needed.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<NativeSelect aria-label="Choose your preferred language">
|
||||
<NativeSelectOption value="en">English</NativeSelectOption>
|
||||
<NativeSelectOption value="es">Spanish</NativeSelectOption>
|
||||
<NativeSelectOption value="fr">French</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### NativeSelect
|
||||
|
||||
The main select component that wraps the native HTML select element.
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ----------- | -------- | ------- |
|
||||
| `className` | `string` | |
|
||||
|
||||
All other props are passed through to the underlying `<select>` element.
|
||||
|
||||
```tsx
|
||||
<NativeSelect>
|
||||
<NativeSelectOption value="option1">Option 1</NativeSelectOption>
|
||||
<NativeSelectOption value="option2">Option 2</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
```
|
||||
|
||||
### NativeSelectOption
|
||||
|
||||
Represents an individual option within the select.
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ----------- | --------- | ------- |
|
||||
| `value` | `string` | |
|
||||
| `disabled` | `boolean` | `false` |
|
||||
| `className` | `string` | |
|
||||
|
||||
All other props are passed through to the underlying `<option>` element.
|
||||
|
||||
```tsx
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana" disabled>
|
||||
Banana
|
||||
</NativeSelectOption>
|
||||
```
|
||||
|
||||
### NativeSelectOptGroup
|
||||
|
||||
Groups related options together for better organization.
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ----------- | --------- | ------- |
|
||||
| `label` | `string` | |
|
||||
| `disabled` | `boolean` | `false` |
|
||||
| `className` | `string` | |
|
||||
|
||||
All other props are passed through to the underlying `<optgroup>` element.
|
||||
|
||||
```tsx
|
||||
<NativeSelectOptGroup label="Fruits">
|
||||
<NativeSelectOption value="apple">Apple</NativeSelectOption>
|
||||
<NativeSelectOption value="banana">Banana</NativeSelectOption>
|
||||
</NativeSelectOptGroup>
|
||||
```
|
||||
@@ -7,7 +7,10 @@ links:
|
||||
api: https://www.radix-ui.com/docs/primitives/components/navigation-menu#api-reference
|
||||
---
|
||||
|
||||
<ComponentPreview name="navigation-menu-demo" />
|
||||
<ComponentPreview
|
||||
name="navigation-menu-demo"
|
||||
className="[&_.preview]:!items-start [&_.preview]:p-4 [&_.preview]:pt-8 md:[&_.preview]:pt-16"
|
||||
/>
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -83,7 +86,7 @@ import {
|
||||
You can use the `asChild` prop to make another component look like a navigation menu trigger. Here's an example of a link that looks like a navigation menu trigger.
|
||||
|
||||
```tsx showLineNumbers title="components/example-navigation-menu.tsx"
|
||||
import { Link } from "next/link"
|
||||
import Link from "next/link"
|
||||
|
||||
export function NavigationMenuDemo() {
|
||||
return (
|
||||
|
||||
@@ -69,9 +69,3 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
</div>
|
||||
</RadioGroup>
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="radio-group-form" />
|
||||
|
||||
@@ -84,7 +84,3 @@ import {
|
||||
name="select-scrollable"
|
||||
description="A select component with a scrollable list of options."
|
||||
/>
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="select-form" />
|
||||
|
||||
@@ -10,7 +10,7 @@ links:
|
||||
|
||||
## About
|
||||
|
||||
Sonner is built and maintained by [emilkowalski\_](https://twitter.com/emilkowalski_).
|
||||
Sonner is built and maintained by [emilkowalski](https://twitter.com/emilkowalski).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -68,7 +68,7 @@ npm install sonner next-themes
|
||||
|
||||
<Step>Add the Toaster component</Step>
|
||||
|
||||
```tsx title="app/layout.tsx" {1,9}
|
||||
```tsx showLineNumbers title="app/layout.tsx" {1,8}
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
@@ -76,8 +76,8 @@ export default function RootLayout({ children }) {
|
||||
<html lang="en">
|
||||
<head />
|
||||
<body>
|
||||
<main>{children}</main>
|
||||
<Toaster />
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
@@ -99,3 +99,56 @@ import { toast } from "sonner"
|
||||
```tsx
|
||||
toast("Event has been created.")
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
<ComponentPreview name="sonner-types" />
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2025-10-13 Icons
|
||||
|
||||
We've updated the Sonner component to use icons from `lucide`. Update your `sonner.tsx` file to use the new icons.
|
||||
|
||||
```tsx showLineNumbers title="components/ui/sonner.tsx" {3-9,20-26}
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
```
|
||||
|
||||
@@ -56,9 +56,3 @@ import { Switch } from "@/components/ui/switch"
|
||||
```tsx
|
||||
<Switch />
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="switch-form" />
|
||||
|
||||
@@ -79,7 +79,3 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
name="textarea-with-button"
|
||||
description="A textarea with a button"
|
||||
/>
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentPreview name="textarea-form" />
|
||||
|
||||
@@ -7,10 +7,7 @@ links:
|
||||
api: https://www.radix-ui.com/docs/primitives/components/toggle-group#api-reference
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
name="toggle-group-demo"
|
||||
description="A toggle group with three items."
|
||||
/>
|
||||
<ComponentPreview name="toggle-group-spacing" />
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -66,13 +63,6 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
|
||||
## Examples
|
||||
|
||||
### Default
|
||||
|
||||
<ComponentPreview
|
||||
name="toggle-group-demo"
|
||||
description="A toggle group with three items."
|
||||
/>
|
||||
|
||||
### Outline
|
||||
|
||||
<ComponentPreview
|
||||
@@ -107,3 +97,42 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
name="toggle-group-disabled"
|
||||
description="A disabled toggle group."
|
||||
/>
|
||||
|
||||
### Spacing
|
||||
|
||||
Use `spacing={2}` to add spacing between toggle group items.
|
||||
|
||||
<ComponentPreview
|
||||
name="toggle-group-spacing"
|
||||
description="A toggle group with spacing."
|
||||
/>
|
||||
|
||||
## API Reference
|
||||
|
||||
### ToggleGroup
|
||||
|
||||
The main component that wraps toggle group items.
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ----------- | --------------------------- | ----------- |
|
||||
| `type` | `"single" \| "multiple"` | `"single"` |
|
||||
| `variant` | `"default" \| "outline"` | `"default"` |
|
||||
| `size` | `"default" \| "sm" \| "lg"` | `"default"` |
|
||||
| `spacing` | `number` | `0` |
|
||||
| `className` | `string` | |
|
||||
|
||||
```tsx
|
||||
<ToggleGroup type="single" variant="outline" size="sm">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
```
|
||||
|
||||
### ToggleGroupItem
|
||||
|
||||
Individual toggle items within a toggle group. Remember to add an `aria-label` to each item for accessibility.
|
||||
|
||||
| Prop | Type | Default |
|
||||
| ----------- | -------- | -------- |
|
||||
| `value` | `string` | Required |
|
||||
| `className` | `string` | |
|
||||
|
||||
45
apps/v4/content/docs/forms/index.mdx
Normal file
45
apps/v4/content/docs/forms/index.mdx
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Forms
|
||||
description: Build forms with React and shadcn/ui.
|
||||
---
|
||||
|
||||
import { ClipboardListIcon, InfoIcon } from "lucide-react"
|
||||
|
||||
## Pick Your Framework
|
||||
|
||||
Start by selecting your framework. Then follow the instructions to learn how to build forms with shadcn/ui and the form library of your choice.
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 sm:gap-6">
|
||||
<LinkedCard href="/docs/forms/react-hook-form">
|
||||
<ClipboardListIcon className="size-10" />
|
||||
<p className="mt-2 font-medium">React Hook Form</p>
|
||||
</LinkedCard>
|
||||
<LinkedCard href="/docs/forms/tanstack-form">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
className="size-10"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6.93 13.688a.343.343 0 0 1 .468.132l.063.106c.48.851.98 1.66 1.5 2.426a35.65 35.65 0 0 0 2.074 2.742.345.345 0 0 1-.039.484l-.074.066c-2.543 2.223-4.191 2.665-4.953 1.333-.746-1.305-.477-3.672.808-7.11a.344.344 0 0 1 .153-.18ZM17.75 16.3a.34.34 0 0 1 .395.27l.02.1c.628 3.286.187 4.93-1.325 4.93-1.48 0-3.36-1.402-5.649-4.203a.327.327 0 0 1-.074-.222c0-.188.156-.34.344-.34h.121a32.984 32.984 0 0 0 2.809-.098c1.07-.086 2.191-.23 3.359-.437zm.871-6.977a.353.353 0 0 1 .445-.21l.102.034c3.262 1.11 4.504 2.332 3.719 3.664-.766 1.305-2.993 2.254-6.684 2.848a.362.362 0 0 1-.238-.047.343.343 0 0 1-.125-.476l.062-.106a34.07 34.07 0 0 0 1.367-2.523c.477-.989.93-2.051 1.352-3.184zM7.797 8.34a.362.362 0 0 1 .238.047.343.343 0 0 1 .125.476l-.062.106a34.088 34.088 0 0 0-1.367 2.523c-.477.988-.93 2.051-1.352 3.184a.353.353 0 0 1-.445.21l-.102-.034C1.57 13.742.328 12.52 1.113 11.188 1.88 9.883 4.106 8.934 7.797 8.34Zm5.281-3.984c2.543-2.223 4.192-2.664 4.953-1.332.746 1.304.477 3.671-.808 7.109a.344.344 0 0 1-.153.18.343.343 0 0 1-.468-.133l-.063-.106a34.64 34.64 0 0 0-1.5-2.426 35.65 35.65 0 0 0-2.074-2.742.345.345 0 0 1 .039-.484ZM7.285 2.274c1.48 0 3.364 1.402 5.649 4.203a.349.349 0 0 1 .078.218.348.348 0 0 1-.348.344l-.117-.004a34.584 34.584 0 0 0-2.809.102 35.54 35.54 0 0 0-3.363.437.343.343 0 0 1-.394-.273l-.02-.098c-.629-3.285-.188-4.93 1.324-4.93Zm2.871 5.812h3.688a.638.638 0 0 1 .55.316l1.848 3.22a.644.644 0 0 1 0 .628l-1.847 3.223a.638.638 0 0 1-.551.316h-3.688a.627.627 0 0 1-.547-.316L7.758 12.25a.644.644 0 0 1 0-.629L9.61 8.402a.627.627 0 0 1 .546-.316Zm3.23.793a.638.638 0 0 1 .552.316l1.39 2.426a.644.644 0 0 1 0 .629l-1.39 2.43a.638.638 0 0 1-.551.316h-2.774a.627.627 0 0 1-.546-.316l-1.395-2.43a.644.644 0 0 1 0-.629l1.395-2.426a.627.627 0 0 1 .546-.316Zm-.491.867h-1.79a.624.624 0 0 0-.546.316l-.899 1.56a.644.644 0 0 0 0 .628l.899 1.563a.632.632 0 0 0 .547.316h1.789a.632.632 0 0 0 .547-.316l.898-1.563a.644.644 0 0 0 0-.629l-.898-1.558a.624.624 0 0 0-.547-.317Zm-.477.828c.227 0 .438.121.547.317l.422.73a.625.625 0 0 1 0 .629l-.422.734a.627.627 0 0 1-.547.317h-.836a.632.632 0 0 1-.547-.317l-.422-.734a.625.625 0 0 1 0-.629l.422-.73a.632.632 0 0 1 .547-.317zm-.418.817a.548.548 0 0 0-.473.273.547.547 0 0 0 0 .547.544.544 0 0 0 .473.27.544.544 0 0 0 .473-.27.547.547 0 0 0 0-.547.548.548 0 0 0-.473-.273Zm-4.422.546h.98M18.98 7.75c.391-1.895.477-3.344.223-4.398-.148-.63-.422-1.137-.84-1.508-.441-.39-1-.582-1.625-.582-1.035 0-2.12.472-3.281 1.367a14.9 14.9 0 0 0-1.473 1.316 1.206 1.206 0 0 0-.136-.144c-1.446-1.285-2.66-2.082-3.7-2.39-.617-.184-1.195-.2-1.722-.024-.559.187-1.004.574-1.317 1.117-.515.894-.652 2.074-.46 3.527.078.59.214 1.235.402 1.934a1.119 1.119 0 0 0-.215.047C3.008 8.62 1.71 9.269.926 10.015c-.465.442-.77.938-.883 1.481-.113.578 0 1.156.312 1.7.516.894 1.465 1.597 2.817 2.155.543.223 1.156.426 1.844.61a1.023 1.023 0 0 0-.07.226c-.391 1.891-.477 3.344-.223 4.395.148.629.425 1.14.84 1.508.44.39 1 .582 1.625.582 1.035 0 2.12-.473 3.28-1.364.477-.37.973-.816 1.489-1.336a1.2 1.2 0 0 0 .195.227c1.446 1.285 2.66 2.082 3.7 2.39.617.184 1.195.2 1.722.024.559-.187 1.004-.574 1.317-1.117.515-.894.652-2.074.46-3.527a14.941 14.941 0 0 0-.425-2.012 1.225 1.225 0 0 0 .238-.047c1.828-.61 3.125-1.258 3.91-2.004.465-.441.77-.937.883-1.48.113-.578 0-1.157-.313-1.7-.515-.894-1.464-1.597-2.816-2.156a14.576 14.576 0 0 0-1.906-.625.865.865 0 0 0 .059-.195z" />
|
||||
</svg>
|
||||
<p className="mt-2 font-medium">TanStack Form</p>
|
||||
</LinkedCard>
|
||||
<LinkedCard href="#" className="border border-dashed bg-transparent">
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className="size-10"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>React</title>
|
||||
<path
|
||||
d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 font-medium">useActionState</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">(Coming Soon)</p>
|
||||
|
||||
</LinkedCard>
|
||||
</div>
|
||||
3
apps/v4/content/docs/forms/meta.json
Normal file
3
apps/v4/content/docs/forms/meta.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": ["react-hook-form", "tanstack-form"]
|
||||
}
|
||||
397
apps/v4/content/docs/forms/next.mdx
Normal file
397
apps/v4/content/docs/forms/next.mdx
Normal file
@@ -0,0 +1,397 @@
|
||||
---
|
||||
title: Next.js
|
||||
description: Build forms in React using useActionState and Server Actions.
|
||||
---
|
||||
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
In this guide, we will take a look at building forms with Next.js using `useActionState` and Server Actions. We'll cover building forms, validation, pending states, accessibility, and more.
|
||||
|
||||
## Demo
|
||||
|
||||
We are going to build the following form with a simple text input and a textarea. On submit, we'll use a server action to validate the form data and update the form state.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-next-demo"
|
||||
className="[&_.preview]:h-[700px] [&_pre]:!h-[700px]"
|
||||
/>
|
||||
|
||||
<Callout icon={<InfoIcon />}>
|
||||
**Note:** The examples on this page intentionally disable browser validation
|
||||
to show how schema validation and form errors work in server actions.
|
||||
</Callout>
|
||||
|
||||
## Approach
|
||||
|
||||
This form leverages Next.js and React's built-in capabilities for form handling. We'll build our form using the `<Field />` component, which gives you **complete flexibility over the markup and styling**.
|
||||
|
||||
- Uses Next.js `<Form />` component for navigation and progressive enhancement.
|
||||
- `<Field />` components for building accessible forms.
|
||||
- `useActionState` for managing form state and errors.
|
||||
- Handles loading states with pending prop.
|
||||
- Server Actions for handling form submissions.
|
||||
- Server-side validation using Zod.
|
||||
|
||||
## Anatomy
|
||||
|
||||
Here's a basic example of a form using the `<Field />` component.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Form action={formAction}>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!formState.errors?.title?.length}>
|
||||
<FieldLabel htmlFor="title">Bug Title</FieldLabel>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
defaultValue={formState.values.title}
|
||||
disabled={pending}
|
||||
aria-invalid={!!formState.errors?.title?.length}
|
||||
placeholder="Login button not working on mobile"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Provide a concise title for your bug report.
|
||||
</FieldDescription>
|
||||
{formState.errors?.title && (
|
||||
<FieldError>{formState.errors.title[0]}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<Button type="submit">Submit</Button>
|
||||
</Form>
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Create a form schema
|
||||
|
||||
We'll start by defining the shape of our form using a Zod schema in a `schema.ts` file.
|
||||
|
||||
<Callout icon={<InfoIcon />}>
|
||||
**Note:** This example uses `zod v3` for schema validation, but you can
|
||||
replace it with any other schema validation library. Make sure your schema
|
||||
library conforms to the Standard Schema specification.
|
||||
</Callout>
|
||||
|
||||
```tsx showLineNumbers title="schema.ts"
|
||||
import { z } from "zod"
|
||||
|
||||
export const formSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(5, "Bug title must be at least 5 characters.")
|
||||
.max(32, "Bug title must be at most 32 characters."),
|
||||
description: z
|
||||
.string()
|
||||
.min(20, "Description must be at least 20 characters.")
|
||||
.max(100, "Description must be at most 100 characters."),
|
||||
})
|
||||
```
|
||||
|
||||
### Define the form state type
|
||||
|
||||
Next, we'll create a type for our form state that includes values, errors, and success status. This will be used to type the form state on the client and server.
|
||||
|
||||
```tsx showLineNumbers title="schema.ts"
|
||||
import { z } from "zod"
|
||||
|
||||
export type FormState = {
|
||||
values?: z.infer<typeof formSchema>
|
||||
errors: null | Partial<Record<keyof z.infer<typeof formSchema>, string[]>>
|
||||
success: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** We define the schema and the `FormState` type in a separate file so we can import them into both the client and server components.
|
||||
|
||||
### Create the Server Action
|
||||
|
||||
A server action is a function that runs on the server and can be called from the client. We'll use it to validate the form data and update the form state.
|
||||
|
||||
<ComponentSource
|
||||
src="/registry/new-york-v4/examples/form-next-demo-action.ts"
|
||||
title="actions.ts"
|
||||
/>
|
||||
|
||||
**Note:** We're returning `values` for error cases. This is because we want to keep the user submitted values in the form state. For success cases, we're returning empty values to reset the form.
|
||||
|
||||
### Build the form
|
||||
|
||||
We can now build the form using the `<Field />` component. We'll use the `useActionState` hook to manage the form state, server action, and pending state.
|
||||
|
||||
<ComponentSource
|
||||
src="/registry/new-york-v4/examples/form-next-demo.tsx"
|
||||
title="form.tsx"
|
||||
/>
|
||||
|
||||
### Done
|
||||
|
||||
That's it. You now have a fully accessible form with client and server-side validation.
|
||||
|
||||
When you submit the form, the `formAction` function will be called on the server. The server action will validate the form data and update the form state.
|
||||
|
||||
If the form data is invalid, the server action will return the errors to the client. If the form data is valid, the server action will return the success status and update the form state.
|
||||
|
||||
## Pending States
|
||||
|
||||
Use the `pending` prop from `useActionState` to show loading indicators and disable form inputs.
|
||||
|
||||
```tsx showLineNumbers {11,26-34}
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Form from "next/form"
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
import { bugReportFormAction } from "./actions"
|
||||
|
||||
export function BugReportForm() {
|
||||
const [formState, formAction, pending] = React.useActionState(
|
||||
bugReportFormAction,
|
||||
{
|
||||
errors: null,
|
||||
success: false,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Form action={formAction}>
|
||||
<FieldGroup>
|
||||
<Field data-disabled={pending}>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" disabled={pending} />
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Spinner />} Submit
|
||||
</Button>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Disabled States
|
||||
|
||||
### Submit Button
|
||||
|
||||
To disable the submit button, use the `pending` prop on the button's `disabled` prop.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending && <Spinner />} Submit
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Field
|
||||
|
||||
To apply a disabled state and styling to a `<Field />` component, use the `data-disabled` prop on the `<Field />` component.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Field data-disabled={pending}>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" disabled={pending} />
|
||||
</Field>
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
### Server-side Validation
|
||||
|
||||
Use `safeParse()` on your schema in your server action to validate the form data.
|
||||
|
||||
```tsx showLineNumbers title="actions.ts" {12-20}
|
||||
"use server"
|
||||
|
||||
export async function bugReportFormAction(
|
||||
_prevState: FormState,
|
||||
formData: FormData
|
||||
) {
|
||||
const values = {
|
||||
title: formData.get("title") as string,
|
||||
description: formData.get("description") as string,
|
||||
}
|
||||
|
||||
const result = formSchema.safeParse(values)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
values,
|
||||
success: false,
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors: null,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Business Logic Validation
|
||||
|
||||
You can add additional custom validation logic in your server action.
|
||||
|
||||
Make sure to return the values on validation errors. This is to ensure that the form state maintains the user's input.
|
||||
|
||||
```tsx showLineNumbers title="actions.ts" {22-35}
|
||||
"use server"
|
||||
|
||||
export async function bugReportFormAction(
|
||||
_prevState: FormState,
|
||||
formData: FormData
|
||||
) {
|
||||
const values = {
|
||||
title: formData.get("title") as string,
|
||||
description: formData.get("description") as string,
|
||||
}
|
||||
|
||||
const result = formSchema.safeParse(values)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
values,
|
||||
success: false,
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if email already exists in database.
|
||||
const existingUser = await db.user.findUnique({
|
||||
where: { email: result.data.email },
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
values,
|
||||
success: false,
|
||||
errors: {
|
||||
email: ["This email is already registered"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors: null,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Displaying Errors
|
||||
|
||||
Display errors next to the field using `<FieldError />`. Make sure to add the `data-invalid` prop to the `<Field />` component and `aria-invalid` prop to the input.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Field data-invalid={!!formState.errors?.email?.length}>
|
||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
aria-invalid={!!formState.errors?.email?.length}
|
||||
/>
|
||||
{formState.errors?.email && (
|
||||
<FieldError>{formState.errors.email[0]}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
```
|
||||
|
||||
## Resetting the Form
|
||||
|
||||
When you submit a form with a server action, React will automatically reset the form state to the initial values.
|
||||
|
||||
### Reset on Success
|
||||
|
||||
To reset the form on success, you can omit the `values` from the server action and React will automatically reset the form state to the initial values. This is standard React behavior.
|
||||
|
||||
```tsx showLineNumbers title="actions.ts" {22-26}
|
||||
export async function demoFormAction(
|
||||
_prevState: FormState,
|
||||
formData: FormData
|
||||
) {
|
||||
const values = {
|
||||
title: formData.get("title") as string,
|
||||
description: formData.get("description") as string,
|
||||
}
|
||||
|
||||
// Validation.
|
||||
if (!result.success) {
|
||||
return {
|
||||
values,
|
||||
success: false,
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// Business logic.
|
||||
callYourDatabaseOrAPI(values)
|
||||
|
||||
// Omit the values on success to reset the form state.
|
||||
return {
|
||||
errors: null,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Preserve on Validation Errors
|
||||
|
||||
To prevent the form from being reset on failure, you can return the values in the server action. This is to ensure that the form state maintains the user's input.
|
||||
|
||||
```tsx showLineNumbers title="actions.ts" {12-17}
|
||||
export async function demoFormAction(
|
||||
_prevState: FormState,
|
||||
formData: FormData
|
||||
) {
|
||||
const values = {
|
||||
title: formData.get("title") as string,
|
||||
description: formData.get("description") as string,
|
||||
}
|
||||
|
||||
// Validation.
|
||||
if (!result.success) {
|
||||
return {
|
||||
// Return the values on validation errors.
|
||||
values,
|
||||
success: false,
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complex Forms
|
||||
|
||||
Here is an example of a more complex form with multiple fields and validation.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-next-complex"
|
||||
className="[&_.preview]:h-[1100px] [&_pre]:!h-[1100px]"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
### Schema
|
||||
|
||||
<ComponentSource
|
||||
src="/registry/new-york-v4/examples/form-next-complex-schema.ts"
|
||||
title="schema.ts"
|
||||
/>
|
||||
|
||||
### Form
|
||||
|
||||
<ComponentSource
|
||||
src="/registry/new-york-v4/examples/form-next-complex.tsx"
|
||||
title="form.tsx"
|
||||
/>
|
||||
|
||||
### Server Action
|
||||
|
||||
<ComponentSource
|
||||
src="/registry/new-york-v4/examples/form-next-complex-action.ts"
|
||||
title="actions.ts"
|
||||
/>
|
||||
629
apps/v4/content/docs/forms/react-hook-form.mdx
Normal file
629
apps/v4/content/docs/forms/react-hook-form.mdx
Normal file
@@ -0,0 +1,629 @@
|
||||
---
|
||||
title: React Hook Form
|
||||
description: Build forms in React using React Hook Form and Zod.
|
||||
links:
|
||||
doc: https://react-hook-form.com
|
||||
---
|
||||
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
In this guide, we will take a look at building forms with React Hook Form. We'll cover building forms with the `<Field />` component, adding schema validation using Zod, error handling, accessibility, and more.
|
||||
|
||||
## Demo
|
||||
|
||||
We are going to build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.
|
||||
|
||||
<Callout icon={<InfoIcon />}>
|
||||
**Note:** For the purpose of this demo, we have intentionally disabled browser
|
||||
validation to show how schema validation and form errors work in React Hook
|
||||
Form. It is recommended to add basic browser validation in your production
|
||||
code.
|
||||
</Callout>
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-demo"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
## Approach
|
||||
|
||||
This form leverages React Hook Form for performant, flexible form handling. We'll build our form using the `<Field />` component, which gives you **complete flexibility over the markup and styling**.
|
||||
|
||||
- Uses React Hook Form's `useForm` hook for form state management.
|
||||
- `<Controller />` component for controlled inputs.
|
||||
- `<Field />` components for building accessible forms.
|
||||
- Client-side validation using Zod with `zodResolver`.
|
||||
|
||||
## Anatomy
|
||||
|
||||
Here's a basic example of a form using the `<Controller />` component from React Hook Form and the `<Field />` component.
|
||||
|
||||
```tsx showLineNumbers {5-18}
|
||||
<Controller
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="Login button not working on mobile"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Provide a concise title for your bug report.
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Form
|
||||
|
||||
### Create a form schema
|
||||
|
||||
We'll start by defining the shape of our form using a Zod schema
|
||||
|
||||
<Callout icon={<InfoIcon />}>
|
||||
**Note:** This example uses `zod v3` for schema validation, but you can
|
||||
replace it with any other Standard Schema validation library supported by
|
||||
React Hook Form.
|
||||
</Callout>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
import * as z from "zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(5, "Bug title must be at least 5 characters.")
|
||||
.max(32, "Bug title must be at most 32 characters."),
|
||||
description: z
|
||||
.string()
|
||||
.min(20, "Description must be at least 20 characters.")
|
||||
.max(100, "Description must be at most 100 characters."),
|
||||
})
|
||||
```
|
||||
|
||||
### Setup the form
|
||||
|
||||
Next, we'll use the `useForm` hook from React Hook Form to create our form instance. We'll also add the Zod resolver to validate the form data.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {17-23}
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import * as z from "zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(5, "Bug title must be at least 5 characters.")
|
||||
.max(32, "Bug title must be at most 32 characters."),
|
||||
description: z
|
||||
.string()
|
||||
.min(20, "Description must be at least 20 characters.")
|
||||
.max(100, "Description must be at most 100 characters."),
|
||||
})
|
||||
|
||||
export function BugReportForm() {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
// Do something with the form values.
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{/* ... */}
|
||||
{/* Build the form here */}
|
||||
{/* ... */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Build the form
|
||||
|
||||
We can now build the form using the `<Controller />` component from React Hook Form and the `<Field />` component.
|
||||
|
||||
<ComponentSource
|
||||
src="/registry/new-york-v4/examples/form-rhf-demo.tsx"
|
||||
title="form.tsx"
|
||||
/>
|
||||
|
||||
### Done
|
||||
|
||||
That's it. You now have a fully accessible form with client-side validation.
|
||||
|
||||
When you submit the form, the `onSubmit` function will be called with the validated form data. If the form data is invalid, React Hook Form will display the errors next to each field.
|
||||
|
||||
## Validation
|
||||
|
||||
### Client-side Validation
|
||||
|
||||
React Hook Form validates your form data using the Zod schema. Define a schema and pass it to the `resolver` option of the `useForm` hook.
|
||||
|
||||
```tsx showLineNumbers title="example-form.tsx" {5-8,12}
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import * as z from "zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export function ExampleForm() {
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Modes
|
||||
|
||||
React Hook Form supports different validation modes.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {3}
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onChange",
|
||||
})
|
||||
```
|
||||
|
||||
| Mode | Description |
|
||||
| ------------- | -------------------------------------------------------- |
|
||||
| `"onChange"` | Validation triggers on every change. |
|
||||
| `"onBlur"` | Validation triggers on blur. |
|
||||
| `"onSubmit"` | Validation triggers on submit (default). |
|
||||
| `"onTouched"` | Validation triggers on first blur, then on every change. |
|
||||
| `"all"` | Validation triggers on blur and change. |
|
||||
|
||||
## Displaying Errors
|
||||
|
||||
Display errors next to the field using `<FieldError />`. For styling and accessibility:
|
||||
|
||||
- Add the `data-invalid` prop to the `<Field />` component.
|
||||
- Add the `aria-invalid` prop to the form control such as `<Input />`, `<SelectTrigger />`, `<Checkbox />`, etc.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {5,11,13}
|
||||
<Controller
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id={field.name}
|
||||
type="email"
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Working with Different Field Types
|
||||
|
||||
### Input
|
||||
|
||||
- For input fields, spread the `field` object onto the `<Input />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Input />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-input"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
For simple text inputs, spread the `field` object onto the input.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {5,7,8}
|
||||
<Controller
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
|
||||
<Input {...field} id={field.name} aria-invalid={fieldState.invalid} />
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Textarea
|
||||
|
||||
- For textarea fields, spread the `field` object onto the `<Textarea />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Textarea />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-textarea"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
For textarea fields, spread the `field` object onto the textarea.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {5,10,18}
|
||||
<Controller
|
||||
name="about"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor="form-rhf-textarea-about">More about you</FieldLabel>
|
||||
<Textarea
|
||||
{...field}
|
||||
id="form-rhf-textarea-about"
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="I'm a software engineer..."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Tell us more about yourself. This will be used to help us personalize
|
||||
your experience.
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Select
|
||||
|
||||
- For select components, use `field.value` and `field.onChange` on the `<Select />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<SelectTrigger />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-select"
|
||||
className="sm:[&_.preview]:h-[500px] sm:[&_pre]:!h-[500px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {5,13,22}
|
||||
<Controller
|
||||
name="language"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field orientation="responsive" data-invalid={fieldState.invalid}>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="form-rhf-select-language">
|
||||
Spoken Language
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
For best results, select the language you speak.
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</FieldContent>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="form-rhf-select-language"
|
||||
aria-invalid={fieldState.invalid}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="item-aligned">
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Checkbox
|
||||
|
||||
- For checkbox arrays, use `field.value` and `field.onChange` with array manipulation.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Checkbox />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
- Remember to add `data-slot="checkbox-group"` to the `<FieldGroup />` component for proper styling and spacing.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-checkbox"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {10,15,20-22,38}
|
||||
<Controller
|
||||
name="tasks"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Tasks</FieldLegend>
|
||||
<FieldDescription>
|
||||
Get notified when tasks you've created have updates.
|
||||
</FieldDescription>
|
||||
<FieldGroup data-slot="checkbox-group">
|
||||
{tasks.map((task) => (
|
||||
<Field
|
||||
key={task.id}
|
||||
orientation="horizontal"
|
||||
data-invalid={fieldState.invalid}
|
||||
>
|
||||
<Checkbox
|
||||
id={`form-rhf-checkbox-${task.id}`}
|
||||
name={field.name}
|
||||
aria-invalid={fieldState.invalid}
|
||||
checked={field.value.includes(task.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newValue = checked
|
||||
? [...field.value, task.id]
|
||||
: field.value.filter((value) => value !== task.id)
|
||||
field.onChange(newValue)
|
||||
}}
|
||||
/>
|
||||
<FieldLabel
|
||||
htmlFor={`form-rhf-checkbox-${task.id}`}
|
||||
className="font-normal"
|
||||
>
|
||||
{task.label}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Radio Group
|
||||
|
||||
- For radio groups, use `field.value` and `field.onChange` on the `<RadioGroup />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<RadioGroupItem />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-radiogroup"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {12-13,17,25,31}
|
||||
<Controller
|
||||
name="plan"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FieldSet>
|
||||
<FieldLegend>Plan</FieldLegend>
|
||||
<FieldDescription>
|
||||
You can upgrade or downgrade your plan at any time.
|
||||
</FieldDescription>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<FieldLabel key={plan.id} htmlFor={`form-rhf-radiogroup-${plan.id}`}>
|
||||
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
|
||||
<FieldContent>
|
||||
<FieldTitle>{plan.title}</FieldTitle>
|
||||
<FieldDescription>{plan.description}</FieldDescription>
|
||||
</FieldContent>
|
||||
<RadioGroupItem
|
||||
value={plan.id}
|
||||
id={`form-rhf-radiogroup-${plan.id}`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</FieldSet>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Switch
|
||||
|
||||
- For switches, use `field.value` and `field.onChange` on the `<Switch />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Switch />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-switch"
|
||||
className="sm:[&_.preview]:h-[500px] sm:[&_pre]:!h-[500px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {5,13,18-19}
|
||||
<Controller
|
||||
name="twoFactor"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="form-rhf-switch-twoFactor">
|
||||
Multi-factor authentication
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
Enable multi-factor authentication to secure your account.
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</FieldContent>
|
||||
<Switch
|
||||
id="form-rhf-switch-twoFactor"
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Complex Forms
|
||||
|
||||
Here is an example of a more complex form with multiple fields and validation.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-complex"
|
||||
className="sm:[&_.preview]:h-[1300px] sm:[&_pre]:!h-[1300px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
## Resetting the Form
|
||||
|
||||
Use `form.reset()` to reset the form to its default values.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||
Reset
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Array Fields
|
||||
|
||||
React Hook Form provides a `useFieldArray` hook for managing dynamic array fields. This is useful when you need to add or remove fields dynamically.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-rhf-array"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
### Using useFieldArray
|
||||
|
||||
Use the `useFieldArray` hook to manage array fields. It provides `fields`, `append`, and `remove` methods.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {8-11}
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
|
||||
export function ExampleForm() {
|
||||
const form = useForm({
|
||||
// ... form config
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "emails",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Array Field Structure
|
||||
|
||||
Wrap your array fields in a `<FieldSet />` with a `<FieldLegend />` and `<FieldDescription />`.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
<FieldSet className="gap-4">
|
||||
<FieldLegend variant="label">Email Addresses</FieldLegend>
|
||||
<FieldDescription>
|
||||
Add up to 5 email addresses where we can contact you.
|
||||
</FieldDescription>
|
||||
<FieldGroup className="gap-4">{/* Array items go here */}</FieldGroup>
|
||||
</FieldSet>
|
||||
```
|
||||
|
||||
### Controller Pattern for Array Items
|
||||
|
||||
Map over the `fields` array and use `<Controller />` for each item. **Make sure to use `field.id` as the key**.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
{
|
||||
fields.map((field, index) => (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`emails.${index}.address`}
|
||||
control={form.control}
|
||||
render={({ field: controllerField, fieldState }) => (
|
||||
<Field orientation="horizontal" data-invalid={fieldState.invalid}>
|
||||
<FieldContent>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
{...controllerField}
|
||||
id={`form-rhf-array-email-${index}`}
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
/>
|
||||
{/* Remove button */}
|
||||
</InputGroup>
|
||||
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Items
|
||||
|
||||
Use the `append` method to add new items to the array.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ address: "" })}
|
||||
disabled={fields.length >= 5}
|
||||
>
|
||||
Add Email Address
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Removing Items
|
||||
|
||||
Use the `remove` method to remove items from the array. Add the remove button conditionally.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
{
|
||||
fields.length > 1 && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => remove(index)}
|
||||
aria-label={`Remove email ${index + 1}`}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Array Validation
|
||||
|
||||
Use Zod's `array` method to validate array fields.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
const formSchema = z.object({
|
||||
emails: z
|
||||
.array(
|
||||
z.object({
|
||||
address: z.string().email("Enter a valid email address."),
|
||||
})
|
||||
)
|
||||
.min(1, "Add at least one email address.")
|
||||
.max(5, "You can add up to 5 email addresses."),
|
||||
})
|
||||
```
|
||||
698
apps/v4/content/docs/forms/tanstack-form.mdx
Normal file
698
apps/v4/content/docs/forms/tanstack-form.mdx
Normal file
@@ -0,0 +1,698 @@
|
||||
---
|
||||
title: TanStack Form
|
||||
description: Build forms in React using TanStack Form and Zod.
|
||||
links:
|
||||
doc: https://tanstack.com/form
|
||||
---
|
||||
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
This guide explores how to build forms using TanStack Form. You'll learn to create forms with the `<Field />` component, implement schema validation with Zod, handle errors, and ensure accessibility.
|
||||
|
||||
## Demo
|
||||
|
||||
We'll start by building the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.
|
||||
|
||||
<Callout icon={<InfoIcon />}>
|
||||
**Note:** For the purpose of this demo, we have intentionally disabled browser
|
||||
validation to show how schema validation and form errors work in TanStack
|
||||
Form. It is recommended to add basic browser validation in your production
|
||||
code.
|
||||
</Callout>
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-demo"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
## Approach
|
||||
|
||||
This form leverages TanStack Form for powerful, headless form handling. We'll build our form using the `<Field />` component, which gives you **complete flexibility over the markup and styling**.
|
||||
|
||||
- Uses TanStack Form's `useForm` hook for form state management.
|
||||
- `form.Field` component with render prop pattern for controlled inputs.
|
||||
- `<Field />` components for building accessible forms.
|
||||
- Client-side validation using Zod.
|
||||
- Real-time validation feedback.
|
||||
|
||||
## Anatomy
|
||||
|
||||
Here's a basic example of a form using TanStack Form with the `<Field />` component.
|
||||
|
||||
```tsx showLineNumbers {15-31}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.Field
|
||||
name="title"
|
||||
children={(field) => {
|
||||
const isInvalid =
|
||||
field.state.meta.isTouched && !field.state.meta.isValid
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="Login button not working on mobile"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Provide a concise title for your bug report.
|
||||
</FieldDescription>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</FieldGroup>
|
||||
<Button type="submit">Submit</Button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Form
|
||||
|
||||
### Create a schema
|
||||
|
||||
We'll start by defining the shape of our form using a Zod schema.
|
||||
|
||||
<Callout icon={<InfoIcon />}>
|
||||
**Note:** This example uses `zod v3` for schema validation. TanStack Form
|
||||
integrates seamlessly with Zod and other Standard Schema validation libraries
|
||||
through its validators API.
|
||||
</Callout>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
import * as z from "zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(5, "Bug title must be at least 5 characters.")
|
||||
.max(32, "Bug title must be at most 32 characters."),
|
||||
description: z
|
||||
.string()
|
||||
.min(20, "Description must be at least 20 characters.")
|
||||
.max(100, "Description must be at most 100 characters."),
|
||||
})
|
||||
```
|
||||
|
||||
### Setup the form
|
||||
|
||||
Use the `useForm` hook from TanStack Form to create your form instance with Zod validation.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {10-21}
|
||||
import { useForm } from "@tanstack/react-form"
|
||||
import { toast } from "sonner"
|
||||
import * as z from "zod"
|
||||
|
||||
const formSchema = z.object({
|
||||
// ...
|
||||
})
|
||||
|
||||
export function BugReportForm() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: formSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
toast.success("Form submitted successfully")
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
form.handleSubmit()
|
||||
}}
|
||||
>
|
||||
{/* ... */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
We are using `onSubmit` to validate the form data here. TanStack Form supports other validation modes, which you can read about in the [documentation](https://tanstack.com/form/latest/docs/framework/react/guides/dynamic-validation).
|
||||
|
||||
### Build the form
|
||||
|
||||
We can now build the form using the `form.Field` component from TanStack Form and the `<Field />` component.
|
||||
|
||||
<ComponentSource
|
||||
src="/registry/new-york-v4/examples/form-tanstack-demo.tsx"
|
||||
title="form.tsx"
|
||||
/>
|
||||
|
||||
### Done
|
||||
|
||||
That's it. You now have a fully accessible form with client-side validation.
|
||||
|
||||
When you submit the form, the `onSubmit` function will be called with the validated form data. If the form data is invalid, TanStack Form will display the errors next to each field.
|
||||
|
||||
## Validation
|
||||
|
||||
### Client-side Validation
|
||||
|
||||
TanStack Form validates your form data using the Zod schema. Validation happens in real-time as the user types.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {13-15}
|
||||
import { useForm } from "@tanstack/react-form"
|
||||
|
||||
const formSchema = z.object({
|
||||
// ...
|
||||
})
|
||||
|
||||
export function BugReportForm() {
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: formSchema,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
console.log(value)
|
||||
},
|
||||
})
|
||||
|
||||
return <form onSubmit={/* ... */}>{/* ... */}</form>
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Modes
|
||||
|
||||
TanStack Form supports different validation strategies through the `validators` option:
|
||||
|
||||
| Mode | Description |
|
||||
| ------------ | ------------------------------------ |
|
||||
| `"onChange"` | Validation triggers on every change. |
|
||||
| `"onBlur"` | Validation triggers on blur. |
|
||||
| `"onSubmit"` | Validation triggers on submit. |
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {6-9}
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: formSchema,
|
||||
onChange: formSchema,
|
||||
onBlur: formSchema,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Displaying Errors
|
||||
|
||||
Display errors next to the field using `<FieldError />`. For styling and accessibility:
|
||||
|
||||
- Add the `data-invalid` prop to the `<Field />` component.
|
||||
- Add the `aria-invalid` prop to the form control such as `<Input />`, `<SelectTrigger />`, `<Checkbox />`, etc.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {4,18}
|
||||
<form.Field
|
||||
name="email"
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
|
||||
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
type="email"
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Working with Different Field Types
|
||||
|
||||
### Input
|
||||
|
||||
- For input fields, use `field.state.value` and `field.handleChange` on the `<Input />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Input />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-input"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {6,11-14,22}
|
||||
<form.Field
|
||||
name="username"
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor="form-tanstack-input-username">Username</FieldLabel>
|
||||
<Input
|
||||
id="form-tanstack-input-username"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="shadcn"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<FieldDescription>
|
||||
This is your public display name. Must be between 3 and 10 characters.
|
||||
Must only contain letters, numbers, and underscores.
|
||||
</FieldDescription>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Textarea
|
||||
|
||||
- For textarea fields, use `field.state.value` and `field.handleChange` on the `<Textarea />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Textarea />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-textarea"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {6,13-16,24}
|
||||
<form.Field
|
||||
name="about"
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor="form-tanstack-textarea-about">
|
||||
More about you
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id="form-tanstack-textarea-about"
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
placeholder="I'm a software engineer..."
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Tell us more about yourself. This will be used to help us personalize
|
||||
your experience.
|
||||
</FieldDescription>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Select
|
||||
|
||||
- For select components, use `field.state.value` and `field.handleChange` on the `<Select />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<SelectTrigger />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-select"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {6,18-19,23}
|
||||
<form.Field
|
||||
name="language"
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
|
||||
return (
|
||||
<Field orientation="responsive" data-invalid={isInvalid}>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="form-tanstack-select-language">
|
||||
Spoken Language
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
For best results, select the language you speak.
|
||||
</FieldDescription>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</FieldContent>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="form-tanstack-select-language"
|
||||
aria-invalid={isInvalid}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="item-aligned">
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Checkbox
|
||||
|
||||
- For checkbox, use `field.state.value` and `field.handleChange` on the `<Checkbox />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Checkbox />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
- For checkbox arrays, use `mode="array"` on the `<form.Field />` component and TanStack Form's array helpers.
|
||||
- Remember to add `data-slot="checkbox-group"` to the `<FieldGroup />` component for proper styling and spacing.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-checkbox"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {12,17,22-24,44}
|
||||
<form.Field
|
||||
name="tasks"
|
||||
mode="array"
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Tasks</FieldLegend>
|
||||
<FieldDescription>
|
||||
Get notified when tasks you've created have updates.
|
||||
</FieldDescription>
|
||||
<FieldGroup data-slot="checkbox-group">
|
||||
{tasks.map((task) => (
|
||||
<Field
|
||||
key={task.id}
|
||||
orientation="horizontal"
|
||||
data-invalid={isInvalid}
|
||||
>
|
||||
<Checkbox
|
||||
id={`form-tanstack-checkbox-${task.id}`}
|
||||
name={field.name}
|
||||
aria-invalid={isInvalid}
|
||||
checked={field.state.value.includes(task.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
field.pushValue(task.id)
|
||||
} else {
|
||||
const index = field.state.value.indexOf(task.id)
|
||||
if (index > -1) {
|
||||
field.removeValue(index)
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FieldLabel
|
||||
htmlFor={`form-tanstack-checkbox-${task.id}`}
|
||||
className="font-normal"
|
||||
>
|
||||
{task.label}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</FieldSet>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Radio Group
|
||||
|
||||
- For radio groups, use `field.state.value` and `field.handleChange` on the `<RadioGroup />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<RadioGroupItem />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-radiogroup"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {21,29,35}
|
||||
<form.Field
|
||||
name="plan"
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend>Plan</FieldLegend>
|
||||
<FieldDescription>
|
||||
You can upgrade or downgrade your plan at any time.
|
||||
</FieldDescription>
|
||||
<RadioGroup
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={field.handleChange}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<FieldLabel
|
||||
key={plan.id}
|
||||
htmlFor={`form-tanstack-radiogroup-${plan.id}`}
|
||||
>
|
||||
<Field orientation="horizontal" data-invalid={isInvalid}>
|
||||
<FieldContent>
|
||||
<FieldTitle>{plan.title}</FieldTitle>
|
||||
<FieldDescription>{plan.description}</FieldDescription>
|
||||
</FieldContent>
|
||||
<RadioGroupItem
|
||||
value={plan.id}
|
||||
id={`form-tanstack-radiogroup-${plan.id}`}
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</FieldSet>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Switch
|
||||
|
||||
- For switches, use `field.state.value` and `field.handleChange` on the `<Switch />` component.
|
||||
- To show errors, add the `aria-invalid` prop to the `<Switch />` component and the `data-invalid` prop to the `<Field />` component.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-switch"
|
||||
className="sm:[&_.preview]:h-[500px] sm:[&_pre]:!h-[500px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {6,14,19-21}
|
||||
<form.Field
|
||||
name="twoFactor"
|
||||
children={(field) => {
|
||||
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid
|
||||
return (
|
||||
<Field orientation="horizontal" data-invalid={isInvalid}>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="form-tanstack-switch-twoFactor">
|
||||
Multi-factor authentication
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
Enable multi-factor authentication to secure your account.
|
||||
</FieldDescription>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</FieldContent>
|
||||
<Switch
|
||||
id="form-tanstack-switch-twoFactor"
|
||||
name={field.name}
|
||||
checked={field.state.value}
|
||||
onCheckedChange={field.handleChange}
|
||||
aria-invalid={isInvalid}
|
||||
/>
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Complex Forms
|
||||
|
||||
Here is an example of a more complex form with multiple fields and validation.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-complex"
|
||||
className="sm:[&_.preview]:h-[1100px] sm:[&_pre]:!h-[1100px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
## Resetting the Form
|
||||
|
||||
Use `form.reset()` to reset the form to its default values.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Button type="button" variant="outline" onClick={() => form.reset()}>
|
||||
Reset
|
||||
</Button>
|
||||
```
|
||||
|
||||
## Array Fields
|
||||
|
||||
TanStack Form provides powerful array field management with `mode="array"`. This allows you to dynamically add, remove, and update array items with full validation support.
|
||||
|
||||
<ComponentPreview
|
||||
name="form-tanstack-array"
|
||||
className="sm:[&_.preview]:h-[700px] sm:[&_pre]:!h-[700px]"
|
||||
chromeLessOnMobile
|
||||
/>
|
||||
|
||||
This example demonstrates managing multiple email addresses with array fields. Users can add up to 5 email addresses, remove individual addresses, and each address is validated independently.
|
||||
|
||||
### Array Field Structure
|
||||
|
||||
Use `mode="array"` on the parent field to enable array field management.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx" {3,12-14}
|
||||
<form.Field
|
||||
name="emails"
|
||||
mode="array"
|
||||
children={(field) => {
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend variant="label">Email Addresses</FieldLegend>
|
||||
<FieldDescription>
|
||||
Add up to 5 email addresses where we can contact you.
|
||||
</FieldDescription>
|
||||
<FieldGroup>
|
||||
{field.state.value.map((_, index) => (
|
||||
// Nested field for each array item
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Nested Fields
|
||||
|
||||
Access individual array items using bracket notation: `fieldName[index].propertyName`. This example uses `InputGroup` to display the remove button inline with the input.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
<form.Field
|
||||
name={`emails[${index}].address`}
|
||||
children={(subField) => {
|
||||
const isSubFieldInvalid =
|
||||
subField.state.meta.isTouched && !subField.state.meta.isValid
|
||||
return (
|
||||
<Field orientation="horizontal" data-invalid={isSubFieldInvalid}>
|
||||
<FieldContent>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id={`form-tanstack-array-email-${index}`}
|
||||
name={subField.name}
|
||||
value={subField.state.value}
|
||||
onBlur={subField.handleBlur}
|
||||
onChange={(e) => subField.handleChange(e.target.value)}
|
||||
aria-invalid={isSubFieldInvalid}
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
/>
|
||||
{field.state.value.length > 1 && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => field.removeValue(index)}
|
||||
aria-label={`Remove email ${index + 1}`}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
{isSubFieldInvalid && (
|
||||
<FieldError errors={subField.state.meta.errors} />
|
||||
)}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Adding Items
|
||||
|
||||
Use `field.pushValue(item)` to add items to an array field. You can disable the button when the array reaches its maximum length.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => field.pushValue({ address: "" })}
|
||||
disabled={field.state.value.length >= 5}
|
||||
>
|
||||
Add Email Address
|
||||
</Button>
|
||||
```
|
||||
|
||||
### Removing Items
|
||||
|
||||
Use `field.removeValue(index)` to remove items from an array field. You can conditionally show the remove button only when there's more than one item.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
{
|
||||
field.state.value.length > 1 && (
|
||||
<InputGroupButton
|
||||
onClick={() => field.removeValue(index)}
|
||||
aria-label={`Remove email ${index + 1}`}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Array Validation
|
||||
|
||||
Validate array fields using Zod's array methods.
|
||||
|
||||
```tsx showLineNumbers title="form.tsx"
|
||||
const formSchema = z.object({
|
||||
emails: z
|
||||
.array(
|
||||
z.object({
|
||||
address: z.string().email("Enter a valid email address."),
|
||||
})
|
||||
)
|
||||
.min(1, "Add at least one email address.")
|
||||
.max(5, "You can add up to 5 email addresses."),
|
||||
})
|
||||
```
|
||||
@@ -18,7 +18,7 @@ npx create-tsrouter-app@latest my-app --template file-router --tailwind --add-on
|
||||
You can now start adding components to your project.
|
||||
|
||||
```bash
|
||||
npx shadcn@canary add button
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
The command above will add the `Button` component to your project. You can then import it like this:
|
||||
|
||||
@@ -7,109 +7,18 @@ description: Install and configure shadcn/ui for TanStack Start.
|
||||
|
||||
### Create project
|
||||
|
||||
Start by creating a new TanStack Start project by following the [Build a Project from Scratch](https://tanstack.com/start/latest/docs/framework/react/build-from-scratch) guide on the TanStack Start website.
|
||||
|
||||
**Do not add Tailwind yet. We'll install Tailwind v4 in the next step.**
|
||||
|
||||
### Add Tailwind
|
||||
|
||||
Install `tailwindcss` and its dependencies.
|
||||
Run the following command to create a new TanStack Start project with shadcn/ui:
|
||||
|
||||
```bash
|
||||
npm install tailwindcss @tailwindcss/postcss postcss
|
||||
npm create @tanstack/start@latest --tailwind --add-ons shadcn
|
||||
```
|
||||
|
||||
### Create postcss.config.ts
|
||||
|
||||
Create a `postcss.config.ts` file at the root of your project.
|
||||
|
||||
```ts title="postcss.config.ts" showLineNumbers
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Create `app/styles/app.css`
|
||||
|
||||
Create an `app.css` file in the `app/styles` directory and import `tailwindcss`
|
||||
|
||||
```css title="app/styles/app.css"
|
||||
@import "tailwindcss" source("../");
|
||||
```
|
||||
|
||||
### Import `app.css`
|
||||
|
||||
```tsx title="app/routes/__root.tsx" showLineNumbers {5,21-26} showLineNumbers
|
||||
import type { ReactNode } from "react"
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router"
|
||||
import { Meta, Scripts } from "@tanstack/start"
|
||||
|
||||
import appCss from "@/styles/app.css?url"
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: "utf-8",
|
||||
},
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=device-width, initial-scale=1",
|
||||
},
|
||||
{
|
||||
title: "TanStack Start Starter",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: appCss,
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
})
|
||||
```
|
||||
|
||||
### Edit tsconfig.json file
|
||||
|
||||
Add the following code to the `tsconfig.json` file to resolve paths.
|
||||
|
||||
```ts title="tsconfig.json" showLineNumbers {9-12}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./app/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Run the CLI
|
||||
|
||||
Run the `shadcn` init command to setup your project:
|
||||
|
||||
```bash
|
||||
npx shadcn@canary init
|
||||
```
|
||||
|
||||
This will create a `components.json` file in the root of your project and configure CSS variables inside `app/styles/app.css`.
|
||||
|
||||
### That's it
|
||||
### Add Components
|
||||
|
||||
You can now start adding components to your project.
|
||||
|
||||
```bash
|
||||
npx shadcn@canary add button
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
The command above will add the `Button` component to your project. You can then import it like this:
|
||||
@@ -117,10 +26,7 @@ The command above will add the `Button` component to your project. You can then
|
||||
```tsx title="app/routes/index.tsx" showLineNumbers {1,6}
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Home() {
|
||||
const router = useRouter()
|
||||
const state = Route.useLoaderData()
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<Button>Click me</Button>
|
||||
@@ -130,3 +36,9 @@ function Home() {
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
If you want to add all `shadcn/ui` components, you can run the following command:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add --all
|
||||
```
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"(root)",
|
||||
"changelog",
|
||||
"components",
|
||||
"forms",
|
||||
"installation",
|
||||
"dark-mode",
|
||||
"registry"
|
||||
|
||||
@@ -103,7 +103,7 @@ You can read more about the registry item schema and file types in the [registry
|
||||
### Install the shadcn CLI
|
||||
|
||||
```bash
|
||||
npm install shadcn@canary
|
||||
npm install shadcn@latest
|
||||
```
|
||||
|
||||
### Add a build script
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Index
|
||||
title: Add a Registry
|
||||
description: Open Source Registry Index
|
||||
---
|
||||
|
||||
@@ -11,16 +11,9 @@ You can see the full list at [https://ui.shadcn.com/r/registries.json](https://u
|
||||
|
||||
## Adding a Registry
|
||||
|
||||
You can submit a PR to add a registry to the index by adding it to the [registries.json](https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/registries.json) file.
|
||||
You can open an issue to add a registry to the index by filling out the [registry directory issue template](https://github.com/shadcn-ui/ui/issues/new?template=registry_directory.yml).
|
||||
|
||||
Here's an example of how to add a registry to the index:
|
||||
|
||||
```json title="registries.json" showLineNumbers
|
||||
{
|
||||
"@acme": "https://registry.acme.com/r/{name}.json",
|
||||
"@example": "https://example.com/r/{name}"
|
||||
}
|
||||
```
|
||||
Once you have submitted your issue, it will be validated and reviewed by the team.
|
||||
|
||||
### Requirements
|
||||
|
||||
@@ -65,15 +58,3 @@ Here's an example of a valid registry:
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
At the root of the `shadcn/ui` project, you can run the following command to validate the `registries.json` file.
|
||||
|
||||
```bash
|
||||
pnpm validate:registries
|
||||
```
|
||||
|
||||
This will validate the registries.json file and output any errors.
|
||||
|
||||
Once you have submitted your PR, it will be validated and reviewed by the team.
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { dirname } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { FlatCompat } from "@eslint/eslintrc"
|
||||
import { defineConfig, globalIgnores } from "eslint/config"
|
||||
import nextVitals from "eslint-config-next/core-web-vitals"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
})
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.config({
|
||||
extends: ["next/core-web-vitals", "next/typescript"],
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
globalIgnores([
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
".source/**",
|
||||
]),
|
||||
{
|
||||
rules: {
|
||||
"@next/next/no-duplicate-head": "off",
|
||||
"react-hooks/incompatible-library": "off",
|
||||
"react-hooks/purity": "off",
|
||||
"@next/next/no-html-link-for-pages": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
])
|
||||
|
||||
export default eslintConfig
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useIsMac() {
|
||||
const [isMac, setIsMac] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMac(navigator.platform.toUpperCase().includes("MAC"))
|
||||
}, [])
|
||||
|
||||
return isMac
|
||||
}
|
||||
@@ -61,7 +61,10 @@ const Layout = ({
|
||||
}
|
||||
})
|
||||
|
||||
const attrs = !value ? ["layout-fixed", "layout-full"] : Object.values(value)
|
||||
const attrs = React.useMemo(
|
||||
() => (!value ? ["layout-fixed", "layout-full"] : Object.values(value)),
|
||||
[value]
|
||||
)
|
||||
|
||||
const applyLayout = React.useCallback(
|
||||
(layout: Layout) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user