commit 1416db152c2247e6b8be9c3a7e250bde865421d1 Author: upstream-mirror Date: Tue Sep 15 22:44:43 2026 +0500 snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted) diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..e355e76 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "shadcn-ui/ui" }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["v4", "tests"] +} diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..b626e44 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "v4", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["--filter=v4", "dev"], + "port": 4000 + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4254113 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(npm test:*)", + "Bash(npm run typecheck:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "WebSearch", + "WebFetch(domain:github.com)" + ], + "deny": [] + }, + "outputStyle": "Explanatory" +} diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 0000000..c30e5a9 --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["@commitlint/config-conventional"] +} diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..464694d --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,41 @@ +{ + "name": "shadcn", + "displayName": "shadcn/ui", + "version": "1.0.0", + "description": "UI component and design system framework. Search registries, install components as source code, and audit your project.", + "author": { + "name": "shadcn" + }, + "homepage": "https://ui.shadcn.com", + "repository": "https://github.com/shadcn-ui/ui", + "license": "MIT", + "logo": "skills/shadcn/assets/shadcn.png", + "keywords": [ + "shadcn", + "shadcn-ui", + "ui", + "components", + "tailwind", + "tailwindcss", + "radix", + "react", + "design-system", + "registry", + "mcp" + ], + "category": "developer-tools", + "tags": [ + "ui", + "components", + "design-system", + "react", + "tailwind" + ], + "skills": "./skills/", + "mcpServers": { + "shadcn": { + "command": "npx", + "args": ["shadcn@latest", "mcp"] + } + } +} diff --git a/.cursor/rules/registry-bases-parity.mdc b/.cursor/rules/registry-bases-parity.mdc new file mode 100644 index 0000000..08ebd58 --- /dev/null +++ b/.cursor/rules/registry-bases-parity.mdc @@ -0,0 +1,22 @@ +--- +description: Keep registry base and radix trees in sync when editing shared UI +globs: apps/v4/registry/bases/**/* +alwaysApply: false +--- + +# Registry bases: Base UI ↔ Radix parity + +`apps/v4/registry/bases/base` and `apps/v4/registry/bases/radix` are **parallel registries**. Anything that exists in both trees for the same purpose (preview blocks, mirrored examples, shared card layouts, etc.) **must stay in sync**. + +## When editing + +- If you change a file under **`bases/base/...`**, apply the **same behavioral and visual change** to the matching path under **`bases/radix/...`** (and the reverse). +- Only diverge where APIs differ (e.g. import paths like `@/registry/bases/base/ui/*` vs `@/registry/bases/radix/ui/*`, or Base UI vs Radix component props). +- Do **not** update only one side unless the user explicitly asks for a single-base change. + +## Typical mirrored paths + +- `blocks/preview/**` — preview cards and blocks +- Parallel `ui/*` components when both exist for the same component + +After edits, briefly confirm both trees were updated (or state why one side is intentionally unchanged). diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ae10a5c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..9e4646c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +node_modules/ +target/ +.next/ +build/ +dist/ + +/templates/ +/fixtures/ diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..94fcc14 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc", + "root": true, + "extends": [ + "next/core-web-vitals", + "turbo", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "ignorePatterns": ["**/fixtures/**"], + "rules": { + "@next/next/no-html-link-for-pages": "off", + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "error" + }, + "settings": { + "tailwindcss": { + "callees": ["cn", "cva"], + "config": "tailwind.config.cjs" + }, + "next": { + "rootDir": ["apps/*/"] + } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser" + } + ] +} diff --git a/.github/DISCUSSION_TEMPLATE/blocks-request.yml b/.github/DISCUSSION_TEMPLATE/blocks-request.yml new file mode 100644 index 0000000..d23ca30 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/blocks-request.yml @@ -0,0 +1,25 @@ +title: "[blocks]: " +labels: ["Blocks Request"] +body: + - type: markdown + attributes: + value: | + ### Thanks for taking the time to create a block request! Please search open/closed requests before submitting, as the block or a similar one may have already been requested. + + - type: textarea + id: block-description + attributes: + label: Description + description: Tell us about your block request + placeholder: "A dashboard for an e-commerce website showing sales, orders, and customers..." + validations: + required: true + + - type: input + id: block-example-url + attributes: + label: Example + description: Link to an example of the block + placeholder: ex. https://example.com + validations: + required: false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..acd2c42 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [shadcn] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..5221946 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,85 @@ +name: "Bug report" +description: Report an issue +title: '[bug]: ' +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + ### Thanks for taking the time to create a bug report. Please search open/closed issues before submitting, as the issue may have already been reported/addressed. + + - type: markdown + attributes: + value: | + #### If you aren't sure this is a bug or not, please open a discussion instead: + - [Discussions](https://github.com/shadcn-ui/ui/discussions/new?category=general) + + - type: textarea + id: bug-description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us how in the description. Thanks! + placeholder: Bug description + validations: + required: true + + - type: input + id: components-affected + attributes: + label: Affected component/components + description: Which shadcn/ui components are affected? + placeholder: ex. Button, Checkbox... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: How to reproduce + description: A step-by-step description of how to reproduce the bug. + placeholder: | + 1. Go to '...' + 2. Click on '....' + 3. See error + validations: + required: true + + - type: input + id: codesandbox-stackblitz + attributes: + label: Codesandbox/StackBlitz link + description: | + A link to a CodeSandbox or StackBlitz that includes a minimal reproduction of the problem. In rare cases when not applicable, you can link to a GitHub repository that we can easily run to recreate the issue. If a report is vague and does not have a reproduction, it will be closed without warning. + + > [!CAUTION] + > If you skip this step, this issue might be **labeled** with `please add a reproduction` and **closed**. + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs + description: "Please include browser console and server logs around the time this bug occurred. Optional if provided reproduction. Please try not to insert an image but copy paste the log text." + render: bash + + - type: textarea + id: system-info + attributes: + label: System Info + description: Information about browsers, system or binaries that's relevant. + render: bash + placeholder: System, Binaries, Browsers + validations: + required: true + + - type: checkboxes + id: terms + attributes: + label: Before submitting + description: By submitting this issue, you agree to follow our [Contributing Guidelines](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md). + options: + - label: I've made research efforts and searched the documentation + required: true + - label: I've searched for existing issues + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c594215 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Get Help + url: https://github.com/shadcn-ui/ui/discussions/new?category=general + about: If you can't get something to work the way you expect, open a question in our discussion forums. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..0640a75 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,55 @@ +name: "Feature request" +description: Create a feature request for shadcn/ui +title: '[feat]: ' +labels: ['area: request'] +body: + - type: markdown + attributes: + value: | + ### Thanks for taking the time to create a feature request! Please search open/closed issues before submitting, as the issue may have already been reported/addressed. + + - type: markdown + attributes: + value: | + #### If you aren't sure this is a bug or not, please open a discussion instead: + - [Discussions](https://github.com/shadcn-ui/ui/discussions/new?category=general) + + - type: textarea + id: feature-description + attributes: + label: Feature description + description: Tell us about your feature request + placeholder: 'I think this feature would be great because...' + value: 'Describe your feature request...' + validations: + required: true + + - type: input + id: components-affected + attributes: + label: Affected component/components + description: Is this feature request relevant to any of the already existing components? + placeholder: ex. Button, Checkbox... + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional Context + description: Add any other context about the feature here. + placeholder: ex. screenshots, Stack Overflow links, forum links, etc. + value: 'Additional details here...' + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: Before submitting + description: By submitting this issue, you agree to follow our [Contributing Guidelines](https://github.com/shadcn-ui/ui/blob/main/CONTRIBUTING.md). + options: + - label: I've made research efforts and searched the documentation + required: true + - label: I've searched for existing issues and PRs + required: true diff --git a/.github/changeset-version.js b/.github/changeset-version.js new file mode 100644 index 0000000..ea1fc05 --- /dev/null +++ b/.github/changeset-version.js @@ -0,0 +1,12 @@ +// ORIGINALLY FROM CLOUDFLARE WRANGLER: +// https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js + +import { execSync } from "child_process" + +// This script is used by the `release.yml` workflow to update the version of the packages being released. +// The standard step is only to run `changeset version` but this does not update the pnpm-lock.yaml file. +// So we also run `pnpm install`, which does this update. +// This is a workaround until this is handled automatically by `changeset version`. +// See https://github.com/changesets/changesets/issues/421. +execSync("npx changeset version", { stdio: "inherit" }) +execSync("pnpm install --lockfile-only", { stdio: "inherit" }) diff --git a/.github/collect-prerelease-info.js b/.github/collect-prerelease-info.js new file mode 100644 index 0000000..1635213 --- /dev/null +++ b/.github/collect-prerelease-info.js @@ -0,0 +1,35 @@ +// Collect the packages that a snapshot prerelease just published, so the +// prerelease comment workflow can render an install line per package. +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const [, , prNumber, channel] = process.argv + +const packagesDir = join(process.cwd(), "packages") +const published = [] + +for (const dir of readdirSync(packagesDir)) { + const pkgPath = join(packagesDir, dir, "package.json") + if (!existsSync(pkgPath)) { + continue + } + + const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) + + // Snapshot versions are stamped `0.0.0--`, so the channel + // marker is how we tell which packages this run actually versioned. + if ( + !pkg.private && + typeof pkg.version === "string" && + pkg.version.includes(`-${channel}`) + ) { + published.push({ name: pkg.name, version: pkg.version }) + } +} + +writeFileSync( + "prerelease-info.json", + JSON.stringify({ pr: prNumber, channel, packages: published }, null, 2) +) + +console.log(`Collected ${published.length} prerelease package(s).`) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..52d3020 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,129 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + exclude-patterns: + ["ai", "@ai-sdk/*", "@tanstack/*", "@base-ui/*", "fumadocs*"] + framework-deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + patterns: ["ai", "@ai-sdk/*", "@tanstack/*", "@base-ui/*", "fumadocs*"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/astro-app" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/astro-monorepo" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/next-app" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/next-monorepo" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/react-router-app" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/react-router-monorepo" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/start-app" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/start-monorepo" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/vite-app" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] + - package-ecosystem: "npm" + directory: "/templates/vite-monorepo" + schedule: + interval: "weekly" + groups: + deps: + applies-to: "version-updates" + update-types: ["minor", "patch"] + ignore: + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] diff --git a/.github/version-script-next.js b/.github/version-script-next.js new file mode 100644 index 0000000..5acd91c --- /dev/null +++ b/.github/version-script-next.js @@ -0,0 +1,21 @@ +// ORIGINALLY FROM CLOUDFLARE WRANGLER: +// https://github.com/cloudflare/wrangler2/blob/main/.github/version-script.js + +import { exec } from "child_process" +import fs from "fs" + +const pkgJsonPath = "packages/shadcn/package.json" +try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath)) + exec("git rev-parse --short HEAD", (err, stdout) => { + if (err) { + console.log(err) + process.exit(1) + } + pkg.version = "0.0.0-next." + stdout.trim() + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n") + }) +} catch (error) { + console.error(error) + process.exit(1) +} diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml new file mode 100644 index 0000000..bf576a1 --- /dev/null +++ b/.github/workflows/browser-tests.yml @@ -0,0 +1,56 @@ +name: Browser tests + +on: + pull_request: + branches: ["*"] + paths: + - "packages/react/**" + - ".github/workflows/browser-tests.yml" + +jobs: + browser: + runs-on: ubuntu-latest + name: pnpm test:browser + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + run: pnpm install + + - name: Cache Playwright browsers + uses: actions/cache@v3 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - name: Install Playwright Chromium + run: pnpm --filter=@shadcn/react exec playwright install --with-deps chromium + + - run: pnpm --filter=@shadcn/react test:browser diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml new file mode 100644 index 0000000..6fdf6a8 --- /dev/null +++ b/.github/workflows/code-check.yml @@ -0,0 +1,130 @@ +name: Code check + +on: + pull_request: + branches: ["*"] + +jobs: + lint: + runs-on: ubuntu-latest + name: pnpm lint + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + run: pnpm install + + - run: pnpm lint + + format: + runs-on: ubuntu-latest + name: pnpm format:check + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Build packages + run: pnpm build:packages + + - run: pnpm format:check + + tsc: + runs-on: ubuntu-latest + name: pnpm typecheck + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: pnpm install + + - name: Build packages + run: pnpm build:packages + + # The generated styles under apps/v4/styles are gitignored, and the v4 + # typecheck imports them. Build them before typechecking. + - name: Build registry styles + run: pnpm --filter=v4 registry:build --style all + + - run: pnpm typecheck diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml new file mode 100644 index 0000000..65253a3 --- /dev/null +++ b/.github/workflows/issue-stale.yml @@ -0,0 +1,50 @@ +# Adapted from vercel/next.js +name: "Stale issue handler" +on: + workflow_dispatch: + schedule: + # This runs every day 20 minutes before midnight: https://crontab.guru/#40_23_*_*_* + - cron: "40 23 * * *" + +permissions: + actions: write + issues: write + pull-requests: write + +jobs: + stale: + runs-on: ubuntu-latest + if: github.repository_owner == 'shadcn-ui' + steps: + - uses: actions/stale@v9 + id: issue-stale + name: "Mark stale issues, close stale issues" + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + ascending: true + days-before-issue-close: 7 + days-before-issue-stale: 365 + days-before-pr-stale: -1 + days-before-pr-close: -1 + remove-issue-stale-when-updated: true + stale-issue-label: "stale?" + exempt-issue-labels: "roadmap,next" + stale-issue-message: "This issue has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless there’s further input. If you believe this issue is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)" + close-issue-message: "This issue has been automatically closed due to one year of inactivity. If you’re still experiencing a similar problem or have additional details to share, please open a new issue following our current issue template. Your updated report helps us investigate and address concerns more efficiently. Thank you for your understanding! (This is an automated message)" + operations-per-run: 300 + - uses: actions/stale@v9 + id: pr-state + name: "Mark stale PRs, close stale PRs" + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + ascending: true + days-before-issue-close: -1 + days-before-issue-stale: -1 + days-before-pr-close: 7 + days-before-pr-stale: 365 + remove-pr-stale-when-updated: true + exempt-pr-labels: "roadmap,next,bug" + stale-pr-label: "stale?" + stale-pr-message: "This PR has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless there’s further input. If you believe this PR is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)" + close-pr-message: "This PR has been automatically closed due to one year of inactivity. Thank you for your understanding! (This is an automated message)" + operations-per-run: 300 diff --git a/.github/workflows/monitor-registries.yml b/.github/workflows/monitor-registries.yml new file mode 100644 index 0000000..fe4cd83 --- /dev/null +++ b/.github/workflows/monitor-registries.yml @@ -0,0 +1,76 @@ +name: Monitor Registries + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + inputs: + mode: + description: Registry checks to run + required: true + default: auto + type: choice + options: + - auto + - hourly + - daily + - weekly + - all + +permissions: + contents: read + +concurrency: + group: registry-health-monitor + cancel-in-progress: false + +jobs: + monitor: + name: Monitor registry health + if: github.repository == 'shadcn-ui/ui' + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.19.1 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Download previous health state + env: + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + run: pnpm registry:health:prepare + + - name: Check registries + env: + MONITOR_MODE: ${{ inputs.mode || 'auto' }} + run: pnpm registry:health:check + + - name: Publish health snapshot + env: + BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + run: pnpm registry:health:publish diff --git a/.github/workflows/prerelease-comment.yml b/.github/workflows/prerelease-comment.yml new file mode 100644 index 0000000..b029d48 --- /dev/null +++ b/.github/workflows/prerelease-comment.yml @@ -0,0 +1,94 @@ +# Adapted from create-t3-app. +name: Write Prerelease comment + +on: + workflow_run: + workflows: ["Release"] + types: + - completed + +jobs: + comment: + if: | + github.repository_owner == 'shadcn-ui' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + name: Write comment to the PR + steps: + # Stable pushes and no-changeset runs upload no artifact, so a missing + # download is expected — gate the rest of the job on it succeeding. + - name: Download prerelease info + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: prerelease-info + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build comment + id: info + if: steps.download.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8")); + + if (!info.packages || info.packages.length === 0) { + core.info("No prerelease packages to comment."); + return; + } + + const installs = info.packages + .map((p) => `pnpm dlx ${p.name}@${p.version}`) + .join("\n"); + const links = info.packages + .map( + (p) => + `- [${p.name}@${p.version}](https://www.npmjs.com/package/${p.name}/v/${p.version})` + ) + .join("\n"); + + const body = [ + `A new ${info.channel} prerelease is available for testing:`, + "", + "```sh", + installs, + "```", + "", + links, + ].join("\n"); + + core.setOutput("pr", info.pr); + core.setOutput("channel", info.channel); + core.setOutput("body", body); + + - name: Comment on PR + if: steps.info.outputs.body + uses: marocchino/sticky-pull-request-comment@v2 + with: + number: ${{ steps.info.outputs.pr }} + message: ${{ steps.info.outputs.body }} + + - name: Remove the prerelease label once published + if: steps.info.outputs.pr + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number("${{ steps.info.outputs.pr }}"), + name: `release: ${{ steps.info.outputs.channel }}`, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + core.info("The prerelease label was already removed."); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b0f880 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,177 @@ +# Adapted from create-t3-app. + +name: Release + +run-name: ${{ github.event_name == 'pull_request' && format('Release Prerelease - PR {0}', github.event.number) || 'Release Stable' }} + +on: + pull_request: + types: [labeled] + branches: + - main + push: + branches: + - main + +jobs: + prerelease: + if: "${{ github.event_name == 'pull_request' && github.repository_owner == 'shadcn-ui' && (contains(github.event.pull_request.labels.*.name, 'release: beta') || contains(github.event.pull_request.labels.*.name, 'release: rc')) }}" + name: Publish Prerelease to NPM + runs-on: ubuntu-latest + environment: Preview + permissions: + id-token: write + contents: read + + steps: + - name: Select prerelease channel + id: prerelease + uses: actions/github-script@v7 + with: + script: | + const prereleaseLabels = [ + { name: "release: beta", channel: "beta" }, + { name: "release: rc", channel: "rc" }, + ]; + const labels = context.payload.pull_request.labels.map((label) => label.name); + const selectedLabels = prereleaseLabels.filter((label) => + labels.includes(label.name) + ); + + if (selectedLabels.length !== 1) { + throw new Error( + `Expected exactly one prerelease label, found: ${ + selectedLabels.map((label) => label.name).join(", ") || "none" + }.` + ); + } + + core.setOutput("channel", selectedLabels[0].channel); + core.setOutput("label", selectedLabels[0].name); + + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Use PNPM + uses: pnpm/action-setup@v4 + with: + version: 10.33.4 + + - name: Use Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + + - name: Update npm for OIDC support + run: npm install -g npm@latest + + - name: Install NPM Dependencies + run: pnpm install + + # A snapshot prerelease needs changesets to compute versions. The + # Changesets version PR consumes them, so a label on that PR is a no-op. + - name: Check for changesets + id: changesets + run: | + shopt -s nullglob + present=false + for file in .changeset/*.md; do + if [ "$(basename "$file")" != "README.md" ]; then + present=true + break + fi + done + echo "present=$present" >> "$GITHUB_OUTPUT" + + - name: No changesets to prerelease + if: steps.changesets.outputs.present == 'false' + run: echo "::notice::No changesets found on this branch; nothing to prerelease." + + # Snapshot versions are stamped per run (timestamped), so each publish is + # unique and can never collide with a real release on the latest tag. + - name: Version snapshot + if: steps.changesets.outputs.present == 'true' + run: pnpm exec changeset version --snapshot ${{ steps.prerelease.outputs.channel }} + + - name: Build packages + if: steps.changesets.outputs.present == 'true' + run: pnpm build:packages + + - name: Publish snapshot to NPM + if: steps.changesets.outputs.present == 'true' + run: pnpm exec changeset publish --tag ${{ steps.prerelease.outputs.channel }} --no-git-tag + + - name: Collect prerelease info + if: steps.changesets.outputs.present == 'true' + run: node .github/collect-prerelease-info.js "${{ github.event.number }}" "${{ steps.prerelease.outputs.channel }}" + + - name: Upload prerelease info + if: steps.changesets.outputs.present == 'true' + uses: actions/upload-artifact@v4 + with: + name: prerelease-info + path: prerelease-info.json + + release: + if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }} + name: Create Version PR or Publish Stable Release + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + + steps: + - name: Checkout Repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Use PNPM + uses: pnpm/action-setup@v4 + with: + version: 10.33.4 + + - name: Use Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + cache: "pnpm" + + - name: Update npm for OIDC support + run: npm install -g npm@latest + + - name: Install NPM Dependencies + run: pnpm install + + # Builds every publishable package under packages/* (shadcn, @shadcn/react, + # @shadcn/helpers), never apps/v4, so each dist is fresh before publish. + - name: Build the packages + run: pnpm build:packages + + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} + git_user_signingkey: true + git_commit_gpgsign: true + git_tag_gpgsign: true + + - name: Create Version PR or Publish to NPM + id: changesets + uses: changesets/action@v1 + with: + setupGitUser: false + commit: "chore(release): version packages" + title: "chore(release): version packages" + version: node .github/changeset-version.js + publish: npx changeset publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_ENV: "production" diff --git a/.github/workflows/signed-commits.yml b/.github/workflows/signed-commits.yml new file mode 100644 index 0000000..897f134 --- /dev/null +++ b/.github/workflows/signed-commits.yml @@ -0,0 +1,97 @@ +name: Signed commits + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - ready_for_review + +permissions: + pull-requests: write + +jobs: + signed-commits: + if: github.repository_owner == 'shadcn-ui' + runs-on: ubuntu-latest + name: Signed commits + steps: + - name: Check PR commits + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const body = "Can you sign the commits please? See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits. Thank you." + const label = "blocked by: unverified commits" + + const { owner, repo } = context.repo + const pullNumber = context.payload.pull_request.number + const hasLabel = context.payload.pull_request.labels.some( + (existingLabel) => existingLabel.name === label + ) + + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }) + + const unsignedCommits = commits.filter((commit) => { + return commit.commit.verification?.reason === "unsigned" + }) + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }) + + const existingComments = comments.filter((comment) => { + return comment.user.type === "Bot" && comment.body.trim() === body + }) + + if (unsignedCommits.length > 0) { + core.info(`Found ${unsignedCommits.length} unsigned commits.`) + + if (existingComments.length === 0) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body, + }) + } + + if (!hasLabel) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pullNumber, + labels: [label], + }) + } + + return + } + + core.info("All commits are signed.") + + for (const comment of existingComments) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id, + }) + } + + if (hasLabel) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: pullNumber, + name: label, + }) + } diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml new file mode 100644 index 0000000..f84d8ec --- /dev/null +++ b/.github/workflows/templates.yml @@ -0,0 +1,314 @@ +name: Templates + +on: + pull_request: + branches: ["*"] + paths: + - ".github/workflows/templates.yml" + - "apps/v4/registry/**" + - "package.json" + - "packages/shadcn/src/commands/add.ts" + - "packages/shadcn/src/commands/init.ts" + - "packages/shadcn/src/templates/**" + - "packages/shadcn/src/utils/create-project.ts" + - "packages/shadcn/src/utils/get-monorepo-info.ts" + - "packages/shadcn/src/utils/get-package-manager.ts" + - "pnpm-lock.yaml" + - "templates/**" + +jobs: + validate: + runs-on: ubuntu-latest + name: ${{ matrix.package-manager == 'pnpm' && format('pnpm {0}', matrix.pnpm-version) || matrix.package-manager }} ${{ matrix.template }} + permissions: + contents: read + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + template: [next, vite, astro, start, react-router] + package-manager: [pnpm, bun, npm, yarn] + pnpm-version: [10.33.4, 11] + exclude: + - package-manager: bun + pnpm-version: 11 + - package-manager: npm + pnpm-version: 11 + - package-manager: yarn + pnpm-version: 11 + env: + NEXT_PUBLIC_APP_URL: http://localhost:4000 + NEXT_PUBLIC_V0_URL: https://v0.dev + REGISTRY_URL: http://localhost:4000/r + ROOT_PNPM_VERSION: 10.33.4 + TEMPLATE_PNPM_VERSION: ${{ matrix.pnpm-version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: ${{ env.ROOT_PNPM_VERSION }} + run_install: false + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Install Yarn + if: matrix.package-manager == 'yarn' + run: | + corepack enable + COREPACK_ENABLE_PROJECT_SPEC=0 corepack prepare yarn@4.12.0 --activate + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT" + + - uses: actions/cache@v4 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install + + - name: Build packages + run: | + pnpm --filter=shadcn build + pnpm --filter=v4 registry:build + + - name: Validate templates + env: + TEMPLATE: ${{ matrix.template }} + TEMPLATE_PACKAGE_MANAGER: ${{ matrix.package-manager }} + SHADCN_TEMPLATE_DIR: ${{ github.workspace }}/templates + run: | + set -euo pipefail + + root_pnpm="$(command -v pnpm)" + validation_script="$RUNNER_TEMP/validate-templates.sh" + + cat > "$validation_script" <<'BASH' + set -euo pipefail + + bin_dir="$RUNNER_TEMP/template-pnpm-bin" + mkdir -p "$bin_dir" + + cat > "$bin_dir/pnpm" <<'PNPM' + #!/usr/bin/env bash + exec npx -y "pnpm@${TEMPLATE_PNPM_VERSION}" "$@" + PNPM + chmod +x "$bin_dir/pnpm" + export PATH="$bin_dir:$PATH" + + echo "Using template pnpm $(pnpm --version)" + + cli="$GITHUB_WORKSPACE/packages/shadcn/dist/index.js" + template_root="$RUNNER_TEMP/generated-template-${TEMPLATE_PACKAGE_MANAGER}-${TEMPLATE}" + rm -rf "$template_root" + mkdir -p "$template_root" + + modes=(app monorepo) + + has_script() { + node -e "const pkg = require('./package.json'); process.exit(pkg.scripts && pkg.scripts[process.argv[1]] ? 0 : 1)" "$1" + } + + run_script_if_present() { + local script="$1" + if has_script "$script"; then + pnpm run "$script" + else + echo "No $script script found; skipping." + fi + } + + validate_non_pnpm_project() { + local package_manager="$1" + local project_path="$2" + local check_workspace_protocol="$3" + local is_monorepo="$4" + + cd "$project_path" + test ! -f pnpm-workspace.yaml + test ! -f pnpm-lock.yaml + + EXPECTED_PACKAGE_MANAGER="$package_manager" \ + CHECK_WORKSPACE_PROTOCOL="$check_workspace_protocol" \ + IS_MONOREPO="$is_monorepo" \ + node <<'NODE' + const fs = require("node:fs") + const path = require("node:path") + + const expectedPackageManager = process.env.EXPECTED_PACKAGE_MANAGER + const checkWorkspaceProtocol = + process.env.CHECK_WORKSPACE_PROTOCOL === "true" + const isMonorepo = process.env.IS_MONOREPO === "true" + const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")) + + if (isMonorepo) { + const workspaces = pkg.workspaces ?? [] + + if (!Array.isArray(workspaces)) { + throw new Error("Expected package.json workspaces to be an array.") + } + + if (workspaces.length === 0) { + throw new Error("Expected package.json workspaces to have entries.") + } + + for (const workspace of ["sharp", "unrs-resolver", "esbuild"]) { + if (workspaces.includes(workspace)) { + throw new Error(`Unexpected workspace entry: ${workspace}`) + } + } + + if (!pkg.packageManager?.startsWith(`${expectedPackageManager}@`)) { + throw new Error( + `Expected packageManager to use ${expectedPackageManager}, got ${pkg.packageManager}` + ) + } + } else { + if (pkg.workspaces !== undefined) { + throw new Error("Did not expect package.json workspaces for app template.") + } + + if (pkg.packageManager !== undefined) { + throw new Error( + `Did not expect packageManager for app template, got ${pkg.packageManager}` + ) + } + } + + if (checkWorkspaceProtocol) { + const packageJsonFiles = [] + function collectPackageJsonFiles(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules") { + continue + } + + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + collectPackageJsonFiles(fullPath) + } else if (entry.name === "package.json") { + packageJsonFiles.push(fullPath) + } + } + } + + collectPackageJsonFiles(process.cwd()) + + for (const file of packageJsonFiles) { + const json = fs.readFileSync(file, "utf8") + if (json.includes("workspace:")) { + throw new Error(`Unexpected workspace: protocol in ${file}`) + } + } + } + NODE + } + + for mode in "${modes[@]}"; do + project="test-${TEMPLATE}-${mode}-${TEMPLATE_PACKAGE_MANAGER}" + project_path="$template_root/$project" + + echo "::group::${TEMPLATE} ${mode} ${TEMPLATE_PACKAGE_MANAGER}" + args=( + init + --defaults + --name "$project" + --template "$TEMPLATE" + --cwd "$template_root" + --silent + ) + + if [ "$mode" = "monorepo" ]; then + args+=(--monorepo) + is_monorepo="true" + else + args+=(--no-monorepo) + is_monorepo="false" + fi + + case "$TEMPLATE_PACKAGE_MANAGER" in + pnpm) + SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \ + REGISTRY_URL="$REGISTRY_URL" \ + npm_config_user_agent="pnpm/${TEMPLATE_PNPM_VERSION}" \ + node "$cli" "${args[@]}" + + cd "$project_path" + pnpm install --frozen-lockfile + run_script_if_present typecheck + run_script_if_present build + ;; + bun) + ( + cd "$template_root" + SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \ + REGISTRY_URL="$REGISTRY_URL" \ + npm_config_user_agent="bun/$(bun --version)" \ + bunx --bun --package "$GITHUB_WORKSPACE/packages/shadcn" \ + shadcn "${args[@]}" + ) + validate_non_pnpm_project \ + "bun" \ + "$project_path" \ + "false" \ + "$is_monorepo" + ;; + npm) + ( + cd "$template_root" + SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \ + REGISTRY_URL="$REGISTRY_URL" \ + npm_config_user_agent="npm/$(npm --version)" \ + npx --yes --package "$GITHUB_WORKSPACE/packages/shadcn" \ + shadcn "${args[@]}" + ) + validate_non_pnpm_project \ + "npm" \ + "$project_path" \ + "true" \ + "$is_monorepo" + ;; + yarn) + ( + cd "$template_root" + SHADCN_TEMPLATE_DIR="$SHADCN_TEMPLATE_DIR" \ + REGISTRY_URL="$REGISTRY_URL" \ + COREPACK_ENABLE_PROJECT_SPEC=0 \ + npm_config_user_agent="yarn/$(COREPACK_ENABLE_PROJECT_SPEC=0 yarn --version)" \ + yarn dlx --package "$GITHUB_WORKSPACE/packages/shadcn" \ + shadcn "${args[@]}" + ) + validate_non_pnpm_project \ + "yarn" \ + "$project_path" \ + "false" \ + "$is_monorepo" + ;; + esac + + echo "::endgroup::" + done + BASH + + "$root_pnpm" exec start-server-and-test \ + "$root_pnpm v4:dev" \ + http://localhost:4000 \ + "bash $validation_script" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b3241f4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,120 @@ +name: Test + +on: + pull_request: + branches: ["*"] + +jobs: + test: + runs-on: ubuntu-latest + name: pnpm test + env: + NEXT_PUBLIC_APP_URL: http://localhost:4000 + NEXT_PUBLIC_V0_URL: https://v0.dev + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: pnpm install + + - run: pnpm test + + react: + runs-on: ubuntu-latest + name: pnpm test (@shadcn/react) + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + run: pnpm install + + - run: pnpm --filter=@shadcn/react test + + helpers: + runs-on: ubuntu-latest + name: pnpm test (@shadcn/helpers) + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + run: pnpm install + + - run: pnpm --filter=@shadcn/helpers test diff --git a/.github/workflows/validate-registries.yml b/.github/workflows/validate-registries.yml new file mode 100644 index 0000000..d05e333 --- /dev/null +++ b/.github/workflows/validate-registries.yml @@ -0,0 +1,84 @@ +name: Validate Registries + +on: + pull_request: + paths: + - "apps/v4/registry/directory.json" + push: + branches: + - main + paths: + - "apps/v4/registry/directory.json" + +jobs: + validate: + runs-on: ubuntu-latest + name: pnpm validate:registries + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 20 + + - name: Block reserved registry namespaces + env: + RESERVED_NAMESPACES: "@shadcn,@ui,@blocks,@components,@block,@component,@util,@utils,@registry,@lib,@hook,@hooks,@theme,@themes,@chart,@charts" + run: | + node <<'EOF' + const fs = require("node:fs") + + const file = "apps/v4/registry/directory.json" + const reservedNamespaces = new Set( + process.env.RESERVED_NAMESPACES.split(",").filter(Boolean) + ) + + function readNames() { + return JSON.parse(fs.readFileSync(file, "utf8")).map( + (entry) => entry.name + ) + } + + const violations = readNames() + .filter((name) => reservedNamespaces.has(name)) + .map((name) => `${file}: ${name}`) + + if (violations.length > 0) { + console.error("Reserved registry namespaces are not allowed:") + + for (const violation of violations) { + console.error(`- ${violation}`) + } + + process.exit(1) + } + EOF + + - uses: pnpm/action-setup@v4 + name: Install pnpm + id: pnpm-install + with: + version: 10.33.4 + run_install: false + + - name: Get pnpm store directory + id: pnpm-cache + run: | + echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - name: Install dependencies + run: pnpm install + + - name: Validate registries + run: pnpm --filter=v4 validate:registries diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2193136 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +.eslintcache +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +.contentlayer +tsconfig.tsbuildinfo + +# ide +.idea +.fleet +.vscode + +.notes +.playwright-mcp +.playwright-cli +shadcn-workspace +plans + +# vitest browser mode writes these only on test failure. +__screenshots__ +.codex-artifacts +.registry-health +.vercel +.tmp* + +CONTEXT.md +docs/adr diff --git a/.kodiak.toml b/.kodiak.toml new file mode 100644 index 0000000..bbfdb22 --- /dev/null +++ b/.kodiak.toml @@ -0,0 +1,18 @@ +# .kodiak.toml +version = 1 + +[merge] +automerge_label = "automerge" +require_automerge_label = true +method = "squash" +delete_branch_on_merge = true +optimistic_updates = false +prioritize_ready_to_merge = true +notify_on_conflict = true + +[merge.message] +title = "pull_request_title" +body = "pull_request_body" +include_pr_number = true +body_type = "markdown" +strip_html_comments = true diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..6613b11 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +link-workspace-packages=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..8c60e1e --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v20.5.1 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fda2537 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +dist +node_modules +.next +build +.contentlayer +**/fixtures +deprecated +apps/v4/registry/styles/**/*.css diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ed21021 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,209 @@ +# Contributing + +Thanks for your interest in contributing to ui.shadcn.com. We're happy to have you here. + +Please take a moment to review this document before submitting your first pull request. We also strongly recommend that you check for open issues and pull requests to see if someone else is working on something similar. + +If you need any help, feel free to reach out to [@shadcn](https://twitter.com/shadcn). + +## About this repository + +This repository is a monorepo. + +- We use [pnpm](https://pnpm.io) and [`workspaces`](https://pnpm.io/workspaces) for development. +- We use [Turborepo](https://turbo.build/repo) as our build system. +- We use [changesets](https://github.com/changesets/changesets) for managing releases. + +## Structure + +This repository is structured as follows: + +``` +apps +└── v4 + ├── app + ├── components + ├── content + └── registry + └── new-york-v4 + ├── example + └── ui +packages +└── shadcn +``` + +| 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 + +### Fork this repo + +You can fork this repo by clicking the fork button in the top right corner of this page. + +### Clone on your local machine + +```bash +git clone https://github.com/your-username/ui.git +``` + +### Navigate to project directory + +```bash +cd ui +``` + +### Create a new Branch + +```bash +git checkout -b my-new-branch +``` + +### Install dependencies + +```bash +pnpm install +``` + +### Run a workspace + +You can use the `pnpm --filter=[WORKSPACE]` command to start the development process for a workspace. + +#### Examples + +1. To run the `ui.shadcn.com` website: + +```bash +pnpm --filter=v4 dev +``` + +> **Fresh clone?** The generated styles are not checked into git. Run +> `pnpm --filter=v4 registry:build --style all` once before starting the dev +> server. If you forget, the dev server will fail fast and tell you exactly +> this. + +2. To run the `shadcn` package: + +```bash +pnpm --filter=shadcn dev +``` + +## Running the CLI Locally + +To run the CLI locally, you can follow the workflow: + +1. Start by running the dev server: + + ```bash + pnpm dev + ``` + +2. In another terminal tab, test the CLI by running: + + ```bash + pnpm shadcn + ``` + + To test the CLI in a specific app, use a command like: + + ```bash + pnpm shadcn -c ~/Desktop/my-app + ``` + +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 `v4` workspace. You can run the documentation locally by running the following command: + +```bash +pnpm --filter=v4 dev +``` + +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/v4/registry`. The components are organized by styles. + +```bash +apps +└── v4 + └── registry + └── new-york-v4 + ├── example + └── ui +``` + +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 registry:build` to update the registry. + +See [`apps/v4/registry/README.md`](apps/v4/registry/README.md) for how the +registry pipeline is structured and for the faster targeted build modes +(`--style`, `--registry`, `--examples`, `--indexes`) you can use while +iterating locally. Always run the full `pnpm registry:build` before committing. + +Note that most generated output is not tracked in git: the installable JSON +under `apps/v4/public/r/styles` and the compiled styles under `apps/v4/styles` +are gitignored and rebuilt on every deploy. Running `registry:build` will not +dirty your working tree with generated files — only changes to the authored +sources (e.g. `registry/bases`) and the tracked indexes are committed. + +## Commit Convention + +Before you create a Pull Request, please check whether your commits comply with +the commit conventions used in this repository. + +When you create a commit we kindly ask you to follow the convention +`category(scope or module): message` in your commit message while using one of +the following categories: + +- `feat / feature`: all changes that introduce completely new code or new + features +- `fix`: changes that fix a bug (ideally you will additionally reference an + issue if present) +- `refactor`: any code related change that is not a fix nor a feature +- `docs`: changing existing or creating new documentation (i.e. README, docs for + usage of a lib or cli usage) +- `build`: all changes regarding the build of the software, changes to + dependencies or the addition of new dependencies +- `test`: all changes regarding tests (adding new tests or changing existing + ones) +- `ci`: all changes regarding the configuration of continuous integration (i.e. + github actions, ci system) +- `chore`: all changes to the repository that do not fit into any of the above + categories + + e.g. `feat(components): add new prop to the avatar component` + +If you are interested in the detailed specification you can visit +https://www.conventionalcommits.org/ or check out the +[Angular Commit Message Guidelines](https://github.com/angular/angular/blob/22b96b9/CONTRIBUTING.md#-commit-message-guidelines). + +## Requests for new components + +If you have a request for a new component, please open a discussion on GitHub. We'll be happy to help you out. + +## 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/shadcn` directory. If you can, it would be great if you could add tests for your changes. + +## Testing + +Tests are written using [Vitest](https://vitest.dev). You can run all the tests from the root of the repository. + +```bash +pnpm test +``` + +Please ensure that the tests are passing when submitting a pull request. If you're adding new features, please include tests. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..fad4d88 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 shadcn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..35657b3 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# shadcn/ui + +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**. + +![hero](apps/v4/public/opengraph-image.png) + +## Documentation + +Visit https://ui.shadcn.com/docs to view the documentation. + +## Contributing + +Please read the [contributing guide](/CONTRIBUTING.md). + +## License + +Licensed under the [MIT license](./LICENSE.md). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..8a7bd03 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,60 @@ +# Releasing + +This monorepo publishes three packages independently with [Changesets](https://github.com/changesets/changesets): + +- **`shadcn`** — the CLI and tooling. +- **`@shadcn/react`** — headless React primitives. +- **`@shadcn/helpers`** — small helpers for developing apps. + +They version on their own lines. A change to one never bumps the other unless a changeset says so. + +## 1. Add a changeset + +Every change that should publish needs a changeset. Run: + +```sh +pnpm changeset +``` + +Select the affected package(s) and bump level. One PR can carry separate changesets for each package at different levels. A PR with no changeset publishes nothing. + +## 2. Stable release + +Stable releases are automated by `.github/workflows/release.yml` (the `release` job, on push to `main`): + +1. Merged changesets accumulate on `main`. +2. The Changesets action opens/updates a **"Version Packages"** PR that bumps versions and writes changelogs. +3. Merging that PR triggers `changeset publish`, which builds all packages (`pnpm build:packages`) and publishes any whose version is ahead of npm — each to the `latest` tag. + +`pnpm build:packages` (`turbo run build --filter=./packages/*`) builds every package under `packages/` but never `apps/v4`. + +## 3. Prereleases (per-PR snapshots) + +Add the **`release: beta`** or **`release: rc`** label to a PR. The `prerelease` job in `release.yml`: + +1. Verifies the branch has changesets (a label on the version PR is a no-op, since it consumed them). +2. Runs `changeset version --snapshot ` — stamps a unique `0.0.0--` on each changeset'd package. +3. Builds and runs `changeset publish --tag --no-git-tag`. +4. Uploads the published package list; `prerelease-comment.yml` posts a `pnpm dlx` install line per package and removes the label. + +The label selects the **dist-tag/channel**; the **changesets on the branch** select which packages publish. Snapshots are timestamped, so they never touch `latest` and never collide. + +```sh +# Install a snapshot from the PR comment, e.g.: +pnpm dlx @shadcn/react@0.0.0-beta-20260624120000 +``` + +## 4. Prerelease trains (sustained `-beta.N` / `-rc.N`) + +For a baking release line (e.g. `1.0.0-rc.0`, `-rc.1`, …) rather than throwaway snapshots, use Changesets pre mode: + +```sh +pnpm changeset pre enter rc # writes .changeset/pre.json +# ...normal changeset + Version PR cycle now produces -rc.N versions on the rc tag... +pnpm changeset pre exit # back to stable; next Version PR ships X.Y.Z on latest +``` + +## Notes + +- `pnpm-workspace.yaml` sets `minimumReleaseAge: 2880` (48h), so freshly published stable/beta versions take time to resolve in normal installs. Use `pnpm dlx @` to test immediately. +- Publishing uses npm OIDC/provenance (`id-token: write` + `npm@latest`); no `NPM_TOKEN` secret is needed. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..da8f903 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +If you believe you have found a security vulnerability, we encourage you to let us know right away. + +We will investigate all legitimate reports and do our best to quickly fix the problem. + +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](https://github.com/shadcn-ui/ui/security/advisories/new) button. diff --git a/apps/v4/.env.example b/apps/v4/.env.example new file mode 100644 index 0000000..d2f09fb --- /dev/null +++ b/apps/v4/.env.example @@ -0,0 +1,2 @@ +NEXT_PUBLIC_V0_URL=https://v0.dev +NEXT_PUBLIC_APP_URL=http://localhost:4000 \ No newline at end of file diff --git a/apps/v4/.gitignore b/apps/v4/.gitignore new file mode 100644 index 0000000..c588538 --- /dev/null +++ b/apps/v4/.gitignore @@ -0,0 +1,63 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + + +# generated content +.contentlayer +.content-collections +.source +.devtools + +# generated registry output (rebuilt by registry:build on every deploy). +# index.json, default and new-york are NOT build outputs: index.json is a +# static contract older CLIs fetch, and default/new-york are frozen v3 +# styles maintained by editing the JSON directly. +/public/r/styles/* +!/public/r/styles/index.json +!/public/r/styles/default/ +!/public/r/styles/new-york/ + +# generated style sources (rebuilt by registry:build, or targeted via +# `pnpm registry:build --style all`). See styles/README.md. +/styles/* +!/styles/README.md diff --git a/apps/v4/.prettierignore b/apps/v4/.prettierignore new file mode 100644 index 0000000..9e1ea2a --- /dev/null +++ b/apps/v4/.prettierignore @@ -0,0 +1,9 @@ +dist +node_modules +.next +build +.contentlayer +registry/__index__.tsx +content/docs/components/calendar.mdx +registry/styles/**/*.css +/styles diff --git a/apps/v4/README.md b/apps/v4/README.md new file mode 100644 index 0000000..cb8a74a --- /dev/null +++ b/apps/v4/README.md @@ -0,0 +1 @@ +This is a wip registry for the `shadcn` canary version. It has React 19 and Tailwind v4 components. diff --git a/apps/v4/app/(app)/(create)/components/accent-picker.tsx b/apps/v4/app/(app)/(create)/components/accent-picker.tsx new file mode 100644 index 0000000..1ad793d --- /dev/null +++ b/apps/v4/app/(app)/(create)/components/accent-picker.tsx @@ -0,0 +1,119 @@ +"use client" + +import { MENU_ACCENTS, type MenuAccentValue } from "@/registry/config" +import { LockButton } from "@/app/(app)/(create)/components/lock-button" +import { + Picker, + PickerContent, + PickerGroup, + PickerRadioGroup, + PickerRadioItem, + PickerTrigger, +} from "@/app/(app)/(create)/components/picker" +import { usePreviewOverride } from "@/app/(app)/(create)/components/preview-override" +import { useDesignSystemSearchParams } from "@/app/(app)/(create)/lib/search-params" + +export function MenuAccentPicker({ + isMobile, + anchorRef, +}: { + isMobile: boolean + anchorRef: React.RefObject +}) { + const [params, setParams] = useDesignSystemSearchParams() + const { setOverride, clearOverride } = usePreviewOverride() + + const currentAccent = MENU_ACCENTS.find( + (accent) => accent.value === params.menuAccent + ) + + return ( +
+ { + if (!open) { + clearOverride() + } + }} + > + +
+
Menu Accent
+
+ {currentAccent?.label} +
+
+
+ + + + +
+
+ + { + setParams({ menuAccent: value as MenuAccentValue }) + }} + onItemPreview={ + isMobile + ? undefined + : (value) => + setOverride({ menuAccent: value as MenuAccentValue }) + } + > + + {MENU_ACCENTS.map((accent) => ( + + {accent.label} + + ))} + + + +
+ +
+ ) +} diff --git a/apps/v4/app/(app)/(create)/components/action-menu.tsx b/apps/v4/app/(app)/(create)/components/action-menu.tsx new file mode 100644 index 0000000..1f5b0af --- /dev/null +++ b/apps/v4/app/(app)/(create)/components/action-menu.tsx @@ -0,0 +1,88 @@ +"use client" + +import Script from "next/script" +import { type RegistryItem } from "shadcn/schema" + +import { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/styles/base-nova/ui/command" +import { useActionMenu } from "@/app/(app)/(create)/hooks/use-action-menu" + +export const CMD_K_FORWARD_TYPE = "cmd-k-forward" + +export function ActionMenu({ + itemsByBase, +}: { + itemsByBase: Record[]> +}) { + const { + activeRegistryName, + getCommandValue, + groups, + handleSelect, + open, + setOpen, + } = useActionMenu(itemsByBase) + + return ( + + + + + No items found. + + {groups.map((group) => + group.items.map((item) => ( + { + handleSelect(item.registryName) + }} + > + {item.label} + + )) + )} + + + + + ) +} + +export function ActionMenuScript() { + return ( + + + + +

Astro

+ + +``` + +## Add a mode toggle + +```tsx title="src/components/ModeToggle.tsx" +import * as React from "react" +import { Moon, Sun } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export function ModeToggle() { + const [theme, setThemeState] = React.useState< + "theme-light" | "dark" | "system" + >("theme-light") + + React.useEffect(() => { + const isDarkMode = document.documentElement.classList.contains("dark") + setThemeState(isDarkMode ? "dark" : "theme-light") + }, []) + + React.useEffect(() => { + const isDark = + theme === "dark" || + (theme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches) + document.documentElement.classList[isDark ? "add" : "remove"]("dark") + }, [theme]) + + return ( + + + + + + setThemeState("theme-light")}> + Light + + setThemeState("dark")}> + Dark + + setThemeState("system")}> + System + + + + ) +} +``` + +## Display the mode toggle + +Place a mode toggle on your site to toggle between light and dark mode. + +```astro title="src/pages/index.astro" +--- +import '../styles/globals.css' +import { ModeToggle } from '@/components/ModeToggle'; +--- + + + + + +

Astro

+ + + +``` diff --git a/apps/v4/content/docs/dark-mode/index.mdx b/apps/v4/content/docs/dark-mode/index.mdx new file mode 100644 index 0000000..72d54ca --- /dev/null +++ b/apps/v4/content/docs/dark-mode/index.mdx @@ -0,0 +1,75 @@ +--- +title: Dark Mode +description: Adding dark mode to your site. +--- + +
+ + + Next.js + + +

Next.js

+
+ + + Vite + + +

Vite

+
+ + + + + +

Astro

+
+ + + Remix + + +

Remix

+
+ + + TanStack + + +

TanStack Start

+
+
diff --git a/apps/v4/content/docs/dark-mode/meta.json b/apps/v4/content/docs/dark-mode/meta.json new file mode 100644 index 0000000..950e756 --- /dev/null +++ b/apps/v4/content/docs/dark-mode/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Dark mode", + "pages": ["index", "next", "vite", "astro", "remix", "tanstack-start"] +} diff --git a/apps/v4/content/docs/dark-mode/next.mdx b/apps/v4/content/docs/dark-mode/next.mdx new file mode 100644 index 0000000..88ee8eb --- /dev/null +++ b/apps/v4/content/docs/dark-mode/next.mdx @@ -0,0 +1,70 @@ +--- +title: Next.js +description: Adding dark mode to your Next.js app. +--- + + + +## Install next-themes + +Start by installing `next-themes`: + +```bash +npm install next-themes +``` + +## Create a theme provider + +```tsx title="components/theme-provider.tsx" showLineNumbers +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +export function ThemeProvider({ + children, + ...props +}: React.ComponentProps) { + return {children} +} +``` + +## Wrap your root layout + +Add the `ThemeProvider` to your root layout and add the `suppressHydrationWarning` prop to the `html` tag. + +```tsx {1,6,9-14,16} title="app/layout.tsx" showLineNumbers +import { ThemeProvider } from "@/components/theme-provider" + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + <> + + + + + {children} + + + + + ) +} +``` + +## Add a mode toggle + +Place a mode toggle on your site to toggle between light and dark mode. + + + + diff --git a/apps/v4/content/docs/dark-mode/remix.mdx b/apps/v4/content/docs/dark-mode/remix.mdx new file mode 100644 index 0000000..897be9a --- /dev/null +++ b/apps/v4/content/docs/dark-mode/remix.mdx @@ -0,0 +1,157 @@ +--- +title: Remix +description: Adding dark mode to your Remix app. +--- + + + +## Modify your tailwind.css file + +Add `:root[class~="dark"]` to your tailwind.css file. This will allow you to use the `dark` class on your html element to apply dark mode styles. + +```css {2} title="app/tailwind.css" showLineNumbers +.dark, +:root[class~="dark"] { + ...; +} +``` + +## Install remix-themes + +Start by installing `remix-themes`: + +```bash +npm install remix-themes +``` + +## Create a session storage and theme session resolver + +```tsx title="app/sessions.server.tsx" showLineNumbers +import { createThemeSessionResolver } from "remix-themes" + +// You can default to 'development' if process.env.NODE_ENV is not set +const isProduction = process.env.NODE_ENV === "production" + +const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "theme", + path: "/", + httpOnly: true, + sameSite: "lax", + secrets: ["s3cr3t"], + // Set domain and secure only if in production + ...(isProduction + ? { domain: "your-production-domain.com", secure: true } + : {}), + }, +}) + +export const themeSessionResolver = createThemeSessionResolver(sessionStorage) +``` + +## Set up Remix Themes + +Add the `ThemeProvider` to your root layout. + +```tsx {1-3,6-11,15-22,25-26,28,33} title="app/root.tsx" showLineNumbers +import clsx from "clsx" +import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from "remix-themes" + +import { themeSessionResolver } from "./sessions.server" + +// Return the theme from the session storage using the loader +export async function loader({ request }: LoaderFunctionArgs) { + const { getTheme } = await themeSessionResolver(request) + return { + theme: getTheme(), + } +} +// Wrap your app with ThemeProvider. +// `specifiedTheme` is the stored theme in the session storage. +// `themeAction` is the action name that's used to change the theme in the session storage. +export default function AppWithProviders() { + const data = useLoaderData() + return ( + + + + ) +} + +export function App() { + const data = useLoaderData() + const [theme] = useTheme() + return ( + + + + + + + + + + + + + + + + ) +} +``` + +## Add an action route + +Create a file in `/routes/action.set-theme.ts`. Ensure that you pass the filename to the ThemeProvider component. This route is used to store the preferred theme in the session storage when the user changes it. + +```tsx title="app/routes/action.set-theme.ts" showLineNumbers +import { createThemeAction } from "remix-themes" + +import { themeSessionResolver } from "./sessions.server" + +export const action = createThemeAction(themeSessionResolver) +``` + +## Add a mode toggle + +Place a mode toggle on your site to toggle between light and dark mode. + +```tsx title="components/mode-toggle.tsx" showLineNumbers +import { Moon, Sun } from "lucide-react" +import { Theme, useTheme } from "remix-themes" + +import { Button } from "./ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu" + +export function ModeToggle() { + const [, setTheme] = useTheme() + + return ( + + + + + + setTheme(Theme.LIGHT)}> + Light + + setTheme(Theme.DARK)}> + Dark + + + + ) +} +``` + + diff --git a/apps/v4/content/docs/dark-mode/tanstack-start.mdx b/apps/v4/content/docs/dark-mode/tanstack-start.mdx new file mode 100644 index 0000000..36198b4 --- /dev/null +++ b/apps/v4/content/docs/dark-mode/tanstack-start.mdx @@ -0,0 +1,191 @@ +--- +title: TanStack Start +description: Adding dark mode to your TanStack Start app. +--- + + + +### Create a theme provider + +TanStack Start uses `ScriptOnce` from `@tanstack/react-router` to inject a script that runs before React hydrates, preventing flash of unstyled content (FOUC). + +```tsx title="components/theme-provider.tsx" showLineNumbers +import { createContext, useContext, useEffect, useState } from "react" +import { ScriptOnce } from "@tanstack/react-router" + +type Theme = "dark" | "light" | "system" + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +function getThemeScript(storageKey: string, defaultTheme: Theme) { + const key = JSON.stringify(storageKey) + const fallback = JSON.stringify(defaultTheme) + + return `(function(){try{var t=localStorage.getItem(${key});if(t!=='light'&&t!=='dark'&&t!=='system'){t=${fallback}}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();` +} + +const ThemeProviderContext = createContext({ + theme: "system", + setTheme: () => {}, +}) + +function applyTheme(theme: Theme) { + const root = document.documentElement + root.classList.remove("light", "dark") + + const resolved = + theme === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : theme + + root.classList.add(resolved) + root.style.colorScheme = resolved +} + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "theme", +}: ThemeProviderProps) { + const [theme, setThemeState] = useState(defaultTheme) + const [mounted, setMounted] = useState(false) + + useEffect(() => { + const stored = localStorage.getItem(storageKey) + setThemeState( + stored === "light" || stored === "dark" || stored === "system" + ? stored + : defaultTheme + ) + setMounted(true) + }, [defaultTheme, storageKey]) + + useEffect(() => { + if (!mounted) return + applyTheme(theme) + }, [theme, mounted]) + + useEffect(() => { + if (!mounted || theme !== "system") return + + const media = window.matchMedia("(prefers-color-scheme: dark)") + const onChange = () => applyTheme("system") + media.addEventListener("change", onChange) + return () => media.removeEventListener("change", onChange) + }, [theme, mounted]) + + const setTheme = (next: Theme) => { + localStorage.setItem(storageKey, next) + setThemeState(next) + } + + return ( + + {getThemeScript(storageKey, defaultTheme)} + {children} + + ) +} + +export function useTheme() { + const context = useContext(ThemeProviderContext) + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider") + return context +} +``` + +### Wrap your root layout + +Add the `ThemeProvider` to your root layout and add the `suppressHydrationWarning` prop to the `html` tag. + +```tsx {8,19,24-26} title="src/routes/__root.tsx" showLineNumbers +import { + createRootRoute, + HeadContent, + Outlet, + Scripts, +} from "@tanstack/react-router" + +import { ThemeProvider } from "@/components/theme-provider" + +export const Route = createRootRoute({ + head: () => ({ + // ... + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + + + + + + + + + ) +} +``` + +### Add a mode toggle + +Place a mode toggle on your site to toggle between light and dark mode. + +```tsx title="components/mode-toggle.tsx" showLineNumbers +import { Moon, Sun } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useTheme } from "@/components/theme-provider" + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} +``` + + diff --git a/apps/v4/content/docs/dark-mode/vite.mdx b/apps/v4/content/docs/dark-mode/vite.mdx new file mode 100644 index 0000000..f95a5f9 --- /dev/null +++ b/apps/v4/content/docs/dark-mode/vite.mdx @@ -0,0 +1,144 @@ +--- +title: Vite +description: Adding dark mode to your Vite app. +--- + +## Create a theme provider + +```tsx title="components/theme-provider.tsx" showLineNumbers +import { createContext, useContext, useEffect, useState } from "react" + +type Theme = "dark" | "light" | "system" + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +} + +type ThemeProviderState = { + theme: Theme + setTheme: (theme: Theme) => void +} + +const initialState: ThemeProviderState = { + theme: "system", + setTheme: () => null, +} + +const ThemeProviderContext = createContext(initialState) + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme + ) + + useEffect(() => { + const root = window.document.documentElement + + root.classList.remove("light", "dark") + + if (theme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light" + + root.classList.add(systemTheme) + return + } + + root.classList.add(theme) + }, [theme]) + + const value = { + theme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme) + setTheme(theme) + }, + } + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider") + + return context +} +``` + +## Wrap your root layout + +Add the `ThemeProvider` to your root layout. + +```tsx {1,5-7} title="App.tsx" showLineNumbers +import { ThemeProvider } from "@/components/theme-provider" + +function App() { + return ( + + {children} + + ) +} + +export default App +``` + +## Add a mode toggle + +Place a mode toggle on your site to toggle between light and dark mode. + +```tsx title="components/mode-toggle.tsx" showLineNumbers +import { Moon, Sun } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useTheme } from "@/components/theme-provider" + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} +``` diff --git a/apps/v4/content/docs/forms/formisch.mdx b/apps/v4/content/docs/forms/formisch.mdx new file mode 100644 index 0000000..c4fc15b --- /dev/null +++ b/apps/v4/content/docs/forms/formisch.mdx @@ -0,0 +1,678 @@ +--- +title: Formisch +description: Build forms in React using Formisch and Valibot. +links: + doc: https://formisch.dev +--- + +import { InfoIcon } from "lucide-react" + +This guide covers building forms with [Formisch](https://formisch.dev), the lightweight, schema-first, and fully type-safe form library for React. We'll create forms with the `` component, validate them with Valibot schemas, handle errors, and ensure accessibility. + +## Demo + +We'll 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. + +}> + **Note:** For the purpose of this demo, we have intentionally disabled browser + validation to show how schema validation and form errors work in Formisch. It + is recommended to add basic browser validation in your production code. + + + + +## Approach + +This form leverages Formisch for headless, schema-first form handling. We'll build our form using the `` component, which gives you **complete flexibility over the markup and styling**. + +- Uses Formisch's `useForm` hook for form state management. +- `
` component to wrap the native `` element with submit handling. +- `` render-prop component for controlled inputs. +- Schema validation using [Valibot](https://valibot.dev). +- Type-safe field paths inferred from the schema. + +## Form Methods + +Formisch exposes form operations as **top-level functions** rather than methods on a form object. Import only what you need: + +```ts +import { getInput, insert, reset, submit } from "@formisch/react" +``` + +Every method follows the same signature: the **first parameter is always the form store**, and the **second parameter (if necessary) is always a config object**. + +```ts +// Read a field value +const email = getInput(form, { path: ["email"] }) + +// Reset the form with new initial values +reset(form, { initialInput: { email: "", password: "" } }) + +// Move an item in a field array +move(form, { path: ["items"], from: 0, to: 3 }) +``` + +This design keeps the API flexible and consistent across all methods. You'll see the same `(form, config)` shape used throughout this guide for reading state (`getInput`, `getErrors`), writing state (`setInput`, `setErrors`), form control (`submit`, `validate`, `focus`), and array operations (`insert`, `remove`, `move`, `swap`, `replace`). See the [full methods reference](https://formisch.dev/react/guides/form-methods) for details. + +## Anatomy + +Here's a basic example of a form using the `` component from Formisch and the shadcn `` component. + +```tsx showLineNumbers {3-21} + + + + {(field) => ( + + Bug Title + + + Provide a concise title for your bug report. + + {field.errors && ( + ({ message }))} /> + )} + + )} + + + +``` + +}> + **Note:** Formisch ships its own `Field` component. To avoid a name clash with + the shadcn `Field`, the examples below import the Formisch one as + `FormischField` and keep the shadcn `Field` under its original name. In your + own code you can alias either side — just be consistent. + + +## Form + +### Create a form schema + +We'll start by defining the shape of our form using a Valibot schema. Formisch infers all input and output types directly from this schema. + +```tsx showLineNumbers title="form.tsx" +import * as v from "valibot" + +const FormSchema = v.object({ + title: v.pipe( + v.string(), + v.minLength(5, "Bug title must be at least 5 characters."), + v.maxLength(32, "Bug title must be at most 32 characters.") + ), + description: v.pipe( + v.string(), + v.minLength(20, "Description must be at least 20 characters."), + v.maxLength(100, "Description must be at most 100 characters.") + ), +}) +``` + +### Set up the form + +Next, we'll use the `useForm` hook from Formisch to create our form instance. The schema is passed directly to `useForm` — there is no resolver step. + +```tsx showLineNumbers title="form.tsx" {1-2,21-25} +import { Form, Field as FormischField, useForm } from "@formisch/react" +import type { SubmitHandler } from "@formisch/react" +import * as v from "valibot" + +const FormSchema = v.object({ + title: v.pipe( + v.string(), + v.minLength(5, "Bug title must be at least 5 characters."), + v.maxLength(32, "Bug title must be at most 32 characters.") + ), + description: v.pipe( + v.string(), + v.minLength(20, "Description must be at least 20 characters."), + v.maxLength(100, "Description must be at most 100 characters.") + ), +}) + +export function BugReportForm() { + const form = useForm({ + schema: FormSchema, + initialInput: { + title: "", + description: "", + }, + }) + + const handleSubmit: SubmitHandler = (output) => { + // Do something with the validated form values. + console.log(output) + } + + return ( +
+ {/* ... */} + {/* Build the form here */} + {/* ... */} +
+ ) +} +``` + +The `
` component wraps a native `` element. It calls `event.preventDefault()`, runs validation, and only invokes `onSubmit` when the data is valid. The `output` you receive is fully typed from the schema. + +### Build the form + +We can now build the form using the `` component from Formisch and the shadcn `` component. + + + +### Done + +That's it. You now have a fully accessible form with client-side validation. + +When you submit the form, the `handleSubmit` function will be called with the validated form data. If the form data is invalid, Formisch will populate `field.errors` for each invalid field and the UI will display them. + +## Validation + +### Client-side Validation + +Formisch validates your form data using the Valibot schema you pass to `useForm`. There is no resolver — the schema is the single source of truth for both runtime validation and static types. + +```tsx showLineNumbers title="form.tsx" {1,3-6,11} +import { useForm } from "@formisch/react" + +const FormSchema = v.object({ + title: v.string(), + description: v.optional(v.string()), +}) + +export function ExampleForm() { + const form = useForm({ + schema: FormSchema, + initialInput: { + title: "", + description: "", + }, + }) +} +``` + +### Validation Modes + +Formisch separates the **first** validation from **subsequent** validations. You configure them with the `validate` and `revalidate` options on `useForm`. + +```tsx showLineNumbers title="form.tsx" {3-4} +const form = useForm({ + schema: FormSchema, + validate: "blur", + revalidate: "input", +}) +``` + +| Option | Value | Description | +| ------------ | ----------- | --------------------------------------------------------------- | +| `validate` | `"submit"` | Validate on form submission (default). | +| `validate` | `"blur"` | Validate when a field loses focus. | +| `validate` | `"input"` | Validate on every input change. | +| `validate` | `"initial"` | Validate immediately on form creation. | +| `revalidate` | `"input"` | Revalidate on every input change after the first run (default). | +| `revalidate` | `"blur"` | Revalidate on blur after the first run. | +| `revalidate` | `"submit"` | Revalidate only on form submission. | + +## Displaying Errors + +Display errors next to the field using ``. Formisch returns errors as an array of strings, so map them to the shape `` expects. For styling and accessibility: + +- Add the `data-invalid` prop to the `` component. +- Add the `aria-invalid` prop to the form control such as ``, ``, ``, etc. + +```tsx showLineNumbers title="form.tsx" {3,10,12-14} + + {(field) => ( + + Email + + {field.errors && ( + ({ message }))} /> + )} + + )} + +``` + +## Working with Different Field Types + +Formisch exposes two ways to bind a field to an element: + +- **Native HTML elements** (like `` and `