mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-02 17:08:39 +00:00
Compare commits
77 Commits
shadcn@4.1
...
update/bas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7c378669 | ||
|
|
ec132714d2 | ||
|
|
94174c1eab | ||
|
|
44c9a8a8ba | ||
|
|
2f8f2be8f9 | ||
|
|
0d07d35d2d | ||
|
|
139ff074bb | ||
|
|
8d0364a9aa | ||
|
|
7e07ce76f9 | ||
|
|
973ebea834 | ||
|
|
709d0723e6 | ||
|
|
42e09a31e6 | ||
|
|
d3002bd9d9 | ||
|
|
dbf9c5ebc4 | ||
|
|
897e9add14 | ||
|
|
8d6553a7f5 | ||
|
|
683073f102 | ||
|
|
02e398ab73 | ||
|
|
af79276f7e | ||
|
|
5a3ad36a5e | ||
|
|
a63e8359ec | ||
|
|
a72491cb9b | ||
|
|
67aec7dcc5 | ||
|
|
40c7064532 | ||
|
|
5b3369f6ee | ||
|
|
b31e6d63b0 | ||
|
|
cf5b227565 | ||
|
|
8055a12f46 | ||
|
|
18fcf0f766 | ||
|
|
c520191cd4 | ||
|
|
35983528c2 | ||
|
|
8692cd4cc1 | ||
|
|
d3727d8c45 | ||
|
|
e3f98d49e4 | ||
|
|
549852bffc | ||
|
|
4139cff3a0 | ||
|
|
95471a0fb9 | ||
|
|
b59f68ecc5 | ||
|
|
6956a099b3 | ||
|
|
2417242b12 | ||
|
|
70a7a0c2a8 | ||
|
|
5602b81d83 | ||
|
|
3ffd3e1c7c | ||
|
|
e03df56fcf | ||
|
|
38fb1b6f41 | ||
|
|
13eb6a81d1 | ||
|
|
5cc8a2af42 | ||
|
|
365d53b590 | ||
|
|
c2ddedf5d2 | ||
|
|
c879483c96 | ||
|
|
4885a9a7ad | ||
|
|
2f5929269a | ||
|
|
2fdf1bb0d4 | ||
|
|
951750bdbe | ||
|
|
6c5d5d6374 | ||
|
|
6ec117715f | ||
|
|
e583c773fe | ||
|
|
0f154171a7 | ||
|
|
9197676b3d | ||
|
|
82dce7e945 | ||
|
|
35bc9934bf | ||
|
|
1fd75c9d7e | ||
|
|
2ea31d8070 | ||
|
|
ea9d371a2d | ||
|
|
d15f17d717 | ||
|
|
46ca8c5d4b | ||
|
|
a5eb279650 | ||
|
|
1994caba0b | ||
|
|
1450bea8d6 | ||
|
|
ced2a5beb5 | ||
|
|
10f1717a3e | ||
|
|
deba036d50 | ||
|
|
5bd81beebf | ||
|
|
4b39b1c614 | ||
|
|
e224dc30d8 | ||
|
|
2f503b7884 | ||
|
|
07ab679555 |
35
.github/collect-prerelease-info.js
vendored
Normal file
35
.github/collect-prerelease-info.js
vendored
Normal file
@@ -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-<channel>-<timestamp>`, 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).`)
|
||||
37
.github/version-script-prerelease.js
vendored
37
.github/version-script-prerelease.js
vendored
@@ -1,37 +0,0 @@
|
||||
import fs from "fs"
|
||||
|
||||
const pkgJsonPath = "packages/shadcn/package.json"
|
||||
const channel = process.argv[2]
|
||||
const headSha = process.argv[3]
|
||||
|
||||
if (!["beta", "rc"].includes(channel)) {
|
||||
console.error(
|
||||
`Expected prerelease channel to be "beta" or "rc", got "${channel}".`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!headSha) {
|
||||
console.error("Expected pull request head SHA.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"))
|
||||
const shortSha = headSha.trim().slice(0, 7)
|
||||
const baseVersion = channel === "beta" ? "0.0.0" : pkg.version
|
||||
|
||||
if (channel === "rc" && baseVersion.includes("-")) {
|
||||
console.error(
|
||||
`Expected a stable planned version for rc, got "${baseVersion}".`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
pkg.version = `${baseVersion}-${channel}.${shortSha}`
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkg, null, "\t") + "\n")
|
||||
console.log(`Prepared shadcn@${pkg.version}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
56
.github/workflows/browser-tests.yml
vendored
Normal file
56
.github/workflows/browser-tests.yml
vendored
Normal file
@@ -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
|
||||
4
.github/workflows/code-check.yml
vendored
4
.github/workflows/code-check.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm --filter=shadcn build
|
||||
run: pnpm build:packages
|
||||
|
||||
- run: pnpm format:check
|
||||
|
||||
@@ -117,6 +117,6 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm --filter=shadcn build
|
||||
run: pnpm build:packages
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
88
.github/workflows/prerelease-comment.yml
vendored
88
.github/workflows/prerelease-comment.yml
vendored
@@ -16,50 +16,64 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Write comment to the PR
|
||||
steps:
|
||||
- name: "Comment on PR"
|
||||
# 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:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
const fs = require("fs");
|
||||
const info = JSON.parse(fs.readFileSync("prerelease-info.json", "utf8"));
|
||||
|
||||
for (const artifact of allArtifacts.data.artifacts) {
|
||||
// Extract the PR number and package version from the artifact name
|
||||
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
|
||||
|
||||
if (match) {
|
||||
const version = match[1];
|
||||
const channel = version.includes("-rc.") ? "rc" : "beta";
|
||||
require("fs").appendFileSync(
|
||||
process.env.GITHUB_ENV,
|
||||
`\nPRERELEASE_PACKAGE_VERSION=${version}` +
|
||||
`\nPRERELEASE_CHANNEL=${channel}` +
|
||||
`\nPRERELEASE_LABEL=release: ${channel}` +
|
||||
`\nWORKFLOW_RUN_PR=${match[2]}` +
|
||||
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (!info.packages || info.packages.length === 0) {
|
||||
core.info("No prerelease packages to comment.");
|
||||
return;
|
||||
}
|
||||
|
||||
- name: "Comment on PR with Link"
|
||||
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: ${{ env.WORKFLOW_RUN_PR }}
|
||||
message: |
|
||||
A new ${{ env.PRERELEASE_CHANNEL }} prerelease is available for testing:
|
||||
number: ${{ steps.info.outputs.pr }}
|
||||
message: ${{ steps.info.outputs.body }}
|
||||
|
||||
```sh
|
||||
pnpm dlx shadcn@${{ env.PRERELEASE_PACKAGE_VERSION }}
|
||||
```
|
||||
|
||||
View on npm: https://www.npmjs.com/package/shadcn/v/${{ env.PRERELEASE_PACKAGE_VERSION }}
|
||||
|
||||
- name: "Remove the prerelease label once published"
|
||||
- name: Remove the prerelease label once published
|
||||
if: steps.info.outputs.pr
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -68,8 +82,8 @@ jobs:
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
|
||||
name: '${{ env.PRERELEASE_LABEL }}',
|
||||
issue_number: Number("${{ steps.info.outputs.pr }}"),
|
||||
name: `release: ${{ steps.info.outputs.channel }}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
|
||||
88
.github/workflows/release.yml
vendored
88
.github/workflows/release.yml
vendored
@@ -46,21 +46,8 @@ jobs:
|
||||
);
|
||||
}
|
||||
|
||||
const selected = selectedLabels[0];
|
||||
const pullRequest = context.payload.pull_request;
|
||||
|
||||
if (
|
||||
selected.channel === "rc" &&
|
||||
(pullRequest.head.ref !== "changeset-release/main" ||
|
||||
pullRequest.title !== "chore(release): version packages")
|
||||
) {
|
||||
throw new Error(
|
||||
"The release: rc label can only be used on the Changesets version PR from changeset-release/main."
|
||||
);
|
||||
}
|
||||
|
||||
core.setOutput("channel", selected.channel);
|
||||
core.setOutput("label", selected.name);
|
||||
core.setOutput("channel", selectedLabels[0].channel);
|
||||
core.setOutput("label", selectedLabels[0].name);
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -86,37 +73,49 @@ jobs:
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Modify package.json version
|
||||
run: node .github/version-script-prerelease.js ${{ steps.prerelease.outputs.channel }} ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: packages/shadcn
|
||||
|
||||
- name: Check package version on NPM
|
||||
id: package-exists
|
||||
# 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: |
|
||||
if npm view "shadcn@${{ steps.package-version.outputs.current-version }}" version >/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
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: Publish Prerelease to NPM
|
||||
if: ${{ steps.package-exists.outputs.exists == 'false' }}
|
||||
run: pnpm pub:${{ steps.prerelease.outputs.channel }}
|
||||
- name: No changesets to prerelease
|
||||
if: steps.changesets.outputs.present == 'false'
|
||||
run: echo "::notice::No changesets found on this branch; nothing to prerelease."
|
||||
|
||||
- name: Build packaged artifact
|
||||
if: ${{ steps.package-exists.outputs.exists == 'true' }}
|
||||
run: pnpm shadcn:build
|
||||
# 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: Upload packaged artifact
|
||||
- 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: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
||||
path: packages/shadcn/dist/index.js
|
||||
name: prerelease-info
|
||||
path: prerelease-info.json
|
||||
|
||||
release:
|
||||
if: ${{ github.event_name == 'push' && github.repository_owner == 'shadcn-ui' }}
|
||||
@@ -151,11 +150,10 @@ jobs:
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
# - name: Check for errors
|
||||
# run: pnpm check
|
||||
|
||||
- name: Build the package
|
||||
run: pnpm shadcn:build
|
||||
# Builds every publishable package under packages/* (shadcn, @shadcn/react),
|
||||
# never apps/v4, so each dist is fresh before changeset publish.
|
||||
- name: Build the packages
|
||||
run: pnpm build:packages
|
||||
|
||||
- name: Import GPG key
|
||||
uses: crazy-max/ghaction-import-gpg@v6
|
||||
|
||||
36
.github/workflows/test.yml
vendored
36
.github/workflows/test.yml
vendored
@@ -46,3 +46,39 @@ jobs:
|
||||
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
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -45,4 +45,11 @@ tsconfig.tsbuildinfo
|
||||
.playwright-mcp
|
||||
.playwright-cli
|
||||
shadcn-workspace
|
||||
|
||||
# vitest browser mode writes these only on test failure.
|
||||
__screenshots__
|
||||
.codex-artifacts
|
||||
.tmp*
|
||||
|
||||
CONTEXT.md
|
||||
docs/adr
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -15,6 +15,7 @@
|
||||
"search.exclude": {
|
||||
"apps/v4/registry/radix-*": true,
|
||||
"apps/v4/public/r/*": true,
|
||||
"packages/shadcn/test/fixtures/*": true
|
||||
"packages/shadcn/test/fixtures/*": true,
|
||||
"apps/v4/styles/*": true
|
||||
}
|
||||
}
|
||||
|
||||
59
RELEASING.md
Normal file
59
RELEASING.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Releasing
|
||||
|
||||
This monorepo publishes two packages independently with [Changesets](https://github.com/changesets/changesets):
|
||||
|
||||
- **`shadcn`** — the CLI and tooling.
|
||||
- **`@shadcn/react`** — headless React primitives.
|
||||
|
||||
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 `shadcn` and `@shadcn/react` 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 `shadcn` and `@shadcn/react` 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 <channel>` — stamps a unique `0.0.0-<channel>-<timestamp>` on each changeset'd package.
|
||||
3. Builds and runs `changeset publish --tag <channel> --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 <pkg>@<exact-snapshot-version>` to test immediately.
|
||||
- Publishing uses npm OIDC/provenance (`id-token: write` + `npm@latest`); no `NPM_TOKEN` secret is needed.
|
||||
1
apps/v4/.gitignore
vendored
1
apps/v4/.gitignore
vendored
@@ -46,3 +46,4 @@ next-env.d.ts
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
.devtools
|
||||
|
||||
@@ -17,7 +17,6 @@ const chartData = [
|
||||
{ month: "Feb", amount: 900 },
|
||||
{ month: "Mar", amount: 1300 },
|
||||
{ month: "Apr", amount: 750 },
|
||||
{ month: "May", amount: 1400 },
|
||||
]
|
||||
|
||||
export function ContributionHistory() {
|
||||
@@ -35,13 +34,14 @@ export function ContributionHistory() {
|
||||
role="img"
|
||||
aria-label="Last 6 months of contribution activity"
|
||||
>
|
||||
{chartData.map((item) => (
|
||||
{chartData.map((item, index) => (
|
||||
<div
|
||||
key={item.month}
|
||||
className="flex h-full flex-1 flex-col justify-end gap-2"
|
||||
>
|
||||
<div
|
||||
className="min-h-2 rounded-t-md bg-chart-2"
|
||||
data-index={index}
|
||||
className="data-[index=5]:bg-chart-6 min-h-2 rounded-lg data-[index=0]:bg-chart-1 data-[index=1]:bg-chart-2 data-[index=2]:bg-chart-3 data-[index=3]:bg-chart-4 data-[index=4]:bg-chart-5"
|
||||
style={{ height: `${(item.amount / maxAmount) * 100}%` }}
|
||||
/>
|
||||
<span className="text-center text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MessageScrollerDemo } from "@/examples/radix/message-scroller-demo"
|
||||
|
||||
import { AccountAccess } from "./account-access"
|
||||
import { AnalyticsCard } from "./analytics-card"
|
||||
import { ClaimableBalance } from "./claimable-balance"
|
||||
@@ -79,7 +81,7 @@ export function CardsDemo() {
|
||||
return (
|
||||
<div
|
||||
data-slot="demo"
|
||||
className="theme-neutral relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background"
|
||||
className="theme-blue relative flex w-full max-w-none flex-col gap-(--gap) overflow-hidden bg-muted p-12 pb-0! [--gap:--spacing(8)] 3xl:[--gap:--spacing(8)] min-[1900px]:p-12 min-[1900px]:[--gap:--spacing(10)]! lg:p-6 lg:[--gap:--spacing(6)] dark:bg-background"
|
||||
>
|
||||
<CardsSkeletonRails />
|
||||
<div className="relative z-10 mx-auto grid gap-(--gap) **:data-[slot=card]:w-full min-[1400px]:grid-cols-4! min-[1900px]:grid-cols-5! md:max-w-3xl md:grid-cols-2 lg:max-w-none lg:grid-cols-3 xl:max-w-[1600px] 2xl:max-w-[1900px]">
|
||||
@@ -93,17 +95,20 @@ export function CardsDemo() {
|
||||
<ClaimableBalance />
|
||||
<DividendIncome />
|
||||
</div>
|
||||
<div className="hidden flex-col gap-(--gap) 3xl:flex!">
|
||||
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
|
||||
<NewMilestone />
|
||||
<PayoutThreshold />
|
||||
<AccountAccess />
|
||||
</div>
|
||||
<div className="hidden flex-col gap-(--gap) md:flex">
|
||||
<QrConnect />
|
||||
<TransferFunds />
|
||||
<div className="**:[.text-center.text-xs]:hidden">
|
||||
<MessageScrollerDemo />
|
||||
</div>
|
||||
{/* <TransferFunds /> */}
|
||||
<Payments />
|
||||
</div>
|
||||
<div className="hidden flex-col gap-(--gap) min-[1400px]:flex">
|
||||
<div className="hidden flex-col gap-(--gap) min-[1900px]:flex">
|
||||
<EmptyDistributeTrack />
|
||||
<AnalyticsCard />
|
||||
<NotificationSettings />
|
||||
|
||||
@@ -117,7 +117,7 @@ export function UIElements() {
|
||||
</span>
|
||||
<span className="flex md:hidden style-sera:md:flex">Dialog</span>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogContent size="sm" className="theme-blue">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Allow accessory to connect?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
|
||||
72
apps/v4/app/(app)/create/components/create-devtools.tsx
Normal file
72
apps/v4/app/(app)/create/components/create-devtools.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { BASES } from "@/registry/config"
|
||||
import { Button } from "@/registry/new-york-v4/ui/button"
|
||||
import { getDocsPathForItem } from "@/app/(app)/create/lib/devtools"
|
||||
import {
|
||||
serializeDesignSystemSearchParams,
|
||||
useDesignSystemSearchParams,
|
||||
} from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
export function CreateDevtools() {
|
||||
const [params, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const previewUrl = React.useMemo(
|
||||
() =>
|
||||
serializeDesignSystemSearchParams(
|
||||
`/preview/${params.base}/${params.item}`,
|
||||
params
|
||||
),
|
||||
[params]
|
||||
)
|
||||
|
||||
const docsUrl = React.useMemo(
|
||||
() => getDocsPathForItem(params.base, params.item),
|
||||
[params.base, params.item]
|
||||
)
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dark absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 items-center gap-0.5 rounded-xl bg-card/90 p-1 shadow-xl backdrop-blur-xl">
|
||||
{BASES.map((base) => (
|
||||
<Button
|
||||
key={base.name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-active={params.base === base.name}
|
||||
className="h-7 min-w-8 cursor-pointer rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-[active=true]:bg-accent data-[active=true]:text-accent-foreground"
|
||||
onClick={() => setParams({ base: base.name })}
|
||||
>
|
||||
{base.name === "radix" ? "Radix" : "Base"}
|
||||
</Button>
|
||||
))}
|
||||
<div className="mx-0.5 h-4 w-px bg-border/80" />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<a href={previewUrl} target="_blank" rel="noreferrer">
|
||||
Open in New Tab
|
||||
</a>
|
||||
</Button>
|
||||
<div className="mx-0.5 h-4 w-px bg-border/80" />
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<a href={docsUrl} target="_blank" rel="noreferrer">
|
||||
Open Docs
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { type RegistryItem } from "shadcn/schema"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { getThemesForBaseColor, STYLES } from "@/registry/config"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -32,11 +33,22 @@ import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
|
||||
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
|
||||
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
// Only visible when user clicks "Create Project".
|
||||
const ProjectForm = dynamic(() =>
|
||||
import("@/app/(app)/create/components/project-form").then(
|
||||
(m) => m.ProjectForm
|
||||
)
|
||||
// Only visible when user clicks "Create Project". Rendered client-only to
|
||||
// avoid a useId hydration mismatch on the Base UI dialog trigger. The loading
|
||||
// placeholder mirrors the trigger button exactly so there is no layout shift.
|
||||
const ProjectForm = dynamic(
|
||||
() =>
|
||||
import("@/app/(app)/create/components/project-form").then(
|
||||
(m) => m.ProjectForm
|
||||
),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<Button disabled aria-hidden>
|
||||
Get Code
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
export function Customizer({
|
||||
|
||||
@@ -111,11 +111,12 @@ export function OpenPreset({
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline" className={triggerClassName}>
|
||||
{label}
|
||||
</Button>
|
||||
<DrawerTrigger
|
||||
render={<Button variant="outline" className={triggerClassName} />}
|
||||
>
|
||||
{label}
|
||||
</DrawerTrigger>
|
||||
|
||||
<DrawerContent className="dark rounded-t-2xl!">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
|
||||
@@ -127,10 +128,12 @@ export function OpenPreset({
|
||||
<Button type="submit" className="h-10" disabled={!nextPreset}>
|
||||
Open
|
||||
</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline" type="button" className="h-10">
|
||||
Cancel
|
||||
</Button>
|
||||
<DrawerClose
|
||||
render={
|
||||
<Button variant="outline" type="button" className="h-10" />
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { CMD_K_FORWARD_TYPE } from "@/app/(app)/create/components/action-menu"
|
||||
import { CreateDevtools } from "@/app/(app)/create/components/create-devtools"
|
||||
import {
|
||||
REDO_FORWARD_TYPE,
|
||||
UNDO_FORWARD_TYPE,
|
||||
@@ -160,6 +161,7 @@ export function Preview() {
|
||||
title="Preview"
|
||||
/>
|
||||
</div>
|
||||
<CreateDevtools />
|
||||
<PreviewSwitcher />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -42,7 +42,8 @@ export async function getBaseComponent(name: string, base: BaseName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return index[name].component
|
||||
const { Components } = await import("@/registry/bases/__components__")
|
||||
return Components[base]?.[name] ?? null
|
||||
}
|
||||
|
||||
export async function getAllItems() {
|
||||
|
||||
10
apps/v4/app/(app)/create/lib/devtools.ts
Normal file
10
apps/v4/app/(app)/create/lib/devtools.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type BaseName } from "@/registry/config"
|
||||
|
||||
export function getDocsPathForItem(base: BaseName, item: string) {
|
||||
if (item.endsWith("-example")) {
|
||||
const component = item.slice(0, -"-example".length)
|
||||
return `/docs/components/${base}/${component}`
|
||||
}
|
||||
|
||||
return "/docs/components"
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export default async function Page(props: {
|
||||
<div className="sticky top-[calc(var(--header-height)+1px)] z-30 ml-auto hidden h-[90svh] w-(--sidebar-width) flex-col gap-4 overflow-hidden overscroll-none pb-8 xl:flex">
|
||||
<div className="h-(--top-spacing) shrink-0"></div>
|
||||
{doc.toc?.length ? (
|
||||
<div className="no-scrollbar flex flex-col gap-8 overflow-y-auto px-8">
|
||||
<div className="flex scroll-fade scrollbar-none flex-col gap-8 overflow-y-auto px-8">
|
||||
<DocsTableOfContents toc={doc.toc} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { RandomizeScript } from "@/app/(app)/create/components/random-button"
|
||||
import { getBaseComponent, getBaseItem } from "@/app/(app)/create/lib/api"
|
||||
|
||||
import "@/app/style-registry.css"
|
||||
import "streamdown/styles.css"
|
||||
|
||||
export const revalidate = false
|
||||
export const dynamic = "force-static"
|
||||
|
||||
74
apps/v4/app/(view)/examples/[base]/[name]/page.tsx
Normal file
74
apps/v4/app/(view)/examples/[base]/[name]/page.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { type Metadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ExamplesComponents } from "@/examples/__components__"
|
||||
import { ExamplesIndex } from "@/examples/__index__"
|
||||
|
||||
import { siteConfig } from "@/lib/config"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
|
||||
export const dynamicParams = true
|
||||
export const revalidate = 3600
|
||||
|
||||
function getExample(base: string, name: string) {
|
||||
const item = ExamplesIndex[base]?.[name]
|
||||
const Component = ExamplesComponents[base]?.[name]
|
||||
if (!item || !Component) {
|
||||
return null
|
||||
}
|
||||
return { item, Component }
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ base: string; name: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { base, name } = await params
|
||||
const example = getExample(base, name)
|
||||
|
||||
if (!example) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const title = example.item.name
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: {
|
||||
title,
|
||||
type: "article",
|
||||
url: absoluteUrl(`/examples/${base}/${title}`),
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteConfig.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title,
|
||||
images: [siteConfig.ogImage],
|
||||
creator: "@shadcn",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ExamplePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ base: string; name: string }>
|
||||
}) {
|
||||
const { base, name } = await params
|
||||
const example = getExample(base, name)
|
||||
|
||||
if (!example) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
const { Component } = example
|
||||
|
||||
return <Component />
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "./legacy-themes.css";
|
||||
|
||||
@source "../node_modules/streamdown/dist/*.js";
|
||||
|
||||
@custom-variant style-vega (&:where(.style-vega *));
|
||||
@custom-variant style-nova (&:where(.style-nova *));
|
||||
@custom-variant style-lyra (&:where(.style-lyra *));
|
||||
@@ -168,6 +170,7 @@
|
||||
@apply overscroll-y-none;
|
||||
}
|
||||
body {
|
||||
position: relative;
|
||||
font-synthesis-weight: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
@@ -284,6 +287,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-rehype-pretty-code-figure] code,
|
||||
[data-rehype-pretty-code-figure] code span {
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings:
|
||||
"liga" 0,
|
||||
"calt" 0;
|
||||
}
|
||||
|
||||
[data-rehype-pretty-code-title] {
|
||||
border-bottom: color-mix(in oklab, var(--border) 30%, transparent);
|
||||
border-bottom-width: 1px;
|
||||
|
||||
@@ -563,3 +563,74 @@
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-blue {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.488 0.243 264.376);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--selection: oklch(0.93 0.03 256);
|
||||
--selection-foreground: oklch(0.145 0 0);
|
||||
|
||||
@variant dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.424 0.199 265.638);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.809 0.105 251.813);
|
||||
--chart-2: oklch(0.623 0.214 259.815);
|
||||
--chart-3: oklch(0.546 0.245 262.881);
|
||||
--chart-4: oklch(0.488 0.243 264.376);
|
||||
--chart-5: oklch(0.424 0.199 265.638);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Badge } from "@/registry/new-york-v4/ui/badge"
|
||||
export function Announcement() {
|
||||
return (
|
||||
<Badge asChild variant="secondary" className="bg-muted">
|
||||
<Link href="/docs/registry/github">
|
||||
Introducing GitHub Registries <ArrowRightIcon />
|
||||
<Link href="/docs/changelog">
|
||||
Components for Chat Interfaces <ArrowRightIcon />
|
||||
</Link>
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IconArrowRight } from "@tabler/icons-react"
|
||||
import { useDocsSearch } from "fumadocs-core/search/client"
|
||||
import { CornerDownLeftIcon, SquareDashedIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
import { encodePreset } from "shadcn/preset"
|
||||
|
||||
import { type Color, type ColorPalette } from "@/lib/colors"
|
||||
import { trackEvent } from "@/lib/events"
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
} from "@/registry/new-york-v4/ui/dialog"
|
||||
import { Separator } from "@/registry/new-york-v4/ui/separator"
|
||||
import { Spinner } from "@/registry/new-york-v4/ui/spinner"
|
||||
import { STYLES } from "@/registry/styles"
|
||||
|
||||
export function CommandMenu({
|
||||
tree,
|
||||
@@ -56,7 +58,7 @@ export function CommandMenu({
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [renderDelayedGroups, setRenderDelayedGroups] = React.useState(false)
|
||||
const [selectedType, setSelectedType] = React.useState<
|
||||
"color" | "page" | "component" | "block" | null
|
||||
"color" | "page" | "component" | "block" | "style" | null
|
||||
>(null)
|
||||
const [copyPayload, setCopyPayload] = React.useState("")
|
||||
|
||||
@@ -208,6 +210,40 @@ export function CommandMenu({
|
||||
)
|
||||
}, [navItems, runCommand, router])
|
||||
|
||||
const stylesSection = React.useMemo(() => {
|
||||
return (
|
||||
<CommandGroup
|
||||
heading="Styles"
|
||||
className="p-0! **:[[cmdk-group-heading]]:scroll-mt-16 **:[[cmdk-group-heading]]:p-3! **:[[cmdk-group-heading]]:pb-1!"
|
||||
>
|
||||
{STYLES.map((style) => (
|
||||
<CommandMenuItem
|
||||
key={style.name}
|
||||
value={`Style ${style.title} ${style.description}`}
|
||||
keywords={["style", "preset", style.name, style.title]}
|
||||
onHighlight={() => {
|
||||
setSelectedType("style")
|
||||
setCopyPayload("")
|
||||
}}
|
||||
onSelect={() => {
|
||||
runCommand(() =>
|
||||
router.push(
|
||||
`/create?preset=${encodePreset({ style: style.name })}`
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
{style.icon}
|
||||
{style.title}
|
||||
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||
Open style in shadcn/create
|
||||
</span>
|
||||
</CommandMenuItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)
|
||||
}, [runCommand, router])
|
||||
|
||||
const pageGroupsSection = React.useMemo(() => {
|
||||
return tree.children.map((group) => {
|
||||
if (group.type !== "folder") {
|
||||
@@ -425,6 +461,7 @@ export function CommandMenu({
|
||||
{query.isLoading ? "Searching..." : "No results found."}
|
||||
</CommandEmpty>
|
||||
{navItemsSection}
|
||||
{stylesSection}
|
||||
{renderDelayedGroups ? (
|
||||
<>
|
||||
{pageGroupsSection}
|
||||
@@ -448,6 +485,7 @@ export function CommandMenu({
|
||||
? "Go to Page"
|
||||
: null}
|
||||
{selectedType === "color" ? "Copy OKLCH" : null}
|
||||
{selectedType === "style" ? "Open in shadcn/create" : null}
|
||||
</div>
|
||||
{copyPayload && (
|
||||
<>
|
||||
|
||||
@@ -1,33 +1,65 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { PAGES_NEW } from "@/lib/docs"
|
||||
import { getPagesFromFolder, type PageTreeFolder } from "@/lib/page-tree"
|
||||
import {
|
||||
getPagesFromFolder,
|
||||
type PageTreeFolder,
|
||||
type PageTreePage,
|
||||
} from "@/lib/page-tree"
|
||||
|
||||
function ComponentLink({
|
||||
component,
|
||||
showNewIndicator,
|
||||
}: {
|
||||
component: PageTreePage
|
||||
showNewIndicator: boolean
|
||||
}) {
|
||||
const isNew = showNewIndicator && PAGES_NEW.includes(component.url)
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={component.url}
|
||||
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
|
||||
>
|
||||
{component.name}
|
||||
{isNew && (
|
||||
<>
|
||||
<span className="sr-only">New</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-2 rounded-full bg-blue-500"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComponentsList({
|
||||
componentsFolder,
|
||||
currentBase,
|
||||
variant = "all",
|
||||
}: {
|
||||
componentsFolder: PageTreeFolder
|
||||
currentBase: string
|
||||
variant?: "all" | "new"
|
||||
}) {
|
||||
const list = getPagesFromFolder(componentsFolder, currentBase)
|
||||
const list = getPagesFromFolder(componentsFolder, currentBase).filter(
|
||||
(component) => variant === "all" || PAGES_NEW.includes(component.url)
|
||||
)
|
||||
|
||||
if (!list.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-x-8 lg:gap-x-16 lg:gap-y-6 xl:gap-x-20">
|
||||
<div className="mt-8 grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-x-8 lg:gap-x-16 lg:gap-y-6 xl:gap-x-20">
|
||||
{list.map((component) => (
|
||||
<Link
|
||||
<ComponentLink
|
||||
key={component.$id}
|
||||
href={component.url}
|
||||
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
|
||||
>
|
||||
{component.name}
|
||||
{PAGES_NEW.includes(component.url) && (
|
||||
<span
|
||||
className="flex size-2 rounded-full bg-blue-500"
|
||||
title="New"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
component={component}
|
||||
showNewIndicator={variant === "all"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -185,9 +185,7 @@ export function DirectoryAddProvider({
|
||||
</DrawerHeader>
|
||||
<div className="px-4">{Content}</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose asChild>
|
||||
<Button size="sm">Done</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose render={<Button size="sm" />}>Done</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -6,10 +6,12 @@ import { BASES } from "@/registry/bases"
|
||||
export function DocsBaseSwitcher({
|
||||
base,
|
||||
component,
|
||||
hrefPrefix = "/docs/components",
|
||||
className,
|
||||
}: {
|
||||
base: string
|
||||
component: string
|
||||
hrefPrefix?: string
|
||||
className?: string
|
||||
}) {
|
||||
const activeBase = BASES.find((baseItem) => base === baseItem.name)
|
||||
@@ -19,7 +21,7 @@ export function DocsBaseSwitcher({
|
||||
{BASES.map((baseItem) => (
|
||||
<Link
|
||||
key={baseItem.name}
|
||||
href={`/docs/components/${baseItem.name}/${component}`}
|
||||
href={`${hrefPrefix}/${baseItem.name}/${component}`}
|
||||
data-active={base === baseItem.name}
|
||||
className="relative inline-flex items-center justify-center gap-1 pt-1 pb-0.5 text-base font-medium text-muted-foreground transition-colors after:absolute after:inset-x-0 after:bottom-[-4px] after:h-0.5 after:bg-foreground after:opacity-0 after:transition-opacity hover:text-foreground data-[active=true]:text-foreground data-[active=true]:after:opacity-100"
|
||||
>
|
||||
|
||||
@@ -73,14 +73,12 @@ export function DocsSidebar({
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
className="sticky top-[calc(var(--header-height)+0.6rem)] z-30 hidden h-[calc(100svh-10rem)] overscroll-none bg-transparent [--sidebar-menu-width:--spacing(56)] lg:flex"
|
||||
className="sticky top-[calc(var(--header-height)+0.6rem)] z-30 hidden h-[calc(100svh-10rem)] overflow-hidden overscroll-none bg-transparent [--sidebar-menu-width:--spacing(56)] lg:flex"
|
||||
collapsible="none"
|
||||
{...props}
|
||||
>
|
||||
<div className="h-9" />
|
||||
<div className="absolute top-8 z-10 h-8 w-(--sidebar-menu-width) shrink-0 bg-linear-to-b from-background via-background/80 to-background/50 blur-xs" />
|
||||
<SidebarContent className="no-scrollbar w-(--sidebar-menu-width) overflow-x-hidden px-2.5">
|
||||
<SidebarGroup className="pt-6">
|
||||
<SidebarContent className="w-(--sidebar-menu-width) scroll-fade scrollbar-none overflow-x-hidden pl-2.5">
|
||||
<SidebarGroup className="pt-12">
|
||||
<SidebarGroupLabel className="font-medium text-muted-foreground">
|
||||
Sections
|
||||
</SidebarGroupLabel>
|
||||
@@ -167,7 +165,6 @@ export function DocsSidebar({
|
||||
</SidebarGroup>
|
||||
)
|
||||
})}
|
||||
<div className="sticky -bottom-1 z-10 h-16 shrink-0 bg-linear-to-t from-background via-background/80 to-background/50 blur-xs" />
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ export function DocsTableOfContents({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2 p-4 pt-0 text-sm", className)}>
|
||||
<p className="sticky top-0 h-6 bg-background text-xs font-medium text-muted-foreground">
|
||||
<p className="h-6 bg-background text-xs font-medium text-muted-foreground">
|
||||
On This Page
|
||||
</p>
|
||||
{toc.map((item) => (
|
||||
|
||||
28
apps/v4/components/markdown.tsx
Normal file
28
apps/v4/components/markdown.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { code } from "@streamdown/code"
|
||||
import { Streamdown } from "streamdown"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DEFAULT_PLUGINS = { code }
|
||||
|
||||
function Markdown({
|
||||
className,
|
||||
plugins = DEFAULT_PLUGINS,
|
||||
controls = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Streamdown>) {
|
||||
return (
|
||||
<Streamdown
|
||||
data-slot="markdown"
|
||||
plugins={plugins}
|
||||
controls={controls}
|
||||
className={cn("cn-markdown w-full min-w-0 overflow-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Markdown }
|
||||
148
apps/v4/components/message-animated.tsx
Normal file
148
apps/v4/components/message-animated.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { motion, useReducedMotion } from "motion/react"
|
||||
|
||||
import type { MessageAnimationPreset } from "@/lib/message-animations"
|
||||
import { MESSAGE_ANIMATIONS } from "@/lib/message-animations"
|
||||
import { Bubble, BubbleContent } from "@/styles/radix-rhea/ui/bubble"
|
||||
import { Message, MessageContent } from "@/styles/radix-rhea/ui/message"
|
||||
import { MessageScrollerItem } from "@/styles/radix-rhea/ui/message-scroller"
|
||||
|
||||
type MessageAnimatedPart = {
|
||||
type: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
type MessageAnimatedMessage = {
|
||||
id: string
|
||||
role: string
|
||||
text?: string
|
||||
parts?: ReadonlyArray<MessageAnimatedPart>
|
||||
}
|
||||
|
||||
type MessageAnimatedTextPart = {
|
||||
key: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
|
||||
|
||||
function MessageAnimated({
|
||||
message,
|
||||
animationPreset = MESSAGE_ANIMATIONS["slide-up"],
|
||||
assistantVariant = "ghost",
|
||||
scrollAnchor,
|
||||
userVariant = "muted",
|
||||
...props
|
||||
}: Omit<
|
||||
React.ComponentProps<typeof MotionMessageScrollerItem>,
|
||||
"animate" | "children" | "exit" | "initial" | "messageId" | "variants"
|
||||
> & {
|
||||
animationPreset?: MessageAnimationPreset
|
||||
assistantVariant?: React.ComponentProps<typeof Bubble>["variant"]
|
||||
message: MessageAnimatedMessage
|
||||
userVariant?: React.ComponentProps<typeof Bubble>["variant"]
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion()
|
||||
const isUserMessage = message.role === "user"
|
||||
|
||||
if (isUserMessage) {
|
||||
return (
|
||||
<MotionMessageScrollerItem
|
||||
messageId={message.id}
|
||||
scrollAnchor={scrollAnchor ?? true}
|
||||
variants={animationPreset.variants}
|
||||
initial={shouldReduceMotion ? false : "initial"}
|
||||
animate="animate"
|
||||
exit={shouldReduceMotion ? undefined : "exit"}
|
||||
{...props}
|
||||
>
|
||||
<MessageAnimatedRow
|
||||
message={message}
|
||||
assistantVariant={assistantVariant}
|
||||
userVariant={userVariant}
|
||||
/>
|
||||
</MotionMessageScrollerItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionMessageScrollerItem
|
||||
messageId={message.id}
|
||||
scrollAnchor={scrollAnchor}
|
||||
initial={false}
|
||||
{...props}
|
||||
>
|
||||
<MessageAnimatedRow
|
||||
message={message}
|
||||
assistantVariant={assistantVariant}
|
||||
userVariant={userVariant}
|
||||
/>
|
||||
</MotionMessageScrollerItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MessageAnimatedRow({
|
||||
message,
|
||||
assistantVariant,
|
||||
userVariant,
|
||||
}: {
|
||||
assistantVariant: React.ComponentProps<typeof Bubble>["variant"]
|
||||
message: MessageAnimatedMessage
|
||||
userVariant: React.ComponentProps<typeof Bubble>["variant"]
|
||||
}) {
|
||||
const isUserMessage = message.role === "user"
|
||||
const textParts = getMessageAnimatedTextParts(message)
|
||||
|
||||
return (
|
||||
<Message align={isUserMessage ? "end" : "start"}>
|
||||
<MessageContent>
|
||||
{textParts.map((part) => {
|
||||
const paragraphs = part.text
|
||||
.split(/\n\s*\n/)
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
return (
|
||||
<Bubble
|
||||
key={part.key}
|
||||
variant={isUserMessage ? userVariant : assistantVariant}
|
||||
>
|
||||
<BubbleContent className="space-y-2">
|
||||
{paragraphs.map((paragraph, paragraphIndex) => (
|
||||
<p
|
||||
key={`${part.key}-${paragraphIndex}`}
|
||||
className="whitespace-pre-wrap"
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
)
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
|
||||
function getMessageAnimatedTextParts(
|
||||
message: MessageAnimatedMessage
|
||||
): MessageAnimatedTextPart[] {
|
||||
if (message.parts) {
|
||||
return message.parts.flatMap((part, index) => {
|
||||
if (part.type !== "text" || typeof part.text !== "string") {
|
||||
return []
|
||||
}
|
||||
|
||||
return [{ key: `${message.id}-${index}`, text: part.text }]
|
||||
})
|
||||
}
|
||||
|
||||
return typeof message.text === "string"
|
||||
? [{ key: `${message.id}-text`, text: message.text }]
|
||||
: []
|
||||
}
|
||||
|
||||
export { MessageAnimated, type MessageAnimatedMessage }
|
||||
645
apps/v4/components/message-parts.tsx
Normal file
645
apps/v4/components/message-parts.tsx
Normal file
@@ -0,0 +1,645 @@
|
||||
import * as React from "react"
|
||||
|
||||
type MessagePartKind =
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "tool"
|
||||
| "file"
|
||||
| "source"
|
||||
| "data"
|
||||
| "custom"
|
||||
| "step-start"
|
||||
| "reasoning-file"
|
||||
| (string & {})
|
||||
|
||||
type MessagePartOf<TMessage> = TMessage extends {
|
||||
parts: ReadonlyArray<infer TPart>
|
||||
}
|
||||
? TPart
|
||||
: unknown
|
||||
|
||||
type MessagePartItem<TPart = unknown, TMessage = unknown> = {
|
||||
key: string
|
||||
part: TPart
|
||||
message: TMessage
|
||||
index: number
|
||||
kind: MessagePartKind
|
||||
name?: string
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
type MessageToolPartGroup<TPart = unknown, TMessage = unknown> = {
|
||||
key: string
|
||||
name?: string
|
||||
toolCallId?: string
|
||||
items: Array<MessagePartItem<TPart, TMessage>>
|
||||
call?: MessagePartItem<TPart, TMessage>
|
||||
result?: MessagePartItem<TPart, TMessage>
|
||||
}
|
||||
|
||||
type MessagePartsResult<TPart = unknown, TMessage = unknown> = {
|
||||
role?: string
|
||||
all: Array<MessagePartItem<TPart, TMessage>>
|
||||
protocol: Array<MessagePartItem<TPart, TMessage>>
|
||||
byKind: Record<string, Array<MessagePartItem<TPart, TMessage>>>
|
||||
tools: Array<MessageToolPartGroup<TPart, TMessage>>
|
||||
toolParts: Array<MessagePartItem<TPart, TMessage>>
|
||||
text: Array<MessagePartItem<TPart, TMessage>>
|
||||
texts: Array<MessagePartItem<TPart, TMessage>>
|
||||
reasoning: Array<MessagePartItem<TPart, TMessage>>
|
||||
files: Array<MessagePartItem<TPart, TMessage>>
|
||||
sources: Array<MessagePartItem<TPart, TMessage>>
|
||||
data: Array<MessagePartItem<TPart, TMessage>>
|
||||
custom: Array<MessagePartItem<TPart, TMessage>>
|
||||
steps: Array<MessagePartItem<TPart, TMessage>>
|
||||
reasoningFiles: Array<MessagePartItem<TPart, TMessage>>
|
||||
get: (kind: MessagePartKind) => Array<MessagePartItem<TPart, TMessage>>
|
||||
}
|
||||
|
||||
type MessagePartsAdapter<TMessage, TPart> = {
|
||||
getParts?: (message: TMessage) => ReadonlyArray<TPart>
|
||||
getRole?: (message: TMessage) => string | undefined
|
||||
getPartKey?: (part: TPart, index: number, message: TMessage) => string
|
||||
getPartKind?: (
|
||||
part: TPart,
|
||||
index: number,
|
||||
message: TMessage
|
||||
) => MessagePartKind
|
||||
getPartName?: (
|
||||
part: TPart,
|
||||
index: number,
|
||||
message: TMessage
|
||||
) => string | undefined
|
||||
getToolCallId?: (
|
||||
part: TPart,
|
||||
index: number,
|
||||
message: TMessage
|
||||
) => string | undefined
|
||||
isToolCall?: (part: TPart, index: number, message: TMessage) => boolean
|
||||
isToolResult?: (part: TPart, index: number, message: TMessage) => boolean
|
||||
}
|
||||
|
||||
type CreateMessagePartsOptions<TMessage, TPart> = {
|
||||
adapter?: MessagePartsAdapter<TMessage, TPart>
|
||||
order?:
|
||||
| "role"
|
||||
| "protocol"
|
||||
| MessagePartKind[]
|
||||
| ((
|
||||
items: Array<MessagePartItem<TPart, TMessage>>,
|
||||
message: TMessage
|
||||
) => Array<MessagePartItem<TPart, TMessage>>)
|
||||
classifyPart?: (
|
||||
part: TPart,
|
||||
context: {
|
||||
message: TMessage
|
||||
index: number
|
||||
kind: MessagePartKind
|
||||
name?: string
|
||||
toolCallId?: string
|
||||
}
|
||||
) =>
|
||||
| {
|
||||
kind?: MessagePartKind
|
||||
name?: string
|
||||
toolCallId?: string
|
||||
}
|
||||
| undefined
|
||||
getKey?: (part: TPart, index: number, message: TMessage) => string
|
||||
}
|
||||
|
||||
type MessagePartRenderProps<TPart = unknown, TMessage = unknown> = {
|
||||
item: MessagePartItem<TPart, TMessage>
|
||||
part: TPart
|
||||
message: TMessage
|
||||
index: number
|
||||
kind: MessagePartKind
|
||||
name?: string
|
||||
toolCallId?: string
|
||||
}
|
||||
|
||||
type MessagePartComponent<
|
||||
TPart = unknown,
|
||||
TMessage = unknown,
|
||||
> = React.ComponentType<MessagePartRenderProps<TPart, TMessage>>
|
||||
|
||||
type MessagePartComponents<TPart = unknown, TMessage = unknown> = {
|
||||
text?: MessagePartComponent<TPart, TMessage>
|
||||
reasoning?: MessagePartComponent<TPart, TMessage>
|
||||
tool?: MessagePartComponent<TPart, TMessage>
|
||||
file?: MessagePartComponent<TPart, TMessage>
|
||||
source?: MessagePartComponent<TPart, TMessage>
|
||||
data?: MessagePartComponent<TPart, TMessage>
|
||||
custom?: MessagePartComponent<TPart, TMessage>
|
||||
stepStart?: MessagePartComponent<TPart, TMessage>
|
||||
reasoningFile?: MessagePartComponent<TPart, TMessage>
|
||||
fallback?: MessagePartComponent<TPart, TMessage>
|
||||
types?: Record<string, MessagePartComponent<TPart, TMessage>>
|
||||
tools?: Record<string, MessagePartComponent<TPart, TMessage>>
|
||||
dataTypes?: Record<string, MessagePartComponent<TPart, TMessage>>
|
||||
}
|
||||
|
||||
type MessagePartProps<TPart = unknown, TMessage = unknown> = {
|
||||
item: MessagePartItem<TPart, TMessage>
|
||||
components: MessagePartComponents<TPart, TMessage>
|
||||
}
|
||||
|
||||
type MessagePartsProps<TPart = unknown, TMessage = unknown> = {
|
||||
parts:
|
||||
| MessagePartsResult<TPart, TMessage>
|
||||
| Array<MessagePartItem<TPart, TMessage>>
|
||||
components: MessagePartComponents<TPart, TMessage>
|
||||
renderPart?: (props: {
|
||||
item: MessagePartItem<TPart, TMessage>
|
||||
children: React.ReactNode
|
||||
}) => React.ReactNode
|
||||
}
|
||||
|
||||
const ASSISTANT_PART_ORDER: MessagePartKind[] = [
|
||||
"reasoning",
|
||||
"tool",
|
||||
"reasoning-file",
|
||||
"file",
|
||||
"text",
|
||||
"source",
|
||||
"data",
|
||||
"custom",
|
||||
"step-start",
|
||||
]
|
||||
|
||||
const USER_PART_ORDER: MessagePartKind[] = [
|
||||
"file",
|
||||
"text",
|
||||
"data",
|
||||
"source",
|
||||
"custom",
|
||||
]
|
||||
|
||||
const DEFAULT_PART_ORDER: MessagePartKind[] = ["text", "data", "custom"]
|
||||
|
||||
function createMessageParts<TMessage>(
|
||||
message: TMessage,
|
||||
options: CreateMessagePartsOptions<TMessage, MessagePartOf<TMessage>> = {}
|
||||
): MessagePartsResult<MessagePartOf<TMessage>, TMessage> {
|
||||
type TPart = MessagePartOf<TMessage>
|
||||
|
||||
const adapter = options.adapter ?? {}
|
||||
const role = adapter.getRole?.(message) ?? getStructuralRole(message)
|
||||
const messageParts =
|
||||
adapter.getParts?.(message) ??
|
||||
(getStructuralParts(message) as ReadonlyArray<TPart>)
|
||||
|
||||
const protocol = messageParts.map((part, index) => {
|
||||
const kind =
|
||||
adapter.getPartKind?.(part, index, message) ?? getStructuralPartKind(part)
|
||||
const name =
|
||||
adapter.getPartName?.(part, index, message) ?? getStructuralPartName(part)
|
||||
const toolCallId =
|
||||
adapter.getToolCallId?.(part, index, message) ??
|
||||
getStructuralToolCallId(part)
|
||||
const classification = options.classifyPart?.(part, {
|
||||
message,
|
||||
index,
|
||||
kind,
|
||||
name,
|
||||
toolCallId,
|
||||
})
|
||||
|
||||
return {
|
||||
key:
|
||||
options.getKey?.(part, index, message) ??
|
||||
adapter.getPartKey?.(part, index, message) ??
|
||||
getStructuralPartKey(part, index),
|
||||
part,
|
||||
message,
|
||||
index,
|
||||
kind: classification?.kind ?? kind,
|
||||
name: classification?.name ?? name,
|
||||
toolCallId: classification?.toolCallId ?? toolCallId,
|
||||
} satisfies MessagePartItem<TPart, TMessage>
|
||||
})
|
||||
|
||||
const all = orderMessagePartItems(
|
||||
protocol,
|
||||
options.order ?? "role",
|
||||
role,
|
||||
message
|
||||
)
|
||||
const byKind = groupMessagePartItems(all)
|
||||
|
||||
function get(kind: MessagePartKind) {
|
||||
return byKind[kind] ?? []
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
all,
|
||||
protocol,
|
||||
byKind,
|
||||
tools: groupToolPartItems(all, adapter, message),
|
||||
toolParts: get("tool"),
|
||||
text: get("text"),
|
||||
texts: get("text"),
|
||||
reasoning: get("reasoning"),
|
||||
files: get("file"),
|
||||
sources: get("source"),
|
||||
data: get("data"),
|
||||
custom: get("custom"),
|
||||
steps: get("step-start"),
|
||||
reasoningFiles: get("reasoning-file"),
|
||||
get,
|
||||
}
|
||||
}
|
||||
|
||||
function MessageParts<TPart, TMessage>({
|
||||
parts,
|
||||
components,
|
||||
renderPart,
|
||||
}: MessagePartsProps<TPart, TMessage>) {
|
||||
const items = Array.isArray(parts) ? parts : parts.all
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => {
|
||||
const children = (
|
||||
<MessagePart key={item.key} item={item} components={components} />
|
||||
)
|
||||
|
||||
return renderPart ? (
|
||||
<React.Fragment key={item.key}>
|
||||
{renderPart({ item, children })}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MessagePart<TPart, TMessage>({
|
||||
item,
|
||||
components,
|
||||
}: MessagePartProps<TPart, TMessage>) {
|
||||
const Component = getMessagePartComponent(item, components)
|
||||
|
||||
if (!Component) {
|
||||
return null
|
||||
}
|
||||
|
||||
return React.createElement(Component, {
|
||||
item,
|
||||
part: item.part,
|
||||
message: item.message,
|
||||
index: item.index,
|
||||
kind: item.kind,
|
||||
name: item.name,
|
||||
toolCallId: item.toolCallId,
|
||||
})
|
||||
}
|
||||
|
||||
function getMessagePartComponent<TPart, TMessage>(
|
||||
item: MessagePartItem<TPart, TMessage>,
|
||||
components: MessagePartComponents<TPart, TMessage>
|
||||
) {
|
||||
const partType = getStructuralPartType(item.part)
|
||||
|
||||
if (partType && components.types?.[partType]) {
|
||||
return components.types[partType]
|
||||
}
|
||||
|
||||
if (item.kind === "tool" && item.name && components.tools?.[item.name]) {
|
||||
return components.tools[item.name]
|
||||
}
|
||||
|
||||
if (item.kind === "data" && item.name && components.dataTypes?.[item.name]) {
|
||||
return components.dataTypes[item.name]
|
||||
}
|
||||
|
||||
if (item.kind === "text") {
|
||||
return components.text ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "reasoning") {
|
||||
return components.reasoning ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "tool") {
|
||||
return components.tool ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "file") {
|
||||
return components.file ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "source") {
|
||||
return components.source ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "data") {
|
||||
return components.data ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "custom") {
|
||||
return components.custom ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "step-start") {
|
||||
return components.stepStart ?? components.fallback
|
||||
}
|
||||
|
||||
if (item.kind === "reasoning-file") {
|
||||
return components.reasoningFile ?? components.file ?? components.fallback
|
||||
}
|
||||
|
||||
return components.fallback
|
||||
}
|
||||
|
||||
function orderMessagePartItems<TPart, TMessage>(
|
||||
items: Array<MessagePartItem<TPart, TMessage>>,
|
||||
order: NonNullable<CreateMessagePartsOptions<TMessage, TPart>["order"]>,
|
||||
role: string | undefined,
|
||||
message: TMessage
|
||||
) {
|
||||
if (order === "protocol") {
|
||||
return items
|
||||
}
|
||||
|
||||
if (typeof order === "function") {
|
||||
return order(items, message)
|
||||
}
|
||||
|
||||
const orderedKinds = Array.isArray(order)
|
||||
? order
|
||||
: role === "user"
|
||||
? USER_PART_ORDER
|
||||
: role === "assistant"
|
||||
? ASSISTANT_PART_ORDER
|
||||
: DEFAULT_PART_ORDER
|
||||
const orderWeight = new Map(orderedKinds.map((kind, index) => [kind, index]))
|
||||
|
||||
return [...items].sort((a, b) => {
|
||||
const aWeight = orderWeight.get(a.kind) ?? Number.MAX_SAFE_INTEGER
|
||||
const bWeight = orderWeight.get(b.kind) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return aWeight === bWeight ? a.index - b.index : aWeight - bWeight
|
||||
})
|
||||
}
|
||||
|
||||
function groupMessagePartItems<TPart, TMessage>(
|
||||
items: Array<MessagePartItem<TPart, TMessage>>
|
||||
) {
|
||||
return items.reduce<Record<string, Array<MessagePartItem<TPart, TMessage>>>>(
|
||||
(groups, item) => {
|
||||
groups[item.kind] ??= []
|
||||
groups[item.kind].push(item)
|
||||
|
||||
return groups
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
function groupToolPartItems<TPart, TMessage>(
|
||||
items: Array<MessagePartItem<TPart, TMessage>>,
|
||||
adapter: MessagePartsAdapter<TMessage, TPart>,
|
||||
message: TMessage
|
||||
) {
|
||||
const groups: MessageToolPartGroup<TPart, TMessage>[] = []
|
||||
const groupsByKey = new Map<string, MessageToolPartGroup<TPart, TMessage>>()
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind !== "tool") {
|
||||
continue
|
||||
}
|
||||
|
||||
const groupKey = item.toolCallId ?? item.name ?? item.key
|
||||
let group = groupsByKey.get(groupKey)
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
key: groupKey,
|
||||
name: item.name,
|
||||
toolCallId: item.toolCallId,
|
||||
items: [],
|
||||
}
|
||||
groupsByKey.set(groupKey, group)
|
||||
groups.push(group)
|
||||
}
|
||||
|
||||
group.items.push(item)
|
||||
|
||||
if (adapter.isToolResult?.(item.part, item.index, message)) {
|
||||
group.result = item
|
||||
} else if (adapter.isToolCall?.(item.part, item.index, message)) {
|
||||
group.call = item
|
||||
} else if (isStructuralToolResult(item.part)) {
|
||||
group.result = item
|
||||
} else if (!group.call) {
|
||||
group.call = item
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function getStructuralParts(message: unknown) {
|
||||
if (isRecord(message) && Array.isArray(message.parts)) {
|
||||
return message.parts
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getStructuralRole(message: unknown) {
|
||||
if (isRecord(message) && typeof message.role === "string") {
|
||||
return message.role
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getStructuralPartKey(part: unknown, index: number) {
|
||||
const type = getStructuralPartType(part) ?? "part"
|
||||
const toolCallId = getStructuralToolCallId(part)
|
||||
|
||||
if (toolCallId) {
|
||||
return `${type}:${toolCallId}`
|
||||
}
|
||||
|
||||
if (isRecord(part)) {
|
||||
if (typeof part.sourceId === "string") {
|
||||
return `${type}:${part.sourceId}`
|
||||
}
|
||||
|
||||
if (typeof part.id === "string") {
|
||||
return `${type}:${part.id}`
|
||||
}
|
||||
|
||||
if (typeof part.url === "string") {
|
||||
return `${type}:${part.url}:${index}`
|
||||
}
|
||||
}
|
||||
|
||||
return `${type}:${index}`
|
||||
}
|
||||
|
||||
function getStructuralPartKind(part: unknown): MessagePartKind {
|
||||
const type = getStructuralPartType(part)
|
||||
|
||||
if (!type) {
|
||||
return "custom"
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
return "text"
|
||||
}
|
||||
|
||||
if (type === "reasoning" || type === "thinking") {
|
||||
return "reasoning"
|
||||
}
|
||||
|
||||
if (
|
||||
type === "file" ||
|
||||
type === "image" ||
|
||||
type === "audio" ||
|
||||
type === "video"
|
||||
) {
|
||||
return "file"
|
||||
}
|
||||
|
||||
if (type === "reasoning-file") {
|
||||
return "reasoning-file"
|
||||
}
|
||||
|
||||
if (
|
||||
type === "source" ||
|
||||
type === "source-url" ||
|
||||
type === "source-document" ||
|
||||
type === "citation" ||
|
||||
type === "search-result"
|
||||
) {
|
||||
return "source"
|
||||
}
|
||||
|
||||
if (
|
||||
type === "dynamic-tool" ||
|
||||
type === "tool-call" ||
|
||||
type === "tool-result" ||
|
||||
type === "tool_call" ||
|
||||
type === "tool_result" ||
|
||||
type.startsWith("tool-")
|
||||
) {
|
||||
return "tool"
|
||||
}
|
||||
|
||||
if (type.startsWith("data-")) {
|
||||
return "data"
|
||||
}
|
||||
|
||||
if (type === "step-start" || type === "step_start") {
|
||||
return "step-start"
|
||||
}
|
||||
|
||||
if (type === "custom") {
|
||||
return "custom"
|
||||
}
|
||||
|
||||
return "custom"
|
||||
}
|
||||
|
||||
function getStructuralPartName(part: unknown) {
|
||||
const type = getStructuralPartType(part)
|
||||
|
||||
if (!type) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isRecord(part)) {
|
||||
if (typeof part.toolName === "string") {
|
||||
return part.toolName
|
||||
}
|
||||
|
||||
if (typeof part.name === "string") {
|
||||
return part.name
|
||||
}
|
||||
|
||||
if (typeof part.kind === "string") {
|
||||
return part.kind
|
||||
}
|
||||
}
|
||||
|
||||
if (type.startsWith("tool-")) {
|
||||
return type.slice("tool-".length)
|
||||
}
|
||||
|
||||
if (type.startsWith("data-")) {
|
||||
return type.slice("data-".length)
|
||||
}
|
||||
|
||||
if (type === "source-url") {
|
||||
return "url"
|
||||
}
|
||||
|
||||
if (type === "source-document") {
|
||||
return "document"
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getStructuralToolCallId(part: unknown) {
|
||||
if (!isRecord(part)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof part.toolCallId === "string") {
|
||||
return part.toolCallId
|
||||
}
|
||||
|
||||
if (typeof part.callId === "string") {
|
||||
return part.callId
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isStructuralToolResult(part: unknown) {
|
||||
const type = getStructuralPartType(part)
|
||||
|
||||
return (
|
||||
type === "tool-result" ||
|
||||
type === "tool_result" ||
|
||||
(isRecord(part) && "output" in part)
|
||||
)
|
||||
}
|
||||
|
||||
function getStructuralPartType(part: unknown) {
|
||||
if (isRecord(part) && typeof part.type === "string") {
|
||||
return part.type
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export {
|
||||
MessagePart,
|
||||
MessageParts,
|
||||
createMessageParts,
|
||||
getMessagePartComponent,
|
||||
type CreateMessagePartsOptions,
|
||||
type MessagePartComponent,
|
||||
type MessagePartComponents,
|
||||
type MessagePartItem,
|
||||
type MessagePartKind,
|
||||
type MessagePartProps,
|
||||
type MessagePartRenderProps,
|
||||
type MessagePartsAdapter,
|
||||
type MessagePartsProps,
|
||||
type MessagePartsResult,
|
||||
type MessageToolPartGroup,
|
||||
}
|
||||
@@ -20,3 +20,4 @@ description: Every component recreated in Figma. With customizable props, typogr
|
||||
- [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.
|
||||
- [Shadcnblocks.com](https://www.shadcnblocks.com) - A Premium Shadcn Figma UI Kit with components, 500+ pro blocks, shadcn theme variables, light/dark mode and Figma MCP ready.
|
||||
- [Obra shadcn/ui Pro](https://shadcn.obra.studio/products/obra-shadcn-ui-pro) by [Obra Studio](https://obra.studio/) - Focused on designers who need to get work done — the best designer experience for shadcn/ui within Figma. variable consistency with shadcn, plus custom components, Pro blocks, and a design-to-code plugin.
|
||||
- [Shadcn Space](https://shadcnspace.com/figma) - A collection of beautifully designed 320+ blocks, 9+ templates, and 250+ components with a shadcn/ui-compatible Figma kit built for modern React and Next.js workflows. Design, prototype, and ship faster using Figma components that mirror real code architecture and production-ready implementation patterns.
|
||||
|
||||
117
apps/v4/content/docs/changelog/2026-06-chat-components.mdx
Normal file
117
apps/v4/content/docs/changelog/2026-06-chat-components.mdx
Normal file
@@ -0,0 +1,117 @@
|
||||
---
|
||||
title: June 2026 - Components for Chat Interfaces
|
||||
description: MessageScroller, Message, Bubble, Attachment, and Marker. Components for building chat interfaces.
|
||||
date: 2026-06-26
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-demo"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
Today, we’re releasing a new set of components for building chat interfaces:
|
||||
[**MessageScroller**](/docs/components/message-scroller),
|
||||
[**Message**](/docs/components/message), [**Bubble**](/docs/components/bubble),
|
||||
[**Attachment**](/docs/components/attachment), and
|
||||
[**Marker**](/docs/components/marker).
|
||||
|
||||
This is the first phase of the chat components work. We’re taking it one piece at a time, reimagining the abstraction behind each part, and shipping them as shadcn/ui components you can copy, compose, and adapt to your product.
|
||||
|
||||
We are starting with the conversation layer: scrolling, message rows, bubbles, attachments, and markers.
|
||||
|
||||
We asked ourselves: what makes a great streaming chat experience? Then we abstracted the core rules into a set of primitives: `MessageScroller`.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add message-scroller message bubble attachment marker
|
||||
```
|
||||
|
||||
## MessageScroller
|
||||
|
||||
`MessageScroller` is the scroll container for a conversation. It handles the
|
||||
parts that are easy to get wrong: anchored turns, streamed replies, saved thread
|
||||
restore, prepended history, jump-to-message, scroll controls, and visibility
|
||||
tracking.
|
||||
|
||||
`MessageScroller` owns that behavior without owning your messages, AI state,
|
||||
transport, persistence, or model state. You bring the content renderer.
|
||||
|
||||
The `MessageScroller` is also available as an unstyled headless component in `@shadcn/react`.
|
||||
|
||||
## Message, Bubble, Attachment, and Marker
|
||||
|
||||
The rest of the components cover the everyday pieces you need around the
|
||||
scroller.
|
||||
|
||||
- `Message` lays out a row in the conversation with avatar, alignment, header,
|
||||
content, footer, and grouped messages.
|
||||
- `Bubble` renders the message surface, with variants, alignment, reactions,
|
||||
links, buttons, and collapsible content.
|
||||
- `Attachment` renders files and images with media, metadata, upload state,
|
||||
actions, and a full-card trigger that keeps actions separately clickable.
|
||||
- `Marker` renders status updates, system notes, bordered rows, and labeled
|
||||
separators for things like streaming state, tool activity, and date breaks.
|
||||
|
||||
They are intentionally small. Compose them together for AI chats, support
|
||||
inboxes, team threads, group chats, and product-specific conversations.
|
||||
|
||||
## scroll-fade and shimmer
|
||||
|
||||
We also added two new CSS utilities for the details that make chat interfaces
|
||||
feel better.
|
||||
|
||||
[`scroll-fade`](/docs/utils/scroll-fade) adds scroll-aware edge fades to scroll
|
||||
containers. Use it on `MessageScroller`, `ScrollArea`, attachment rows, and any
|
||||
long list where you want to hint at more content without adding overlays or
|
||||
scroll listeners.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="scroll-fade-demo"
|
||||
previewClassName="h-auto"
|
||||
/>
|
||||
|
||||
[`shimmer`](/docs/utils/shimmer) adds a text shimmer for live status. Use it
|
||||
for things like "Thinking…", "Generating response…", running tools, and
|
||||
streaming markers.
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-demo" />
|
||||
|
||||
Both utilities ship with `shadcn/tailwind.css`, so projects initialized with
|
||||
`npx shadcn@latest init` already have them.
|
||||
|
||||
## @shadcn/react
|
||||
|
||||
We also created `@shadcn/react`, a new package for unstyled, headless React
|
||||
components.
|
||||
|
||||
The first primitive is `@shadcn/react/message-scroller`. The registry component
|
||||
wraps it with shadcn/ui styles, but the scroll behavior lives in the package:
|
||||
anchoring, auto-follow, prepend preservation, scroll commands, and visibility.
|
||||
|
||||
This lets us ship behavior without locking it to a visual style. You still get
|
||||
copy-and-paste components that match your project, and the hard interaction
|
||||
logic stays tested in one place.
|
||||
|
||||
Available now for Radix and Base UI.
|
||||
|
||||
## AI Elements
|
||||
|
||||
This does not replace [AI Elements](https://ai-sdk.dev/elements/overview). You
|
||||
can keep using AI Elements for AI interface components and patterns. This
|
||||
release is about bringing the core pieces of chat into shadcn/ui, one component
|
||||
at a time.
|
||||
|
||||
If you are already using a component from AI Elements, you do not need to
|
||||
rewrite your app. Keep what works. Try the shadcn/ui version when you want the
|
||||
newer abstraction, the updated styling, or support across Radix and Base UI.
|
||||
|
||||
The goal is to make these pieces easy to adopt independently. Replace one part,
|
||||
compose it with what you already have, and keep building.
|
||||
|
||||
<Button asChild size="sm">
|
||||
<Link href="/docs/components" className="mt-6 no-underline!">
|
||||
View Components
|
||||
</Link>
|
||||
</Button>
|
||||
304
apps/v4/content/docs/components/base/attachment.mdx
Normal file
304
apps/v4/content/docs/components/base/attachment.mdx
Normal file
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: Attachment
|
||||
description: Displays a file or image attachment with media, metadata, upload state, and actions.
|
||||
base: base
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="attachment-demo"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
The `Attachment` component displays a file or image attachment, its media, name, and metadata, with optional actions and upload state. Use it for files and images in chat composers, message threads, and upload lists.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add attachment
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Install the required shadcn/ui dependencies:</Step>
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="attachment"
|
||||
title="components/ui/attachment.tsx"
|
||||
styleName="base-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/components/ui/attachment"
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Attachment>
|
||||
<AttachmentMedia>
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build an attachment:
|
||||
|
||||
```text
|
||||
Attachment
|
||||
├── AttachmentMedia
|
||||
├── AttachmentContent
|
||||
│ ├── AttachmentTitle
|
||||
│ └── AttachmentDescription
|
||||
├── AttachmentActions
|
||||
│ └── AttachmentAction
|
||||
└── AttachmentTrigger
|
||||
```
|
||||
|
||||
Use `AttachmentGroup` to lay out multiple attachments in a scrollable row:
|
||||
|
||||
```text
|
||||
AttachmentGroup
|
||||
├── Attachment
|
||||
└── Attachment
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Icon and image media through `AttachmentMedia`
|
||||
- Upload states: `idle`, `uploading`, `processing`, `error`, and `done` with built-in styling and a shimmer while in progress
|
||||
- Three sizes and horizontal or vertical orientation
|
||||
- A full-card `AttachmentTrigger` that opens a link or dialog while the actions stay independently clickable
|
||||
- Scrollable, snapping `AttachmentGroup` with an edge fade
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Image
|
||||
|
||||
Set `variant="image"` on `AttachmentMedia` and render an `<img>` inside it. Use `orientation="vertical"` to stack the media above the content.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="attachment-image"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### States
|
||||
|
||||
Set `state` to reflect the upload lifecycle. `uploading` and `processing` shimmer the title, and `error` switches to a destructive treatment.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="attachment-states"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### Sizes
|
||||
|
||||
Use `size` to switch between `default`, `sm`, and `xs`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="attachment-sizes"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### Group
|
||||
|
||||
Wrap attachments in `AttachmentGroup` to lay them out in a horizontally scrollable, snapping row with an edge fade.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="attachment-group"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### Trigger
|
||||
|
||||
Add an `AttachmentTrigger` to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay clickable.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="attachment-trigger"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Dialog>
|
||||
<Attachment>
|
||||
{/* media, content, actions */}
|
||||
<DialogTrigger
|
||||
render={<AttachmentTrigger aria-label="Preview research-summary.pdf" />}
|
||||
/>
|
||||
</Attachment>
|
||||
<DialogContent>{/* ... */}</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
`AttachmentAction` renders a `Button`, and `AttachmentTrigger` renders a real `<button>` (or your element via `render`). Follow the guidance below so both are operable and announced.
|
||||
|
||||
### Label icon-only actions
|
||||
|
||||
`AttachmentAction` is usually icon-only, so give each one an `aria-label` describing the action and its target.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
```
|
||||
|
||||
### Label the trigger
|
||||
|
||||
`AttachmentTrigger` covers the card with no text of its own, so give it an `aria-label` for what activating it does.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<AttachmentTrigger
|
||||
render={
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open workspace.png"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
The trigger sits behind the actions in the stacking order, so an `AttachmentAction` and the `AttachmentTrigger` never trap each other — both remain separately focusable and clickable.
|
||||
|
||||
### Keyboard scrolling
|
||||
|
||||
An `AttachmentGroup` scrolls horizontally. When its attachments are interactive: a trigger or actions, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding `tabIndex={0}`, `role="group"`, and an `aria-label`.
|
||||
|
||||
### Meaning beyond color
|
||||
|
||||
The `error` state uses a destructive color. Keep the failure reason in `AttachmentDescription` so the state is not conveyed by color alone.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Attachment
|
||||
|
||||
The root attachment container.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------- |
|
||||
| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | The upload state. Drives styling and the shimmer. |
|
||||
| `size` | `"default" \| "sm" \| "xs"` | `"default"` | The attachment size. |
|
||||
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Lay the media beside or above the content. |
|
||||
| `className` | `string` | - | Additional classes to apply to the root element. |
|
||||
|
||||
### AttachmentMedia
|
||||
|
||||
The media slot for an icon or image preview.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------- | -------- | ---------------------------------------------- |
|
||||
| `variant` | `"icon" \| "image"` | `"icon"` | Whether the media holds an icon or an `<img>`. |
|
||||
| `className` | `string` | - | Additional classes to apply to the media slot. |
|
||||
|
||||
### AttachmentContent
|
||||
|
||||
Wraps the title and description.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the content slot. |
|
||||
|
||||
### AttachmentTitle
|
||||
|
||||
The attachment name. Shimmers while the attachment is `uploading` or `processing`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the title. |
|
||||
|
||||
### AttachmentDescription
|
||||
|
||||
Secondary metadata such as the file type, size, or upload status.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the description. |
|
||||
|
||||
### AttachmentActions
|
||||
|
||||
A container for one or more actions, aligned to the end of the attachment.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the actions. |
|
||||
|
||||
### AttachmentAction
|
||||
|
||||
An action button. Renders a [`Button`](/docs/components/button) and accepts all of its props.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | ------------------------------------- | ----------- | ---------------------------------------- |
|
||||
| `size` | `Button["size"]` | `"icon-xs"` | The button size. |
|
||||
| `...props` | `React.ComponentProps<typeof Button>` | - | Props spread to the underlying `Button`. |
|
||||
|
||||
### AttachmentTrigger
|
||||
|
||||
A full-card overlay that activates the attachment. Renders a `<button>` by default.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | -------------------------------- | ------- | ---------------------------------------------- |
|
||||
| `render` | `ReactElement \| function` | - | Render as a different element, such as a link. |
|
||||
| `...props` | `React.ComponentProps<"button">` | - | Props spread to the trigger element. |
|
||||
|
||||
### AttachmentGroup
|
||||
|
||||
Lays out attachments in a horizontally scrollable, snapping row.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the group. |
|
||||
300
apps/v4/content/docs/components/base/bubble.mdx
Normal file
300
apps/v4/content/docs/components/base/bubble.mdx
Normal file
@@ -0,0 +1,300 @@
|
||||
---
|
||||
title: Bubble
|
||||
description: Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
|
||||
base: base
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
The `Bubble` component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions.
|
||||
|
||||
For full-featured chat interfaces, use the [`Message`](/docs/components/message) component. `Bubble` is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in [`Message`](/docs/components/message).
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add bubble
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="bubble"
|
||||
title="components/ui/bubble.tsx"
|
||||
styleName="base-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Bubble>
|
||||
<BubbleContent>
|
||||
I checked the registry output and removed the stale route.
|
||||
</BubbleContent>
|
||||
<BubbleReactions>
|
||||
<span>👍</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build a bubble:
|
||||
|
||||
```text
|
||||
Bubble
|
||||
├── BubbleContent
|
||||
└── BubbleReactions
|
||||
```
|
||||
|
||||
Use `BubbleGroup` to group consecutive bubbles from the same sender:
|
||||
|
||||
```text
|
||||
BubbleGroup
|
||||
├── Bubble
|
||||
│ └── BubbleContent
|
||||
└── Bubble
|
||||
└── BubbleContent
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Seven visual variants, from a strong primary bubble to unframed ghost content
|
||||
- Start and end alignment for sender and receiver bubbles
|
||||
- Reactions that anchor to the bubble edge with configurable side and alignment
|
||||
- Bubbles size to their content, up to 80% of the container width
|
||||
- Polymorphic content via `render` for link and button bubbles
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Variants
|
||||
|
||||
Use `variant` to change the visual treatment of the bubble.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-variants"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| Variant | Description |
|
||||
| ------------- | ------------------------------------------------------ |
|
||||
| `default` | A strong primary bubble, usually for the current user. |
|
||||
| `secondary` | The standard neutral bubble for conversation content. |
|
||||
| `muted` | A lower-emphasis bubble for quiet supporting content. |
|
||||
| `tinted` | A subtle primary-tinted bubble. |
|
||||
| `outline` | A bordered bubble for secondary or rich content. |
|
||||
| `ghost` | Unframed content for assistant text or rich content. |
|
||||
| `destructive` | A destructive bubble for error or failed actions. |
|
||||
|
||||
A bubble sizes to its content, up to 80% of the container width. The `ghost` variant removes the max-width so assistant text and rich content can span the full row.
|
||||
|
||||
### Alignment
|
||||
|
||||
Use `align` on `Bubble` to align the bubble to the start or end of the conversation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-alignment"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| align | Description |
|
||||
| ------- | -------------------------------------------------- |
|
||||
| `start` | Align the bubble to the start of the conversation. |
|
||||
| `end` | Align the bubble to the end of the conversation. |
|
||||
|
||||
**Note:** When building chat interfaces, you probably want to use alignment on the `Message` component itself, not the `Bubble` component. You can use the `role` prop on the `Message` component to automatically align the bubble to the start or end of the conversation.
|
||||
|
||||
### Bubble Group
|
||||
|
||||
Use `BubbleGroup` to group consecutive bubbles from the same sender. Note the `align` prop should be set on the `Bubble` component itself, not the `BubbleGroup` component.
|
||||
|
||||
```text
|
||||
BubbleGroup
|
||||
├── Bubble
|
||||
│ └── BubbleContent
|
||||
└── Bubble
|
||||
└── BubbleContent
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-group-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Links and Buttons
|
||||
|
||||
You can turn a bubble into a link or button by using the `render` prop on `BubbleContent`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-link-button"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Bubble, BubbleContent } from "@/components/ui/bubble"
|
||||
|
||||
export function BubbleLinkDemo() {
|
||||
return (
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent render={<button />}>Click here</BubbleContent>
|
||||
</Bubble>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Reactions
|
||||
|
||||
Use `BubbleReactions` for bubble reactions. You can use it to display reactions or quick action buttons. Use `side` and `align` to position the row — `side="top"` anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger `gap` for this reason.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-reactions"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Show More / Collapsible
|
||||
|
||||
Long bubble content can be composed with [`Collapsible`](/docs/components/collapsible) to allow for a show more or show less interaction. Use the `CollapsibleTrigger` component to trigger the collapsible content.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-collapsible"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Tooltip
|
||||
|
||||
Wrap a bubble in a [`Tooltip`](/docs/components/tooltip) to reveal metadata on hover, such as when a message was read.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-tooltip"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Popover
|
||||
|
||||
Pair a bubble with a [`Popover`](/docs/components/popover) to surface more information on demand, such as the full error message for a failed action.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="bubble-popover"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
## Accessibility
|
||||
|
||||
`Bubble` renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below.
|
||||
|
||||
### Labeling Reactions
|
||||
|
||||
Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like `+8` are announced as "plus eight". Group the row as a single image with a descriptive `aria-label` so it announces once. `role="img"` also hides the individual emoji from assistive tech, so no `aria-hidden` is needed.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, and 8 more">
|
||||
<span>👍</span>
|
||||
<span>🔥</span>
|
||||
<span>+8</span>
|
||||
</BubbleReactions>
|
||||
```
|
||||
|
||||
When reactions are interactive, render buttons instead and give icon-only buttons an `aria-label`.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<BubbleReactions>
|
||||
<Button aria-label="Thumbs up" variant="secondary" size="icon-xs">
|
||||
<ThumbsUpIcon />
|
||||
</Button>
|
||||
</BubbleReactions>
|
||||
```
|
||||
|
||||
### Interactive Bubbles
|
||||
|
||||
When a bubble is clickable, render it as a real `<button>` or `<a>` with the `render` prop so it is focusable and exposes the correct role. `BubbleContent` ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Bubble variant="muted" align="end">
|
||||
<BubbleContent render={<button type="button" onClick={onReply} />}>
|
||||
I forgot my password
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
```
|
||||
|
||||
### Meaning Beyond Color
|
||||
|
||||
Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a `destructive` bubble, keep the error context in the message text rather than relying on the color treatment.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Bubble
|
||||
|
||||
The root bubble wrapper.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------ |
|
||||
| `variant` | `"default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive"` | `"default"` | The bubble visual treatment. |
|
||||
| `align` | `"start" \| "end"` | `"start"` | The inline alignment of the bubble. |
|
||||
| `className` | `string` | - | Additional classes to apply to the root element. |
|
||||
|
||||
### BubbleContent
|
||||
|
||||
The bubble content wrapper.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------------------------- | ------- | --------------------------------------------------------- |
|
||||
| `render` | `ReactElement \| function` | - | Render the content as a different element such as a link. |
|
||||
| `className` | `string` | - | Additional classes to apply to the content element. |
|
||||
|
||||
### BubbleReactions
|
||||
|
||||
Displays overlapped reactions for a bubble.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------- | ---------- | ------------------------------------------------ |
|
||||
| `side` | `"top" \| "bottom"` | `"bottom"` | The side of the bubble to anchor the reactions. |
|
||||
| `align` | `"start" \| "end"` | `"end"` | The inline alignment of the reactions. |
|
||||
| `className` | `string` | - | Additional classes to apply to the reaction row. |
|
||||
|
||||
### BubbleGroup
|
||||
|
||||
Groups consecutive bubbles from the same sender.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ---------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the group root. |
|
||||
@@ -4,14 +4,11 @@ description: A drawer component for React.
|
||||
base: base
|
||||
component: true
|
||||
links:
|
||||
doc: https://vaul.emilkowal.ski/getting-started
|
||||
doc: https://base-ui.com/react/components/drawer
|
||||
api: https://base-ui.com/react/components/drawer#api-reference
|
||||
---
|
||||
|
||||
<ComponentPreview styleName="base-nova" name="drawer-demo" />
|
||||
|
||||
## About
|
||||
|
||||
Drawer is built on top of [Vaul](https://github.com/emilkowalski/vaul) by [emilkowalski](https://twitter.com/emilkowalski).
|
||||
<ComponentPreview styleName="base-rhea" name="drawer-demo" />
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -36,7 +33,7 @@ npx shadcn@latest add drawer
|
||||
<Step>Install the following dependencies:</Step>
|
||||
|
||||
```bash
|
||||
npm install vaul
|
||||
npm install @base-ui/react
|
||||
```
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
@@ -44,7 +41,7 @@ npm install vaul
|
||||
<ComponentSource
|
||||
name="drawer"
|
||||
title="components/ui/drawer.tsx"
|
||||
styleName="base-nova"
|
||||
styleName="base-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
@@ -55,6 +52,14 @@ npm install vaul
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
Add the following to your global styles. On iOS Safari, the drawer overlay is absolutely positioned and requires a positioned `body` to cover the viewport after the page is scrolled. See the [Base UI docs](https://base-ui.com/react/overview/quick-start#ios-26-safari) for details.
|
||||
|
||||
```css
|
||||
body {
|
||||
position: relative;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
@@ -72,17 +77,16 @@ import {
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Drawer>
|
||||
<DrawerTrigger>Open</DrawerTrigger>
|
||||
<DrawerTrigger render={<Button variant="outline" />}>Open</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Are you absolutely sure?</DrawerTitle>
|
||||
<DrawerDescription>This action cannot be undone.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="p-4">{/* Content here */}</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DrawerClose>
|
||||
<DrawerClose render={<Button variant="outline" />}>Cancel</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -102,32 +106,215 @@ Drawer
|
||||
└── DrawerFooter
|
||||
```
|
||||
|
||||
`DrawerContent` composes the portal, overlay, viewport, and popup from Base UI. For lower-level control, `DrawerPortal`, `DrawerOverlay`, and `DrawerSwipeHandle` are also exported.
|
||||
|
||||
## Custom Sizes
|
||||
|
||||
A vertical drawer sizes itself to its content and is capped at `calc(100dvh - 6rem)` by default. A side drawer spans `75%` of the viewport width, or `24rem` on larger screens.
|
||||
|
||||
To customize the height of a vertical drawer, use the `h-*` and `max-h-*` utilities on `DrawerContent`.
|
||||
|
||||
```tsx
|
||||
<DrawerContent className="h-[50vh]">
|
||||
```
|
||||
|
||||
To customize the width of a side drawer, use the `w-*` and `max-w-*` utilities on `DrawerContent`.
|
||||
|
||||
```tsx
|
||||
<DrawerContent className="w-96">
|
||||
```
|
||||
|
||||
When the same component renders in multiple directions, scope an override to one axis using the `data-[swipe-axis=*]` variants.
|
||||
|
||||
```tsx
|
||||
<DrawerContent className="data-[swipe-axis=y]:max-h-[50vh] data-[swipe-axis=x]:w-96">
|
||||
```
|
||||
|
||||
To make a region of the drawer scrollable, make the scroll container a flex item. Avoid `h-full`, which does not resolve inside a content-sized drawer.
|
||||
|
||||
```tsx
|
||||
<DrawerContent>
|
||||
<DrawerHeader>...</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4">{/* Scrollable content */}</div>
|
||||
<DrawerFooter>...</DrawerFooter>
|
||||
</DrawerContent>
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
The drawer exposes CSS variables for style-level customization. Set the sizing variables on `DrawerContent`. Set the overlay variable on `[data-slot=drawer-overlay]` in your CSS.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------------ | ---------------------- | ----------------------------------------------------------------------- |
|
||||
| `--drawer-inset` | `0px` | Floats the drawer from the viewport edges. |
|
||||
| `--drawer-bleed-background` | `var(--color-popover)` | Fills the gap behind the drawer on swipe overshoot. |
|
||||
| `--drawer-overlay-min-opacity` | `0` | Minimum overlay opacity. Defaults to `0.5` when snap points are active. |
|
||||
|
||||
The drawer also sets data attributes you can target with variants such as `data-[swipe-direction=down]:` on `DrawerContent`, or `group-data-[swipe-axis=y]/drawer-popup:` on its descendants.
|
||||
|
||||
| Attribute | Values | Set when |
|
||||
| ------------------------- | ----------------------------- | ------------------------------------- |
|
||||
| `data-swipe-direction` | `up`, `right`, `down`, `left` | Always. |
|
||||
| `data-swipe-axis` | `x`, `y` | Always. |
|
||||
| `data-snap-points` | Present | The drawer has snap points. |
|
||||
| `data-expanded` | Present | The drawer is at the full snap point. |
|
||||
| `data-swiping` | Present | A swipe is in progress. |
|
||||
| `data-nested-drawer-open` | Present | A nested drawer is open on top. |
|
||||
|
||||
## Examples
|
||||
|
||||
### Scrollable Content
|
||||
### Position
|
||||
|
||||
Keep actions visible while the content scrolls.
|
||||
Use the `swipeDirection` prop to set the side of the drawer.
|
||||
|
||||
<ComponentPreview styleName="base-nova" name="drawer-scrollable-content" />
|
||||
Available options are `up`, `right`, `down`, and `left`.
|
||||
|
||||
### Sides
|
||||
<ComponentPreview styleName="base-rhea" name="drawer-sides" />
|
||||
|
||||
Use the `direction` prop to set the side of the drawer. Available options are `top`, `right`, `bottom`, and `left`.
|
||||
### Swipe Handle
|
||||
|
||||
<ComponentPreview styleName="base-nova" name="drawer-sides" />
|
||||
Use `showSwipeHandle` on `Drawer` to render a swipe handle.
|
||||
|
||||
### Responsive Dialog
|
||||
<ComponentPreview styleName="base-rhea" name="drawer-swipe-handle" />
|
||||
|
||||
### Nested
|
||||
|
||||
Open drawers from inside another drawer. Parent drawers stay mounted and stack behind the frontmost drawer.
|
||||
|
||||
<ComponentPreview styleName="base-rhea" name="drawer-nested" />
|
||||
|
||||
### Non Modal
|
||||
|
||||
Set `modal={false}` to allow interaction with the rest of the page while the drawer is open. Combine with `disablePointerDismissal` to prevent the drawer from closing on outside presses. Use `modal="trap-focus"` to keep focus inside the drawer while leaving scroll and pointer interaction unrestricted.
|
||||
|
||||
<ComponentPreview styleName="base-rhea" name="drawer-non-modal" />
|
||||
|
||||
### Snap Points
|
||||
|
||||
Use `snapPoints` to snap a drawer to preset heights. Numbers between `0` and `1` represent fractions of the viewport. Numbers greater than `1` are treated as pixel values. String values support `px` and `rem` units. Snap points apply to vertical drawers.
|
||||
|
||||
Track the active snap point with the controlled `snapPoint` and `onSnapPointChange` props. At the full snap point, the drawer gets a `data-expanded` attribute you can style with the `data-expanded:` variant.
|
||||
|
||||
<ComponentPreview styleName="base-rhea" name="drawer-snap-points" />
|
||||
|
||||
### Responsive
|
||||
|
||||
You can combine the `Dialog` and `Drawer` components to create a responsive dialog. This renders a `Dialog` component on desktop and a `Drawer` on mobile.
|
||||
|
||||
<ComponentPreview styleName="base-nova" name="drawer-dialog" />
|
||||
<ComponentPreview styleName="base-rhea" name="drawer-dialog" />
|
||||
|
||||
## RTL
|
||||
## Migrating from Vaul
|
||||
|
||||
To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl).
|
||||
The base drawer now uses [Base UI](https://base-ui.com/react/components/drawer)
|
||||
instead of Vaul. If you installed the previous base drawer, update your usage
|
||||
to the Base UI API.
|
||||
|
||||
<ComponentPreview styleName="base-nova" name="drawer-rtl" direction="rtl" />
|
||||
<Steps>
|
||||
|
||||
<Step>Update the dependency.</Step>
|
||||
|
||||
```diff
|
||||
- npm install vaul
|
||||
+ npm install @base-ui/react
|
||||
```
|
||||
|
||||
<Step>Replace `direction` with `swipeDirection`.</Step>
|
||||
|
||||
Use `down` instead of `bottom`, and `up` instead of `top`. `left` and `right`
|
||||
stay the same.
|
||||
|
||||
```diff
|
||||
- <Drawer direction="bottom">
|
||||
+ <Drawer swipeDirection="down">
|
||||
```
|
||||
|
||||
<Step>Replace `asChild` with `render`.</Step>
|
||||
|
||||
For `DrawerTrigger`, pass the trigger element to the `render` prop.
|
||||
|
||||
```diff
|
||||
- <DrawerTrigger asChild>
|
||||
- <Button variant="outline">Open</Button>
|
||||
- </DrawerTrigger>
|
||||
+ <DrawerTrigger render={<Button variant="outline" />}>
|
||||
+ Open
|
||||
+ </DrawerTrigger>
|
||||
```
|
||||
|
||||
For `DrawerClose`, pass the close element to the `render` prop.
|
||||
|
||||
```diff
|
||||
- <DrawerClose asChild>
|
||||
- <Button variant="outline">Cancel</Button>
|
||||
- </DrawerClose>
|
||||
+ <DrawerClose render={<Button variant="outline" />}>
|
||||
+ Cancel
|
||||
+ </DrawerClose>
|
||||
```
|
||||
|
||||
<Step>Update snap point props.</Step>
|
||||
|
||||
If you use snap points, rename the controlled snap point props and the sequential
|
||||
snap point prop.
|
||||
|
||||
```diff
|
||||
<Drawer
|
||||
snapPoints={[0.25, 0.5, 1]}
|
||||
- activeSnapPoint={snapPoint}
|
||||
- setActiveSnapPoint={setSnapPoint}
|
||||
- snapToSequentialPoint
|
||||
+ snapPoint={snapPoint}
|
||||
+ onSnapPointChange={setSnapPoint}
|
||||
+ snapToSequentialPoints
|
||||
>
|
||||
```
|
||||
|
||||
<Step>Update animation and focus props.</Step>
|
||||
|
||||
```diff
|
||||
- <Drawer onAnimationEnd={(open) => setDone(open)}>
|
||||
+ <Drawer onOpenChangeComplete={(open) => setDone(open)}>
|
||||
```
|
||||
|
||||
```diff
|
||||
- <DrawerContent onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
+ <DrawerContent initialFocus={false}>
|
||||
```
|
||||
|
||||
<Step>Review Vaul-only props.</Step>
|
||||
|
||||
Vaul props like `handleOnly`, `repositionInputs`, and
|
||||
`shouldScaleBackground` do not have one-to-one replacements in the base drawer
|
||||
API. Use Base UI props such as `disablePointerDismissal`, `modal`, `snapPoints`,
|
||||
or controlled `open` state for the behavior you need.
|
||||
|
||||
```diff
|
||||
- <Drawer handleOnly repositionInputs={false} shouldScaleBackground>
|
||||
+ <Drawer>
|
||||
```
|
||||
|
||||
```diff
|
||||
- <Drawer dismissible={false}>
|
||||
+ <Drawer disablePointerDismissal>
|
||||
```
|
||||
|
||||
<Step>Update custom data attribute selectors.</Step>
|
||||
|
||||
Replace Vaul's `data-vaul-drawer-direction` selectors with Base UI's
|
||||
`data-swipe-direction` selectors.
|
||||
|
||||
```diff
|
||||
- <DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[50vh]">
|
||||
+ <DrawerContent className="data-[swipe-direction=down]:max-h-[50vh]">
|
||||
```
|
||||
|
||||
Base UI also exposes attributes like `data-swiping`, `data-starting-style`, and
|
||||
`data-ending-style` for swipe and transition states. Descendants inside
|
||||
`DrawerContent` can use `group-data-[swipe-axis=x]/drawer-popup` and
|
||||
`group-data-[swipe-axis=y]/drawer-popup` for axis-specific styling.
|
||||
|
||||
</Steps>
|
||||
|
||||
## API Reference
|
||||
|
||||
See the [Vaul documentation](https://vaul.emilkowal.ski/getting-started) for the full API reference.
|
||||
See the [Base UI documentation](https://base-ui.com/react/components/drawer) for the full API reference.
|
||||
|
||||
273
apps/v4/content/docs/components/base/marker.mdx
Normal file
273
apps/v4/content/docs/components/base/marker.mdx
Normal file
@@ -0,0 +1,273 @@
|
||||
---
|
||||
title: Marker
|
||||
description: Displays an inline status, system note, bordered row, or labeled separator in a conversation.
|
||||
base: base
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
The `Marker` component displays inline conversation markers such as status updates, system notes, bordered rows, and labeled separators. Compose it with [`Message`](/docs/components/message) in a conversation thread.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add marker
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="marker"
|
||||
title="components/ui/marker.tsx"
|
||||
styleName="base-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker>
|
||||
<MarkerIcon>
|
||||
<CheckIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Explored 4 files</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build a marker:
|
||||
|
||||
```text
|
||||
Marker
|
||||
├── MarkerIcon
|
||||
└── MarkerContent
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Inline marker, bordered row, and labeled separator variants
|
||||
- Decorative icon slot that is hidden from assistive tech
|
||||
- Polymorphic root via `render` for link and button markers
|
||||
- Pairs with the [`shimmer`](/docs/utils/shimmer) utility for streaming status text
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Variants
|
||||
|
||||
Use `variant` to switch between an inline marker, bordered row, and labeled separator.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-variants"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| Variant | Description |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| `default` | An inline marker for status, notes, and actions. |
|
||||
| `border` | A default marker with a bottom border under the row. |
|
||||
| `separator` | A centered label with divider lines on each side. |
|
||||
|
||||
### Status
|
||||
|
||||
Set `role="status"` and include a [`Spinner`](/docs/components/spinner) for streaming or in-progress markers so updates are announced.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-status"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Shimmer
|
||||
|
||||
Add the [`shimmer`](/docs/utils/shimmer) utility class to `MarkerContent` for an animated streaming-text effect. The utility ships with the `shadcn` package — see the shimmer docs for installation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-shimmer"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Separator
|
||||
|
||||
Use the `separator` variant for labeled dividers, such as dates or section breaks, in a conversation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-separator"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Border
|
||||
|
||||
Use the `border` variant for status rows that should keep the default marker alignment while separating the next row.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-border"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### With Icon
|
||||
|
||||
Use `MarkerIcon` to render an icon alongside the content. Use `flex-col` to stack the icon above the content.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-icon"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Links and Buttons
|
||||
|
||||
Turn a marker into a link or button with the `render` prop on `Marker`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="marker-link-button"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Marker, MarkerContent } from "@/components/ui/marker"
|
||||
|
||||
export function MarkerLinkDemo() {
|
||||
return (
|
||||
<Marker render={<a href="#" />}>
|
||||
<MarkerContent>View the pull request</MarkerContent>
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
`Marker` is presentational by default. The correct semantics depend on how you use it, so choose the role based on intent rather than relying on a single default.
|
||||
|
||||
### Status and Progress
|
||||
|
||||
For streaming or progress markers such as "Thinking..." or a running tool, set `role="status"` so assistive tech announces the update as it appears. `Marker` forwards `role` to the underlying element.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Compacting conversation</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
### Labeled Separators
|
||||
|
||||
A separator that carries text, such as a date or a section label, needs no role. The divider lines are decorative CSS pseudo-elements, and the text is announced as ordinary content.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Today</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
<Callout>
|
||||
**Note:** Do not add `role="separator"` to a labeled divider. A separator
|
||||
takes its accessible name from `aria-label`, not from its text, and its
|
||||
contents are treated as presentational, so the visible label would not be
|
||||
announced. Reserve `role="separator"` for a divider with no meaningful text.
|
||||
</Callout>
|
||||
|
||||
### Bordered Markers
|
||||
|
||||
A bordered marker keeps the same semantics as the default marker. The bottom border is decorative, so choose `role="status"`, `render`, or no role based on the marker's purpose.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker variant="border">
|
||||
<MarkerIcon>
|
||||
<FileTextIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Opened implementation notes</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
### Decorative Icons
|
||||
|
||||
`MarkerIcon` is decorative and hidden from assistive tech with `aria-hidden`, so the adjacent `MarkerContent` carries the meaning. For an icon-only marker, provide an `aria-label` or visible text so it is not announced as empty.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker aria-label="Synced">
|
||||
<MarkerIcon>
|
||||
<CheckIcon />
|
||||
</MarkerIcon>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
### Interactive Markers
|
||||
|
||||
When a marker links or triggers an action, render it as a real `<button>` or `<a>` with the `render` prop so it is focusable and exposes the correct role. The accessible name comes from the marker text.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker render={<a href="/files" />}>
|
||||
<MarkerIcon>
|
||||
<FileTextIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Explored 4 files</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Marker
|
||||
|
||||
The root marker element. The file also exports `markerVariants` for composing the marker styles into custom components.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------------------------------------- | ----------- | ------------------------------------------------ |
|
||||
| `variant` | `"default" \| "border" \| "separator"` | `"default"` | The marker layout. |
|
||||
| `render` | `ReactElement \| function` | - | Render as a different element, such as a link. |
|
||||
| `className` | `string` | - | Additional classes to apply to the root element. |
|
||||
|
||||
### MarkerIcon
|
||||
|
||||
A decorative icon slot. Hidden from assistive tech with `aria-hidden`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | --------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the icon slot. |
|
||||
|
||||
### MarkerContent
|
||||
|
||||
The marker text content.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the content slot. |
|
||||
586
apps/v4/content/docs/components/base/message-scroller.mdx
Normal file
586
apps/v4/content/docs/components/base/message-scroller.mdx
Normal file
@@ -0,0 +1,586 @@
|
||||
---
|
||||
title: Message Scroller
|
||||
description: A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
|
||||
base: base
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-demo"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
## What Makes a Great Streaming Chat Experience
|
||||
|
||||
Building a chat interface used to be simple. You create an inverted list with
|
||||
an input. Type a message, it appends at the bottom. When a reply comes in, the
|
||||
list grows and scrolls. Done.
|
||||
|
||||
Streaming breaks that model. Messages arrive in chunks while you may still be
|
||||
reading, scrolling, or looking somewhere else entirely.
|
||||
|
||||
Now the challenge is preserving the reader's place while the conversation keeps
|
||||
changing. Get that wrong and the experience feels jumpy: people are pulled to
|
||||
the bottom, lose context, and have to find their way back.
|
||||
|
||||
In practice, this comes down to scroll: when to follow, when to hold, and when
|
||||
to let the reader decide. A great streaming chat should:
|
||||
|
||||
1. **Move only when the reader asked to move.** If someone is reading, don’t pull them somewhere else. Auto-scroll should never be the default.
|
||||
2. **Follow only while they’re following.** If they’re at the live edge, keep the stream in view. If they scroll away, leave them there.
|
||||
3. **Every interaction is a signal.** Scrolling is not the only one. Selecting text, using the keyboard, opening a link, or searching should all stop the interface from moving.
|
||||
4. **Start a new turn near the top of the viewport.** This gives the new turn somewhere it can be read from the beginning.
|
||||
5. **Then stream in the answer.** The answer should grow into the screen, not immediately push everything away.
|
||||
6. **Keep part of the previous conversation in context.** The prompt and reply should stay visually connected, and enough of the previous turn should remain visible so the reader knows where they are.
|
||||
7. **Let new content arrive offscreen.** The conversation can keep streaming without changing what the reader is looking at.
|
||||
8. **Show what’s happening out of view.** Make it clear when a response is still streaming or when new messages have arrived.
|
||||
9. **Make it easy to return to the latest reply.** A “Jump to latest” action should bring the reader back and resume following.
|
||||
10. **Let people jump anywhere in the conversation.** Long threads need message links, search, unread markers, and direct navigation.
|
||||
11. **Reopen where the reader left off.** A saved conversation should open at the last meaningful turn. Often this is the last user message. Not the absolute bottom.
|
||||
12. **Keep the reader’s place when layout changes.** Images load. Markdown expands. Code blocks render. Older messages appear above. None of that should make the reader lose their place.
|
||||
13. **Handle interruptions without stealing position.** Stopping, retrying, regenerating, branching, or errors should not unexpectedly move the conversation.
|
||||
14. **Stay responsive in long threads.** Streaming text, markdown, code, images, and long history should still feel responsive.
|
||||
15. **Be accessible without the noise.** Keep the transcript navigable, preserve keyboard focus, and announce important events at a comfortable pace.
|
||||
|
||||
**Never move the reader against their intent.**
|
||||
|
||||
## MessageScroller
|
||||
|
||||
MessageScroller is a chat transcript scroller built for these behaviors.
|
||||
`MessageScrollerProvider` owns the scroll state and transcript-row behavior:
|
||||
opening position, streamed output, new-turn anchoring, prepended history,
|
||||
visibility, and scroll controls. `MessageScroller` is the styled frame that
|
||||
renders inside it.
|
||||
|
||||
MessageScroller is scoped to the scroll viewport. It does not own messages, AI state,
|
||||
transport, persistence, branching, or model state. Your product code stays
|
||||
focused on composing messages, markers, tools, attachments, and prompt inputs.
|
||||
|
||||
It gives you the scroll behavior that chat needs, without taking over the rest
|
||||
of the chat UI. And it stays fast, even in long conversations with rich
|
||||
markdown.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add message-scroller
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Install the following dependencies:</Step>
|
||||
|
||||
```bash
|
||||
npm install @shadcn/react
|
||||
```
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="message-scroller"
|
||||
title="components/ui/message-scroller.tsx"
|
||||
styleName="base-nova"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Message } from "@/components/ui/message"
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent>
|
||||
{messages.map((message) => (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
<Message />
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
`MessageScroller` fills its parent, so place it inside a height-constrained
|
||||
container.
|
||||
|
||||
```tsx
|
||||
<div className="flex h-screen flex-col">
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent>
|
||||
<MessageScrollerItem>
|
||||
{/* a message, marker, or row */}
|
||||
</MessageScrollerItem>
|
||||
<MessageScrollerItem />
|
||||
<MessageScrollerItem />
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
- **`MessageScrollerProvider`** — the headless root. Owns scroll state and the
|
||||
behavior props for opening position, auto-scroll, anchoring, scroll commands,
|
||||
and visibility tracking.
|
||||
- **`MessageScroller`** — the styled frame. Lays out the viewport, content, and
|
||||
controls inside the provider.
|
||||
- **`MessageScrollerViewport`** — the scrollable element. Receives native scroll
|
||||
events and preserves the visible row when older messages are prepended.
|
||||
- **`MessageScrollerContent`** — the transcript container. Holds the rows and
|
||||
provides the live-region defaults for new messages.
|
||||
- **`MessageScrollerItem`** — the transcript row boundary. Wrap every direct
|
||||
child of the content so the scroller can measure, anchor, preserve position,
|
||||
track visibility, and jump to it. An item can be a message, marker, typing
|
||||
indicator, separator, join/leave event, or "load earlier" row.
|
||||
- **`MessageScrollerButton`** — the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Anchoring Turns
|
||||
|
||||
A turn is the part of the conversation that starts a new exchange. In a simple
|
||||
AI chat, that is usually the user's message and the assistant reply that follows.
|
||||
|
||||
An anchor is the row the viewport should treat as the start of that turn. Mark
|
||||
that row with `scrollAnchor`. When a new anchor is appended, the viewport moves
|
||||
it near the top and keeps a peek of the previous item above it, so the new turn
|
||||
does not feel detached from its context.
|
||||
|
||||
```tsx
|
||||
// This tells the scroller to anchor the user's message for the next turn.
|
||||
<MessageScrollerItem
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
/>
|
||||
```
|
||||
|
||||
Scroll anchors are not tied to message role. You can turn any row into an anchor:
|
||||
a user message, a system marker, a handoff event, or anything else that starts a
|
||||
meaningful turn. `MessageScroller` only needs to know which row should anchor the
|
||||
viewport.
|
||||
|
||||
In the following example, the user's message is anchored. When you send a new message, the viewport anchors it near the top and appends the assistant reply below it. Toggle the anchor to the assistant's message to see the difference.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-anchoring"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
### Group Chat
|
||||
|
||||
In a group chat, the turn boundary is more specific than "the user message". It is often
|
||||
the message that asks the model to respond, or a marker like "Marcus joined the
|
||||
chat". Typing indicators and history controls usually should not anchor.
|
||||
|
||||
Because anchoring is role-independent, you can anchor a marker just as easily as
|
||||
a message.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Marcus joined the chat</MarkerContent>
|
||||
</Marker>
|
||||
</MessageScrollerItem>
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-group-chat"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
### Keeping Context Visible
|
||||
|
||||
When a new turn starts, it should still feel like part of the same continuous
|
||||
thread. `scrollPreviousItemPeek` keeps a slice of the previous item visible
|
||||
above the anchor, so the reader keeps their context instead of feeling like the
|
||||
conversation restarted on a blank page.
|
||||
|
||||
```tsx
|
||||
// Keep 64px of the previous turn visible above the newly anchored row.
|
||||
<MessageScrollerProvider scrollPreviousItemPeek={64}>
|
||||
<MessageScroller>{/* anchored turns */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
Adjust the peek amount in the example below to see how it affects the conversation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-previous-context"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
### Following the Live Edge
|
||||
|
||||
When the reader is at the live edge, either because they stayed there or
|
||||
returned there, `autoScroll` keeps streamed replies in view as they grow.
|
||||
Scrolling away from the live edge releases the view, whether by wheel, touch,
|
||||
keyboard scroll keys, or dragging the scrollbar. An explicit message jump
|
||||
releases it too. New chunks can then arrive without moving the reader.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller>{/* streamed turns */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-streaming"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
Calling `scrollToEnd`, or pressing `MessageScrollerButton`, re-engages
|
||||
follow-output when `autoScroll` is enabled, so a reader who scrolled away can
|
||||
return to the live edge and keep following. The root and viewport expose
|
||||
`data-autoscrolling` while that programmatic scroll to the latest message runs,
|
||||
so you can conditionally apply styles during the transition.
|
||||
|
||||
### Opening Saved Threads
|
||||
|
||||
It can seem reasonable to reopen a saved thread at the absolute end of the
|
||||
transcript, but that often drops the reader into the conversation without enough
|
||||
context. A better default is `"last-anchor"`: show the last meaningful turn,
|
||||
like the user's latest message, with the reply below it.
|
||||
|
||||
That gives the reader an immediate place in the thread. They can see what they
|
||||
asked, where the answer starts, and continue from there without reconstructing
|
||||
the conversation from the bottom edge.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider defaultScrollPosition="last-anchor">
|
||||
<MessageScroller>{/* transcript */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-opening-position"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
`"last-anchor"` is keyed on `scrollAnchor`, not message role. If no anchor
|
||||
exists, or the last anchored turn already fits in the viewport, it falls back to
|
||||
`"end"`.
|
||||
|
||||
Use `"start"` when you want to resume at the beginning of a conversation, or
|
||||
`"end"` when the absolute latest message is the right place to land.
|
||||
|
||||
### Loading Earlier Messages
|
||||
|
||||
Loading earlier messages should not move the conversation the reader is already
|
||||
looking at. When older rows are prepended above the current transcript,
|
||||
`MessageScrollerViewport` preserves the visible row so the reader stays in the
|
||||
same place while history loads above them.
|
||||
|
||||
This is enabled by default through `preserveScrollOnPrepend`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-load-history"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
Use stable `messageId` values for message rows. That gives the scroller a
|
||||
specific row to preserve instead of guessing from whichever pixel happens to sit
|
||||
at the viewport edge.
|
||||
|
||||
### Animating New Messages
|
||||
|
||||
`MessageScrollerItem` can be animated directly. Create a motion version of the
|
||||
item, keep `messageId` and `scrollAnchor` on it, and use transform and opacity
|
||||
for the entrance.
|
||||
|
||||
A common chat pattern is to animate the user's message when it is sent, then let
|
||||
the assistant reply stream into a regular row below it. Start the user row below
|
||||
its final position so it feels like it rises from the live edge of the viewport.
|
||||
|
||||
```tsx
|
||||
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-animation"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
Avoid animating height, margin, or padding for row entrances; those changes can
|
||||
fight the scroller's positioning work. If the reader prefers reduced motion,
|
||||
skip the entrance animation and keep the scroll behavior the same.
|
||||
|
||||
### Jumping to Messages
|
||||
|
||||
Search results, permalinks, outline items, and toolbar buttons often need to
|
||||
drive the transcript from outside the message list. Use `useMessageScroller` for
|
||||
those controls. Because the hooks read from `MessageScrollerProvider`, they work
|
||||
in any component inside the provider, including controls rendered outside the
|
||||
`MessageScroller` frame.
|
||||
|
||||
```tsx
|
||||
import { useMessageScroller } from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-commands"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
`scrollToMessage` targets the `messageId` on `MessageScrollerItem`, so rows that
|
||||
need to be addressable should have stable ids. `scrollToMessage` returns `false`
|
||||
when the target is not mounted and cannot be queued.
|
||||
|
||||
`scrollToMessage` can queue a target before items exist, which covers
|
||||
client-resolved permalinks while the transcript mounts. After rows have mounted,
|
||||
a missing id returns `false` instead of starting a guessed retry loop. A `true`
|
||||
result means the scroll ran or was queued, not that the row is already in view.
|
||||
|
||||
### Tracking the Reader's Position
|
||||
|
||||
Use `useMessageScrollerVisibility` to track the reader's position in the
|
||||
conversation. A common example is a table-of-contents or a jump menu that
|
||||
highlights the current anchored turn.
|
||||
|
||||
```tsx
|
||||
import { useMessageScrollerVisibility } from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-visibility"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
`currentAnchorId` answers "where am I" by reporting the current anchored turn,
|
||||
and it stays set after that anchor scrolls above the viewport. `visibleMessageIds`
|
||||
answers "what is on screen", in document order.
|
||||
|
||||
Visibility is pay-for-what-you-use. Tracking only runs while something
|
||||
subscribes to `useMessageScrollerVisibility`, and rows need a `messageId` to
|
||||
participate.
|
||||
|
||||
### Reading Scroll State
|
||||
|
||||
Use `useMessageScrollerScrollable` when you need scroll state in JavaScript, such
|
||||
as a status indicator or a custom "jump to latest" control. It reports which
|
||||
edges the viewport can still scroll toward; "at the start/end" is the negation
|
||||
(`!start` / `!end`), and "scrollable at all" is `start || end`. For styling the
|
||||
scroller itself, prefer the `data-scrollable` attribute.
|
||||
|
||||
```tsx
|
||||
import { useMessageScrollerScrollable } from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
const { start, end } = useMessageScrollerScrollable()
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-scroller-scrollable"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
## Performance
|
||||
|
||||
`MessageScroller` is benchmarked against large transcripts with markdown and
|
||||
composed message rows.
|
||||
|
||||
Our performance goal for `MessageScroller` is to keep the scroll hot path outside of React state: no React rerenders for
|
||||
transcript rows, no forced layout on every scroll, and as little off-screen paint
|
||||
work as the browser can avoid.
|
||||
|
||||
Scroll position, anchoring, and follow-output are tracked imperatively and mirrored onto the root and viewport through `data-*` attributes, so scrolling and streaming do not rerender transcript rows.
|
||||
|
||||
The styled `MessageScrollerItem` also ships with `content-visibility: auto` and
|
||||
`contain-intrinsic-size`. Rows stay in the DOM for selection, copy,
|
||||
find-in-page, SSR, and assistive tech, but the browser can skip rendering work
|
||||
for rows far outside the viewport.
|
||||
|
||||
Visibility tracking is pay-for-what-you-use. A jump menu or active
|
||||
turn indicator costs nothing until something subscribes to
|
||||
`useMessageScrollerVisibility`.
|
||||
|
||||
This is comfortable for the expected range of a chat transcript: hundreds to low
|
||||
thousands of turns, including messages with markdown and composed components.
|
||||
|
||||
## Virtualization
|
||||
|
||||
Virtualization is intentionally left outside the primitive. `MessageScroller`
|
||||
renders real DOM rows and stays fast well into the thousands of turns (see
|
||||
[Performance](#performance)), so most transcripts never need it.
|
||||
|
||||
When a transcript is large enough to need virtualization, use
|
||||
`MessageScrollerViewport` as the scroll element and let the virtualizer own the
|
||||
rows.
|
||||
|
||||
```tsx showLineNumbers
|
||||
import * as React from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
|
||||
function VirtualizedTranscript({
|
||||
messages,
|
||||
}: {
|
||||
messages: Array<{ id: string; content: React.ReactNode }>
|
||||
}) {
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: messages.length,
|
||||
getScrollElement: () => viewportRef.current,
|
||||
estimateSize: () => 86,
|
||||
getItemKey: (index) => messages[index]?.id ?? index,
|
||||
overscan: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport ref={viewportRef}>
|
||||
<MessageScrollerContent className="block min-h-full">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const message = messages[virtualItem.index]
|
||||
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualItem.index}
|
||||
className="absolute start-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Message>{message.content}</Message>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
`MessageScroller` keeps the scroll container keyboard reachable and the
|
||||
transcript announceable without forcing a specific message UI.
|
||||
|
||||
`MessageScrollerViewport` is a labelled, keyboard-focusable scroll region by
|
||||
default. It uses `role="region"`, `aria-label="Messages"`, and `tabIndex={0}`,
|
||||
so keyboard users can focus the transcript and scroll it directly.
|
||||
|
||||
`MessageScrollerContent` marks the transcript as a live region with
|
||||
`role="log"` and `aria-relevant="additions"`. New rows can be announced, but
|
||||
streamed text mutations do not have to be announced token by token.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerContent aria-busy={status === "streaming"}>
|
||||
{/* messages */}
|
||||
</MessageScrollerContent>
|
||||
```
|
||||
|
||||
Pass `aria-busy` while a turn streams if announcements should wait for the
|
||||
completed message row.
|
||||
|
||||
`MessageScrollerButton` renders a real button. When there is nothing to scroll
|
||||
toward, it sets `inert`, uses `tabIndex={-1}`, and exposes `data-active="false"`
|
||||
so inactive scroll controls do not create extra focus stops.
|
||||
|
||||
## Unstyled
|
||||
|
||||
The behavior in `MessageScroller` comes from the `@shadcn/react` package. To use
|
||||
it directly with your own markup and styles, see
|
||||
[Message Scroller](/docs/react/message-scroller) under @shadcn/react.
|
||||
|
||||
## API Reference
|
||||
|
||||
The props, data attributes, and hooks for every part are documented on the
|
||||
[@shadcn/react Message Scroller](/docs/react/message-scroller#api-reference) page.
|
||||
They are identical for the styled component and the unstyled parts.
|
||||
248
apps/v4/content/docs/components/base/message.mdx
Normal file
248
apps/v4/content/docs/components/base/message.mdx
Normal file
@@ -0,0 +1,248 @@
|
||||
---
|
||||
title: Message
|
||||
description: Displays a message in a conversation, with optional avatar, header, footer, and alignment.
|
||||
base: base
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
The `Message` component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface.
|
||||
|
||||
For AI apps, you can render reasoning steps, tool calls and assistant messages using the `Message` component.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add message
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="message"
|
||||
title="components/ui/message.tsx"
|
||||
styleName="base-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Bubble, BubbleContent } from "@/components/ui/bubble"
|
||||
import { Message, MessageAvatar, MessageContent } from "@/components/ui/message"
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Message>
|
||||
<MessageAvatar>
|
||||
<Avatar>
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble>
|
||||
<BubbleContent>How can I help you today?</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
```
|
||||
|
||||
**Note:** `Message` owns the row layout—avatar, alignment, header, and footer.
|
||||
Render the visible message surface inside it with
|
||||
[`Bubble`](/docs/components/bubble). For the scroll container around a
|
||||
conversation, use [`MessageScroller`](/docs/components/message-scroller).
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build a message:
|
||||
|
||||
```text
|
||||
Message
|
||||
├── MessageAvatar
|
||||
└── MessageContent
|
||||
├── MessageHeader
|
||||
├── Bubble
|
||||
└── MessageFooter
|
||||
```
|
||||
|
||||
Use `MessageGroup` to stack consecutive messages from the same sender:
|
||||
|
||||
```text
|
||||
MessageGroup
|
||||
├── Message
|
||||
└── Message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Start and end alignment for sender and receiver rows via the `align` prop
|
||||
- Avatar slot that anchors to the bottom of the message and stays clear of the footer
|
||||
- Header and footer slots for sender names, status, and message actions
|
||||
- Footer follows the message side; actions stay aligned on `align="end"` rows
|
||||
- Group wrapper for stacking consecutive messages from the same sender
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Avatar
|
||||
|
||||
Use `MessageAvatar` to render an avatar next to the message. Set `align="end"` on the message to align the avatar to the end of the message.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-avatar"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| align | Description |
|
||||
| ------- | --------------------------------------------------- |
|
||||
| `start` | Align the message to the start of the conversation. |
|
||||
| `end` | Align the message to the end of the conversation. |
|
||||
|
||||
### Group
|
||||
|
||||
Use `MessageGroup` to stack consecutive messages from the same sender. Render an empty `MessageAvatar` on the earlier messages to keep them aligned with the avatar on the last one.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-group"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Header and Footer
|
||||
|
||||
Use `MessageHeader` for a sender name and `MessageFooter` for metadata such as a delivery or read status.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-header-footer"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Actions
|
||||
|
||||
Place message-level actions in `MessageFooter`, such as copy, retry, or feedback buttons.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-actions"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Attachment
|
||||
|
||||
<ComponentPreview
|
||||
styleName="base-rhea"
|
||||
name="message-attachment"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
## Accessibility
|
||||
|
||||
`Message` is a presentational layout wrapper. Accessibility comes from the content you place inside it.
|
||||
|
||||
### Label icon-only actions
|
||||
|
||||
Action buttons in `MessageFooter` are usually icon-only, so give each one an `aria-label`.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<MessageFooter>
|
||||
<Button variant="ghost" size="icon" aria-label="Copy">
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</MessageFooter>
|
||||
```
|
||||
|
||||
### Status updates
|
||||
|
||||
For in-progress messages, use a [`Marker`](/docs/components/marker) with `role="status"` so assistive tech announces the update as it appears.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Message>
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Checking the logs...</MarkerContent>
|
||||
</Marker>
|
||||
</Message>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Message
|
||||
|
||||
The message row wrapper.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------ | --------- | ------------------------------------------------- |
|
||||
| `align` | `"start" \| "end"` | `"start"` | The alignment of the message in the conversation. |
|
||||
| `className` | `string` | - | Additional classes to apply to the row. |
|
||||
|
||||
### MessageGroup
|
||||
|
||||
Groups consecutive messages from the same sender.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ---------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the group root. |
|
||||
|
||||
### MessageAvatar
|
||||
|
||||
The avatar slot, aligned to the bottom of the message. When the message has a `MessageFooter`, the avatar shifts up to stay aligned with the message surface instead of the footer.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the avatar slot. |
|
||||
|
||||
### MessageContent
|
||||
|
||||
Wraps the header, message surface, and footer.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the content slot. |
|
||||
|
||||
### MessageHeader
|
||||
|
||||
Displays content above the message, such as a sender name. Stays aligned to the start regardless of `align`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the header. |
|
||||
|
||||
### MessageFooter
|
||||
|
||||
Displays content below the message, such as status or actions. Aligns to the message side.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the footer. |
|
||||
@@ -5,9 +5,11 @@
|
||||
"alert",
|
||||
"alert-dialog",
|
||||
"aspect-ratio",
|
||||
"attachment",
|
||||
"avatar",
|
||||
"badge",
|
||||
"breadcrumb",
|
||||
"bubble",
|
||||
"button",
|
||||
"button-group",
|
||||
"calendar",
|
||||
@@ -35,7 +37,10 @@
|
||||
"item",
|
||||
"kbd",
|
||||
"label",
|
||||
"marker",
|
||||
"menubar",
|
||||
"message",
|
||||
"message-scroller",
|
||||
"native-select",
|
||||
"navigation-menu",
|
||||
"pagination",
|
||||
|
||||
@@ -3,6 +3,12 @@ title: Components
|
||||
description: Here you can find all the components available in the library. We are working on adding more components.
|
||||
---
|
||||
|
||||
## New Components
|
||||
|
||||
<ComponentsList variant="new" />
|
||||
|
||||
## All Components
|
||||
|
||||
<ComponentsList />
|
||||
|
||||
---
|
||||
|
||||
302
apps/v4/content/docs/components/radix/attachment.mdx
Normal file
302
apps/v4/content/docs/components/radix/attachment.mdx
Normal file
@@ -0,0 +1,302 @@
|
||||
---
|
||||
title: Attachment
|
||||
description: Displays a file or image attachment with media, metadata, upload state, and actions.
|
||||
base: radix
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="attachment-demo"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
The `Attachment` component displays a file or image attachment, its media, name, and metadata, with optional actions and upload state. Use it for files and images in chat composers, message threads, and upload lists.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add attachment
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Install the required shadcn/ui dependencies:</Step>
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
```
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="attachment"
|
||||
title="components/ui/attachment.tsx"
|
||||
styleName="radix-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/components/ui/attachment"
|
||||
```
|
||||
|
||||
```tsx
|
||||
<Attachment>
|
||||
<AttachmentMedia>
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build an attachment:
|
||||
|
||||
```text
|
||||
Attachment
|
||||
├── AttachmentMedia
|
||||
├── AttachmentContent
|
||||
│ ├── AttachmentTitle
|
||||
│ └── AttachmentDescription
|
||||
├── AttachmentActions
|
||||
│ └── AttachmentAction
|
||||
└── AttachmentTrigger
|
||||
```
|
||||
|
||||
Use `AttachmentGroup` to lay out multiple attachments in a scrollable row:
|
||||
|
||||
```text
|
||||
AttachmentGroup
|
||||
├── Attachment
|
||||
└── Attachment
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Icon and image media through `AttachmentMedia`
|
||||
- Upload states: `idle`, `uploading`, `processing`, `error`, and `done` with built-in styling and a shimmer while in progress
|
||||
- Three sizes and horizontal or vertical orientation
|
||||
- A full-card `AttachmentTrigger` that opens a link or dialog while the actions stay independently clickable
|
||||
- Scrollable, snapping `AttachmentGroup` with an edge fade
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Image
|
||||
|
||||
Set `variant="image"` on `AttachmentMedia` and render an `<img>` inside it. Use `orientation="vertical"` to stack the media above the content.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="attachment-image"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### States
|
||||
|
||||
Set `state` to reflect the upload lifecycle. `uploading` and `processing` shimmer the title, and `error` switches to a destructive treatment.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="attachment-states"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### Sizes
|
||||
|
||||
Use `size` to switch between `default`, `sm`, and `xs`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="attachment-sizes"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### Group
|
||||
|
||||
Wrap attachments in `AttachmentGroup` to lay them out in a horizontally scrollable, snapping row with an edge fade.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="attachment-group"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
### Trigger
|
||||
|
||||
Add an `AttachmentTrigger` to make the whole card open a link or dialog. It fills the card behind the actions, so the actions stay clickable.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="attachment-trigger"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background"
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Dialog>
|
||||
<Attachment>
|
||||
{/* media, content, actions */}
|
||||
<DialogTrigger asChild>
|
||||
<AttachmentTrigger aria-label="Preview research-summary.pdf" />
|
||||
</DialogTrigger>
|
||||
</Attachment>
|
||||
<DialogContent>{/* ... */}</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
`AttachmentAction` renders a `Button`, and `AttachmentTrigger` renders a real `<button>` (or your element via `asChild`). Follow the guidance below so both are operable and announced.
|
||||
|
||||
### Label icon-only actions
|
||||
|
||||
`AttachmentAction` is usually icon-only, so give each one an `aria-label` describing the action and its target.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<AttachmentAction aria-label="Remove sales-dashboard.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
```
|
||||
|
||||
### Label the trigger
|
||||
|
||||
`AttachmentTrigger` covers the card with no text of its own, so give it an `aria-label` for what activating it does.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open workspace.png"
|
||||
/>
|
||||
</AttachmentTrigger>
|
||||
```
|
||||
|
||||
The trigger sits behind the actions in the stacking order, so an `AttachmentAction` and the `AttachmentTrigger` never trap each other — both remain separately focusable and clickable.
|
||||
|
||||
### Keyboard scrolling
|
||||
|
||||
An `AttachmentGroup` scrolls horizontally. When its attachments are interactive: a trigger or actions, keyboard users reach off-screen items by tabbing to them. For a row of presentational attachments, make the group itself focusable and scrollable by adding `tabIndex={0}`, `role="group"`, and an `aria-label`.
|
||||
|
||||
### Meaning beyond color
|
||||
|
||||
The `error` state uses a destructive color. Keep the failure reason in `AttachmentDescription` so the state is not conveyed by color alone.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Attachment
|
||||
|
||||
The root attachment container.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | ------------------------------------------------------------ | -------------- | ------------------------------------------------- |
|
||||
| `state` | `"idle" \| "uploading" \| "processing" \| "error" \| "done"` | `"done"` | The upload state. Drives styling and the shimmer. |
|
||||
| `size` | `"default" \| "sm" \| "xs"` | `"default"` | The attachment size. |
|
||||
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Lay the media beside or above the content. |
|
||||
| `className` | `string` | - | Additional classes to apply to the root element. |
|
||||
|
||||
### AttachmentMedia
|
||||
|
||||
The media slot for an icon or image preview.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------- | -------- | ---------------------------------------------- |
|
||||
| `variant` | `"icon" \| "image"` | `"icon"` | Whether the media holds an icon or an `<img>`. |
|
||||
| `className` | `string` | - | Additional classes to apply to the media slot. |
|
||||
|
||||
### AttachmentContent
|
||||
|
||||
Wraps the title and description.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the content slot. |
|
||||
|
||||
### AttachmentTitle
|
||||
|
||||
The attachment name. Shimmers while the attachment is `uploading` or `processing`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the title. |
|
||||
|
||||
### AttachmentDescription
|
||||
|
||||
Secondary metadata such as the file type, size, or upload status.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the description. |
|
||||
|
||||
### AttachmentActions
|
||||
|
||||
A container for one or more actions, aligned to the end of the attachment.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the actions. |
|
||||
|
||||
### AttachmentAction
|
||||
|
||||
An action button. Renders a [`Button`](/docs/components/button) and accepts all of its props.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | ------------------------------------- | ----------- | ---------------------------------------- |
|
||||
| `size` | `Button["size"]` | `"icon-xs"` | The button size. |
|
||||
| `...props` | `React.ComponentProps<typeof Button>` | - | Props spread to the underlying `Button`. |
|
||||
|
||||
### AttachmentTrigger
|
||||
|
||||
A full-card overlay that activates the attachment. Renders a `<button>` by default.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | -------------------------------- | ------- | -------------------------------------------- |
|
||||
| `asChild` | `boolean` | `false` | Render as the child element, such as a link. |
|
||||
| `...props` | `React.ComponentProps<"button">` | - | Props spread to the trigger element. |
|
||||
|
||||
### AttachmentGroup
|
||||
|
||||
Lays out attachments in a horizontally scrollable, snapping row.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the group. |
|
||||
304
apps/v4/content/docs/components/radix/bubble.mdx
Normal file
304
apps/v4/content/docs/components/radix/bubble.mdx
Normal file
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: Bubble
|
||||
description: Displays conversational content in a message bubble. Supports variants, alignment, grouping, reactions, and collapsible content.
|
||||
base: radix
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
The `Bubble` component displays framed conversational content. Use it for chat text, short structured output, quoted replies, suggestions, and reactions.
|
||||
|
||||
For full-featured chat interfaces, use the [`Message`](/docs/components/message) component. `Bubble` is intentionally scoped to the bubble surface. Place avatars, names, timestamps, metadata, and message-level actions in [`Message`](/docs/components/message).
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add bubble
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="bubble"
|
||||
title="components/ui/bubble.tsx"
|
||||
styleName="radix-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Bubble>
|
||||
<BubbleContent>
|
||||
I checked the registry output and removed the stale route.
|
||||
</BubbleContent>
|
||||
<BubbleReactions>
|
||||
<span>👍</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build a bubble:
|
||||
|
||||
```text
|
||||
Bubble
|
||||
├── BubbleContent
|
||||
└── BubbleReactions
|
||||
```
|
||||
|
||||
Use `BubbleGroup` to group consecutive bubbles from the same sender:
|
||||
|
||||
```text
|
||||
BubbleGroup
|
||||
├── Bubble
|
||||
│ └── BubbleContent
|
||||
└── Bubble
|
||||
└── BubbleContent
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Seven visual variants, from a strong primary bubble to unframed ghost content
|
||||
- Start and end alignment for sender and receiver bubbles
|
||||
- Reactions that anchor to the bubble edge with configurable side and alignment
|
||||
- Bubbles size to their content, up to 80% of the container width
|
||||
- Polymorphic content via `asChild` for link and button bubbles
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Variants
|
||||
|
||||
Use `variant` to change the visual treatment of the bubble.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-variants"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| Variant | Description |
|
||||
| ------------- | ------------------------------------------------------ |
|
||||
| `default` | A strong primary bubble, usually for the current user. |
|
||||
| `secondary` | The standard neutral bubble for conversation content. |
|
||||
| `muted` | A lower-emphasis bubble for quiet supporting content. |
|
||||
| `tinted` | A subtle primary-tinted bubble. |
|
||||
| `outline` | A bordered bubble for secondary or rich content. |
|
||||
| `ghost` | Unframed content for assistant text or rich content. |
|
||||
| `destructive` | A destructive bubble for error or failed actions. |
|
||||
|
||||
A bubble sizes to its content, up to 80% of the container width. The `ghost` variant removes the max-width so assistant text and rich content can span the full row.
|
||||
|
||||
### Alignment
|
||||
|
||||
Use `align` on `Bubble` to align the bubble to the start or end of the conversation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-alignment"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| align | Description |
|
||||
| ------- | -------------------------------------------------- |
|
||||
| `start` | Align the bubble to the start of the conversation. |
|
||||
| `end` | Align the bubble to the end of the conversation. |
|
||||
|
||||
**Note:** When building chat interfaces, you probably want to use alignment on the `Message` component itself, not the `Bubble` component. You can use the `role` prop on the `Message` component to automatically align the bubble to the start or end of the conversation.
|
||||
|
||||
### Bubble Group
|
||||
|
||||
Use `BubbleGroup` to group consecutive bubbles from the same sender. Note the `align` prop should be set on the `Bubble` component itself, not the `BubbleGroup` component.
|
||||
|
||||
```text
|
||||
BubbleGroup
|
||||
├── Bubble
|
||||
│ └── BubbleContent
|
||||
└── Bubble
|
||||
└── BubbleContent
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-group-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Links and Buttons
|
||||
|
||||
You can turn a bubble into a link or button by using the `asChild` prop on `BubbleContent`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-link-button"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Bubble, BubbleContent } from "@/components/ui/bubble"
|
||||
|
||||
export function BubbleLinkDemo() {
|
||||
return (
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent asChild>
|
||||
<button>Click here</button>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Reactions
|
||||
|
||||
Use `BubbleReactions` for bubble reactions. You can use it to display reactions or quick action buttons. Use `side` and `align` to position the row — `side="top"` anchors it to the upper edge. Reactions overlap the bubble edge, so leave vertical space between rows — the examples below use a larger `gap` for this reason.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-reactions"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Show More / Collapsible
|
||||
|
||||
Long bubble content can be composed with [`Collapsible`](/docs/components/collapsible) to allow for a show more or show less interaction. Use the `CollapsibleTrigger` component to trigger the collapsible content.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-collapsible"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Tooltip
|
||||
|
||||
Wrap a bubble in a [`Tooltip`](/docs/components/tooltip) to reveal metadata on hover, such as when a message was read.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-tooltip"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Popover
|
||||
|
||||
Pair a bubble with a [`Popover`](/docs/components/popover) to surface more information on demand, such as the full error message for a failed action.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="bubble-popover"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
## Accessibility
|
||||
|
||||
`Bubble` renders the presentational message surface. Keep conversation-level semantics on the surrounding container and follow the guidelines below.
|
||||
|
||||
### Labeling Reactions
|
||||
|
||||
Reactions render as a row of emoji. A screen reader reads each glyph with no context, and counters like `+8` are announced as "plus eight". Group the row as a single image with a descriptive `aria-label` so it announces once. `role="img"` also hides the individual emoji from assistive tech, so no `aria-hidden` is needed.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<BubbleReactions role="img" aria-label="Reactions: thumbs up, fire, and 8 more">
|
||||
<span>👍</span>
|
||||
<span>🔥</span>
|
||||
<span>+8</span>
|
||||
</BubbleReactions>
|
||||
```
|
||||
|
||||
When reactions are interactive, render buttons instead and give icon-only buttons an `aria-label`.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<BubbleReactions>
|
||||
<Button aria-label="Thumbs up" variant="secondary" size="icon-xs">
|
||||
<ThumbsUpIcon />
|
||||
</Button>
|
||||
</BubbleReactions>
|
||||
```
|
||||
|
||||
### Interactive Bubbles
|
||||
|
||||
When a bubble is clickable, render it as a real `<button>` or `<a>` with the `asChild` prop so it is focusable and exposes the correct role. `BubbleContent` ships a visible focus ring for interactive elements, and the accessible name comes from the bubble text. No extra label is needed.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Bubble variant="muted" align="end">
|
||||
<BubbleContent asChild>
|
||||
<button type="button" onClick={onReply}>
|
||||
I forgot my password
|
||||
</button>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
```
|
||||
|
||||
### Meaning Beyond Color
|
||||
|
||||
Bubble variants signal role and tone with color. Pair them with text, alignment, or icons so meaning is not conveyed by color alone. For a `destructive` bubble, keep the error context in the message text rather than relying on the color treatment.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Bubble
|
||||
|
||||
The root bubble wrapper.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------------------------------------------------------------------------------ | ----------- | ------------------------------------------------ |
|
||||
| `variant` | `"default" \| "secondary" \| "muted" \| "tinted" \| "outline" \| "ghost" \| "destructive"` | `"default"` | The bubble visual treatment. |
|
||||
| `align` | `"start" \| "end"` | `"start"` | The inline alignment of the bubble. |
|
||||
| `className` | `string` | - | Additional classes to apply to the root element. |
|
||||
|
||||
### BubbleContent
|
||||
|
||||
The bubble content wrapper.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | --------- | ------- | --------------------------------------------------- |
|
||||
| `asChild` | `boolean` | `false` | Render the content as the child element. |
|
||||
| `className` | `string` | - | Additional classes to apply to the content element. |
|
||||
|
||||
### BubbleReactions
|
||||
|
||||
Displays overlapped reactions for a bubble.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------- | ---------- | ------------------------------------------------ |
|
||||
| `side` | `"top" \| "bottom"` | `"bottom"` | The side of the bubble to anchor the reactions. |
|
||||
| `align` | `"start" \| "end"` | `"end"` | The inline alignment of the reactions. |
|
||||
| `className` | `string` | - | Additional classes to apply to the reaction row. |
|
||||
|
||||
### BubbleGroup
|
||||
|
||||
Groups consecutive bubbles from the same sender.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ---------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the group root. |
|
||||
277
apps/v4/content/docs/components/radix/marker.mdx
Normal file
277
apps/v4/content/docs/components/radix/marker.mdx
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: Marker
|
||||
description: Displays an inline status, system note, bordered row, or labeled separator in a conversation.
|
||||
base: radix
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
The `Marker` component displays inline conversation markers such as status updates, system notes, bordered rows, and labeled separators. Compose it with [`Message`](/docs/components/message) in a conversation thread.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add marker
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="marker"
|
||||
title="components/ui/marker.tsx"
|
||||
styleName="radix-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/components/ui/marker"
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker>
|
||||
<MarkerIcon>
|
||||
<CheckIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Explored 4 files</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build a marker:
|
||||
|
||||
```text
|
||||
Marker
|
||||
├── MarkerIcon
|
||||
└── MarkerContent
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Inline marker, bordered row, and labeled separator variants
|
||||
- Decorative icon slot that is hidden from assistive tech
|
||||
- Polymorphic root via `asChild` for link and button markers
|
||||
- Pairs with the [`shimmer`](/docs/utils/shimmer) utility for streaming status text
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Variants
|
||||
|
||||
Use `variant` to switch between an inline marker, bordered row, and labeled separator.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-variants"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| Variant | Description |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| `default` | An inline marker for status, notes, and actions. |
|
||||
| `border` | A default marker with a bottom border under the row. |
|
||||
| `separator` | A centered label with divider lines on each side. |
|
||||
|
||||
### Status
|
||||
|
||||
Set `role="status"` and include a [`Spinner`](/docs/components/spinner) for streaming or in-progress markers so updates are announced.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-status"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Shimmer
|
||||
|
||||
Add the [`shimmer`](/docs/utils/shimmer) utility class to `MarkerContent` for an animated streaming-text effect. The utility ships with the `shadcn` package — see the shimmer docs for installation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-shimmer"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Separator
|
||||
|
||||
Use the `separator` variant for labeled dividers, such as dates or section breaks, in a conversation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-separator"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Border
|
||||
|
||||
Use the `border` variant for status rows that should keep the default marker alignment while separating the next row.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-border"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### With Icon
|
||||
|
||||
Use `MarkerIcon` to render an icon alongside the content. Use `flex-col` to stack the icon above the content.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-icon"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Links and Buttons
|
||||
|
||||
Turn a marker into a link or button with the `asChild` prop on `Marker`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="marker-link-button"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Marker, MarkerContent } from "@/components/ui/marker"
|
||||
|
||||
export function MarkerLinkDemo() {
|
||||
return (
|
||||
<Marker asChild>
|
||||
<a href="#">
|
||||
<MarkerContent>View the pull request</MarkerContent>
|
||||
</a>
|
||||
</Marker>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
`Marker` is presentational by default. The correct semantics depend on how you use it, so choose the role based on intent rather than relying on a single default.
|
||||
|
||||
### Status and Progress
|
||||
|
||||
For streaming or progress markers such as "Thinking..." or a running tool, set `role="status"` so assistive tech announces the update as it appears. `Marker` forwards `role` to the underlying element.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Compacting conversation</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
### Labeled Separators
|
||||
|
||||
A separator that carries text, such as a date or a section label, needs no role. The divider lines are decorative CSS pseudo-elements, and the text is announced as ordinary content.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Today</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
<Callout>
|
||||
**Note:** Do not add `role="separator"` to a labeled divider. A separator
|
||||
takes its accessible name from `aria-label`, not from its text, and its
|
||||
contents are treated as presentational, so the visible label would not be
|
||||
announced. Reserve `role="separator"` for a divider with no meaningful text.
|
||||
</Callout>
|
||||
|
||||
### Bordered Markers
|
||||
|
||||
A bordered marker keeps the same semantics as the default marker. The bottom border is decorative, so choose `role="status"`, `asChild`, or no role based on the marker's purpose.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker variant="border">
|
||||
<MarkerIcon>
|
||||
<FileTextIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Opened implementation notes</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
### Decorative Icons
|
||||
|
||||
`MarkerIcon` is decorative and hidden from assistive tech with `aria-hidden`, so the adjacent `MarkerContent` carries the meaning. For an icon-only marker, provide an `aria-label` or visible text so it is not announced as empty.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker aria-label="Synced">
|
||||
<MarkerIcon>
|
||||
<CheckIcon />
|
||||
</MarkerIcon>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
### Interactive Markers
|
||||
|
||||
When a marker links or triggers an action, render it as a real `<button>` or `<a>` with the `asChild` prop so it is focusable and exposes the correct role. The accessible name comes from the marker text.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Marker asChild>
|
||||
<a href="/files">
|
||||
<MarkerIcon>
|
||||
<FileTextIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Explored 4 files</MarkerContent>
|
||||
</a>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Marker
|
||||
|
||||
The root marker element. The file also exports `markerVariants` for composing the marker styles into custom components.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------------------------------------- | ----------- | ------------------------------------------------ |
|
||||
| `variant` | `"default" \| "border" \| "separator"` | `"default"` | The marker layout. |
|
||||
| `asChild` | `boolean` | `false` | Render as the child element, such as a link. |
|
||||
| `className` | `string` | - | Additional classes to apply to the root element. |
|
||||
|
||||
### MarkerIcon
|
||||
|
||||
A decorative icon slot. Hidden from assistive tech with `aria-hidden`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | --------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the icon slot. |
|
||||
|
||||
### MarkerContent
|
||||
|
||||
The marker text content.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the content slot. |
|
||||
586
apps/v4/content/docs/components/radix/message-scroller.mdx
Normal file
586
apps/v4/content/docs/components/radix/message-scroller.mdx
Normal file
@@ -0,0 +1,586 @@
|
||||
---
|
||||
title: Message Scroller
|
||||
description: A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.
|
||||
base: radix
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-demo"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
## What Makes a Great Streaming Chat Experience
|
||||
|
||||
Building a chat interface used to be simple. You create an inverted list with
|
||||
an input. Type a message, it appends at the bottom. When a reply comes in, the
|
||||
list grows and scrolls. Done.
|
||||
|
||||
Streaming breaks that model. Messages arrive in chunks while you may still be
|
||||
reading, scrolling, or looking somewhere else entirely.
|
||||
|
||||
Now the challenge is preserving the reader's place while the conversation keeps
|
||||
changing. Get that wrong and the experience feels jumpy: people are pulled to
|
||||
the bottom, lose context, and have to find their way back.
|
||||
|
||||
In practice, this comes down to scroll: when to follow, when to hold, and when
|
||||
to let the reader decide. A great streaming chat should:
|
||||
|
||||
1. **Move only when the reader asked to move.** If someone is reading, don’t pull them somewhere else. Auto-scroll should never be the default.
|
||||
2. **Follow only while they’re following.** If they’re at the live edge, keep the stream in view. If they scroll away, leave them there.
|
||||
3. **Every interaction is a signal.** Scrolling is not the only one. Selecting text, using the keyboard, opening a link, or searching should all stop the interface from moving.
|
||||
4. **Start a new turn near the top of the viewport.** This gives the new turn somewhere it can be read from the beginning.
|
||||
5. **Then stream in the answer.** The answer should grow into the screen, not immediately push everything away.
|
||||
6. **Keep part of the previous conversation in context.** The prompt and reply should stay visually connected, and enough of the previous turn should remain visible so the reader knows where they are.
|
||||
7. **Let new content arrive offscreen.** The conversation can keep streaming without changing what the reader is looking at.
|
||||
8. **Show what’s happening out of view.** Make it clear when a response is still streaming or when new messages have arrived.
|
||||
9. **Make it easy to return to the latest reply.** A “Jump to latest” action should bring the reader back and resume following.
|
||||
10. **Let people jump anywhere in the conversation.** Long threads need message links, search, unread markers, and direct navigation.
|
||||
11. **Reopen where the reader left off.** A saved conversation should open at the last meaningful turn. Often this is the last user message. Not the absolute bottom.
|
||||
12. **Keep the reader’s place when layout changes.** Images load. Markdown expands. Code blocks render. Older messages appear above. None of that should make the reader lose their place.
|
||||
13. **Handle interruptions without stealing position.** Stopping, retrying, regenerating, branching, or errors should not unexpectedly move the conversation.
|
||||
14. **Stay responsive in long threads.** Streaming text, markdown, code, images, and long history should still feel responsive.
|
||||
15. **Be accessible without the noise.** Keep the transcript navigable, preserve keyboard focus, and announce important events at a comfortable pace.
|
||||
|
||||
**Never move the reader against their intent.**
|
||||
|
||||
## MessageScroller
|
||||
|
||||
MessageScroller is a chat transcript scroller built for these behaviors.
|
||||
`MessageScrollerProvider` owns the scroll state and transcript-row behavior:
|
||||
opening position, streamed output, new-turn anchoring, prepended history,
|
||||
visibility, and scroll controls. `MessageScroller` is the styled frame that
|
||||
renders inside it.
|
||||
|
||||
MessageScroller is scoped to the scroll viewport. It does not own messages, AI state,
|
||||
transport, persistence, branching, or model state. Your product code stays
|
||||
focused on composing messages, markers, tools, attachments, and prompt inputs.
|
||||
|
||||
It gives you the scroll behavior that chat needs, without taking over the rest
|
||||
of the chat UI. And it stays fast, even in long conversations with rich
|
||||
markdown.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add message-scroller
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Install the following dependencies:</Step>
|
||||
|
||||
```bash
|
||||
npm install @shadcn/react
|
||||
```
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="message-scroller"
|
||||
title="components/ui/message-scroller.tsx"
|
||||
styleName="radix-nova"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Message } from "@/components/ui/message"
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent>
|
||||
{messages.map((message) => (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
<Message />
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
`MessageScroller` fills its parent, so place it inside a height-constrained
|
||||
container.
|
||||
|
||||
```tsx
|
||||
<div className="flex h-screen flex-col">
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller className="flex-1">{/* transcript */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Composition
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent>
|
||||
<MessageScrollerItem>
|
||||
{/* a message, marker, or row */}
|
||||
</MessageScrollerItem>
|
||||
<MessageScrollerItem />
|
||||
<MessageScrollerItem />
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
- **`MessageScrollerProvider`** — the headless root. Owns scroll state and the
|
||||
behavior props for opening position, auto-scroll, anchoring, scroll commands,
|
||||
and visibility tracking.
|
||||
- **`MessageScroller`** — the styled frame. Lays out the viewport, content, and
|
||||
controls inside the provider.
|
||||
- **`MessageScrollerViewport`** — the scrollable element. Receives native scroll
|
||||
events and preserves the visible row when older messages are prepended.
|
||||
- **`MessageScrollerContent`** — the transcript container. Holds the rows and
|
||||
provides the live-region defaults for new messages.
|
||||
- **`MessageScrollerItem`** — the transcript row boundary. Wrap every direct
|
||||
child of the content so the scroller can measure, anchor, preserve position,
|
||||
track visibility, and jump to it. An item can be a message, marker, typing
|
||||
indicator, separator, join/leave event, or "load earlier" row.
|
||||
- **`MessageScrollerButton`** — the scroll control. Scrolls to the start or end of the transcript and is inert until there is content in its direction.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Anchoring Turns
|
||||
|
||||
A turn is the part of the conversation that starts a new exchange. In a simple
|
||||
AI chat, that is usually the user's message and the assistant reply that follows.
|
||||
|
||||
An anchor is the row the viewport should treat as the start of that turn. Mark
|
||||
that row with `scrollAnchor`. When a new anchor is appended, the viewport moves
|
||||
it near the top and keeps a peek of the previous item above it, so the new turn
|
||||
does not feel detached from its context.
|
||||
|
||||
```tsx
|
||||
// This tells the scroller to anchor the user's message for the next turn.
|
||||
<MessageScrollerItem
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
/>
|
||||
```
|
||||
|
||||
Scroll anchors are not tied to message role. You can turn any row into an anchor:
|
||||
a user message, a system marker, a handoff event, or anything else that starts a
|
||||
meaningful turn. `MessageScroller` only needs to know which row should anchor the
|
||||
viewport.
|
||||
|
||||
In the following example, the user's message is anchored. When you send a new message, the viewport anchors it near the top and appends the assistant reply below it. Toggle the anchor to the assistant's message to see the difference.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-anchoring"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
### Group Chat
|
||||
|
||||
In a group chat, the turn boundary is more specific than "the user message". It is often
|
||||
the message that asks the model to respond, or a marker like "Marcus joined the
|
||||
chat". Typing indicators and history controls usually should not anchor.
|
||||
|
||||
Because anchoring is role-independent, you can anchor a marker just as easily as
|
||||
a message.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerItem messageId="marcus-joined" scrollAnchor>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Marcus joined the chat</MarkerContent>
|
||||
</Marker>
|
||||
</MessageScrollerItem>
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-group-chat"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
### Keeping Context Visible
|
||||
|
||||
When a new turn starts, it should still feel like part of the same continuous
|
||||
thread. `scrollPreviousItemPeek` keeps a slice of the previous item visible
|
||||
above the anchor, so the reader keeps their context instead of feeling like the
|
||||
conversation restarted on a blank page.
|
||||
|
||||
```tsx
|
||||
// Keep 64px of the previous turn visible above the newly anchored row.
|
||||
<MessageScrollerProvider scrollPreviousItemPeek={64}>
|
||||
<MessageScroller>{/* anchored turns */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
Adjust the peek amount in the example below to see how it affects the conversation.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-previous-context"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
### Following the Live Edge
|
||||
|
||||
When the reader is at the live edge, either because they stayed there or
|
||||
returned there, `autoScroll` keeps streamed replies in view as they grow.
|
||||
Scrolling away from the live edge releases the view, whether by wheel, touch,
|
||||
keyboard scroll keys, or dragging the scrollbar. An explicit message jump
|
||||
releases it too. New chunks can then arrive without moving the reader.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller>{/* streamed turns */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-streaming"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
Calling `scrollToEnd`, or pressing `MessageScrollerButton`, re-engages
|
||||
follow-output when `autoScroll` is enabled, so a reader who scrolled away can
|
||||
return to the live edge and keep following. The root and viewport expose
|
||||
`data-autoscrolling` while that programmatic scroll to the latest message runs,
|
||||
so you can conditionally apply styles during the transition.
|
||||
|
||||
### Opening Saved Threads
|
||||
|
||||
It can seem reasonable to reopen a saved thread at the absolute end of the
|
||||
transcript, but that often drops the reader into the conversation without enough
|
||||
context. A better default is `"last-anchor"`: show the last meaningful turn,
|
||||
like the user's latest message, with the reply below it.
|
||||
|
||||
That gives the reader an immediate place in the thread. They can see what they
|
||||
asked, where the answer starts, and continue from there without reconstructing
|
||||
the conversation from the bottom edge.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider defaultScrollPosition="last-anchor">
|
||||
<MessageScroller>{/* transcript */}</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-opening-position"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
`"last-anchor"` is keyed on `scrollAnchor`, not message role. If no anchor
|
||||
exists, or the last anchored turn already fits in the viewport, it falls back to
|
||||
`"end"`.
|
||||
|
||||
Use `"start"` when you want to resume at the beginning of a conversation, or
|
||||
`"end"` when the absolute latest message is the right place to land.
|
||||
|
||||
### Loading Earlier Messages
|
||||
|
||||
Loading earlier messages should not move the conversation the reader is already
|
||||
looking at. When older rows are prepended above the current transcript,
|
||||
`MessageScrollerViewport` preserves the visible row so the reader stays in the
|
||||
same place while history loads above them.
|
||||
|
||||
This is enabled by default through `preserveScrollOnPrepend`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-load-history"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
Use stable `messageId` values for message rows. That gives the scroller a
|
||||
specific row to preserve instead of guessing from whichever pixel happens to sit
|
||||
at the viewport edge.
|
||||
|
||||
### Animating New Messages
|
||||
|
||||
`MessageScrollerItem` can be animated directly. Create a motion version of the
|
||||
item, keep `messageId` and `scrollAnchor` on it, and use transform and opacity
|
||||
for the entrance.
|
||||
|
||||
A common chat pattern is to animate the user's message when it is sent, then let
|
||||
the assistant reply stream into a regular row below it. Start the user row below
|
||||
its final position so it feels like it rises from the live edge of the viewport.
|
||||
|
||||
```tsx
|
||||
const MotionMessageScrollerItem = motion.create(MessageScrollerItem)
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-animation"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
Avoid animating height, margin, or padding for row entrances; those changes can
|
||||
fight the scroller's positioning work. If the reader prefers reduced motion,
|
||||
skip the entrance animation and keep the scroll behavior the same.
|
||||
|
||||
### Jumping to Messages
|
||||
|
||||
Search results, permalinks, outline items, and toolbar buttons often need to
|
||||
drive the transcript from outside the message list. Use `useMessageScroller` for
|
||||
those controls. Because the hooks read from `MessageScrollerProvider`, they work
|
||||
in any component inside the provider, including controls rendered outside the
|
||||
`MessageScroller` frame.
|
||||
|
||||
```tsx
|
||||
import { useMessageScroller } from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
const { scrollToMessage, scrollToEnd, scrollToStart } = useMessageScroller()
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-commands"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
`scrollToMessage` targets the `messageId` on `MessageScrollerItem`, so rows that
|
||||
need to be addressable should have stable ids. `scrollToMessage` returns `false`
|
||||
when the target is not mounted and cannot be queued.
|
||||
|
||||
`scrollToMessage` can queue a target before items exist, which covers
|
||||
client-resolved permalinks while the transcript mounts. After rows have mounted,
|
||||
a missing id returns `false` instead of starting a guessed retry loop. A `true`
|
||||
result means the scroll ran or was queued, not that the row is already in view.
|
||||
|
||||
### Tracking the Reader's Position
|
||||
|
||||
Use `useMessageScrollerVisibility` to track the reader's position in the
|
||||
conversation. A common example is a table-of-contents or a jump menu that
|
||||
highlights the current anchored turn.
|
||||
|
||||
```tsx
|
||||
import { useMessageScrollerVisibility } from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
const { currentAnchorId, visibleMessageIds } = useMessageScrollerVisibility()
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-visibility"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
hideCode
|
||||
/>
|
||||
|
||||
`currentAnchorId` answers "where am I" by reporting the current anchored turn,
|
||||
and it stays set after that anchor scrolls above the viewport. `visibleMessageIds`
|
||||
answers "what is on screen", in document order.
|
||||
|
||||
Visibility is pay-for-what-you-use. Tracking only runs while something
|
||||
subscribes to `useMessageScrollerVisibility`, and rows need a `messageId` to
|
||||
participate.
|
||||
|
||||
### Reading Scroll State
|
||||
|
||||
Use `useMessageScrollerScrollable` when you need scroll state in JavaScript, such
|
||||
as a status indicator or a custom "jump to latest" control. It reports which
|
||||
edges the viewport can still scroll toward; "at the start/end" is the negation
|
||||
(`!start` / `!end`), and "scrollable at all" is `start || end`. For styling the
|
||||
scroller itself, prefer the `data-scrollable` attribute.
|
||||
|
||||
```tsx
|
||||
import { useMessageScrollerScrollable } from "@/components/ui/message-scroller"
|
||||
```
|
||||
|
||||
```tsx
|
||||
const { start, end } = useMessageScrollerScrollable()
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-scroller-scrollable"
|
||||
className="rounded-[34px] sm:rounded-4xl"
|
||||
previewClassName="h-auto theme-blue bg-surface dark:bg-background p-4 min-[480px]:p-8 min-[560px]:p-10 sm:px-10 sm:py-16"
|
||||
/>
|
||||
|
||||
## Performance
|
||||
|
||||
`MessageScroller` is benchmarked against large transcripts with markdown and
|
||||
composed message rows.
|
||||
|
||||
Our performance goal for `MessageScroller` is to keep the scroll hot path outside of React state: no React rerenders for
|
||||
transcript rows, no forced layout on every scroll, and as little off-screen paint
|
||||
work as the browser can avoid.
|
||||
|
||||
Scroll position, anchoring, and follow-output are tracked imperatively and mirrored onto the root and viewport through `data-*` attributes, so scrolling and streaming do not rerender transcript rows.
|
||||
|
||||
The styled `MessageScrollerItem` also ships with `content-visibility: auto` and
|
||||
`contain-intrinsic-size`. Rows stay in the DOM for selection, copy,
|
||||
find-in-page, SSR, and assistive tech, but the browser can skip rendering work
|
||||
for rows far outside the viewport.
|
||||
|
||||
Visibility tracking is pay-for-what-you-use. A jump menu or active
|
||||
turn indicator costs nothing until something subscribes to
|
||||
`useMessageScrollerVisibility`.
|
||||
|
||||
This is comfortable for the expected range of a chat transcript: hundreds to low
|
||||
thousands of turns, including messages with markdown and composed components.
|
||||
|
||||
## Virtualization
|
||||
|
||||
Virtualization is intentionally left outside the primitive. `MessageScroller`
|
||||
renders real DOM rows and stays fast well into the thousands of turns (see
|
||||
[Performance](#performance)), so most transcripts never need it.
|
||||
|
||||
When a transcript is large enough to need virtualization, use
|
||||
`MessageScrollerViewport` as the scroll element and let the virtualizer own the
|
||||
rows.
|
||||
|
||||
```tsx showLineNumbers
|
||||
import * as React from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
|
||||
function VirtualizedTranscript({
|
||||
messages,
|
||||
}: {
|
||||
messages: Array<{ id: string; content: React.ReactNode }>
|
||||
}) {
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: messages.length,
|
||||
getScrollElement: () => viewportRef.current,
|
||||
estimateSize: () => 86,
|
||||
getItemKey: (index) => messages[index]?.id ?? index,
|
||||
overscan: 8,
|
||||
})
|
||||
|
||||
return (
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport ref={viewportRef}>
|
||||
<MessageScrollerContent className="block min-h-full">
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const message = messages[virtualItem.index]
|
||||
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualItem.index}
|
||||
className="absolute start-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Message>{message.content}</Message>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
`MessageScroller` keeps the scroll container keyboard reachable and the
|
||||
transcript announceable without forcing a specific message UI.
|
||||
|
||||
`MessageScrollerViewport` is a labelled, keyboard-focusable scroll region by
|
||||
default. It uses `role="region"`, `aria-label="Messages"`, and `tabIndex={0}`,
|
||||
so keyboard users can focus the transcript and scroll it directly.
|
||||
|
||||
`MessageScrollerContent` marks the transcript as a live region with
|
||||
`role="log"` and `aria-relevant="additions"`. New rows can be announced, but
|
||||
streamed text mutations do not have to be announced token by token.
|
||||
|
||||
```tsx
|
||||
<MessageScrollerContent aria-busy={status === "streaming"}>
|
||||
{/* messages */}
|
||||
</MessageScrollerContent>
|
||||
```
|
||||
|
||||
Pass `aria-busy` while a turn streams if announcements should wait for the
|
||||
completed message row.
|
||||
|
||||
`MessageScrollerButton` renders a real button. When there is nothing to scroll
|
||||
toward, it sets `inert`, uses `tabIndex={-1}`, and exposes `data-active="false"`
|
||||
so inactive scroll controls do not create extra focus stops.
|
||||
|
||||
## Unstyled
|
||||
|
||||
The behavior in `MessageScroller` comes from the `@shadcn/react` package. To use
|
||||
it directly with your own markup and styles, see
|
||||
[Message Scroller](/docs/react/message-scroller) under @shadcn/react.
|
||||
|
||||
## API Reference
|
||||
|
||||
The props, data attributes, and hooks for every part are documented on the
|
||||
[@shadcn/react Message Scroller](/docs/react/message-scroller#api-reference) page.
|
||||
They are identical for the styled component and the unstyled parts.
|
||||
248
apps/v4/content/docs/components/radix/message.mdx
Normal file
248
apps/v4/content/docs/components/radix/message.mdx
Normal file
@@ -0,0 +1,248 @@
|
||||
---
|
||||
title: Message
|
||||
description: Displays a message in a conversation, with optional avatar, header, footer, and alignment.
|
||||
base: radix
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-demo"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
The `Message` component lays out a single message in a conversation. It handles the avatar, alignment, header, and footer around the message surface.
|
||||
|
||||
For AI apps, you can render reasoning steps, tool calls and assistant messages using the `Message` component.
|
||||
|
||||
## Installation
|
||||
|
||||
<CodeTabs>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">Command</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add message
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps className="mb-0 pt-2">
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource
|
||||
name="message"
|
||||
title="components/ui/message.tsx"
|
||||
styleName="radix-rhea"
|
||||
/>
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</CodeTabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx showLineNumbers
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Bubble, BubbleContent } from "@/components/ui/bubble"
|
||||
import { Message, MessageAvatar, MessageContent } from "@/components/ui/message"
|
||||
```
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Message>
|
||||
<MessageAvatar>
|
||||
<Avatar>
|
||||
<AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble>
|
||||
<BubbleContent>How can I help you today?</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
```
|
||||
|
||||
**Note:** `Message` owns the row layout—avatar, alignment, header, and footer.
|
||||
Render the visible message surface inside it with
|
||||
[`Bubble`](/docs/components/bubble). For the scroll container around a
|
||||
conversation, use [`MessageScroller`](/docs/components/message-scroller).
|
||||
|
||||
## Composition
|
||||
|
||||
Use the following composition to build a message:
|
||||
|
||||
```text
|
||||
Message
|
||||
├── MessageAvatar
|
||||
└── MessageContent
|
||||
├── MessageHeader
|
||||
├── Bubble
|
||||
└── MessageFooter
|
||||
```
|
||||
|
||||
Use `MessageGroup` to stack consecutive messages from the same sender:
|
||||
|
||||
```text
|
||||
MessageGroup
|
||||
├── Message
|
||||
└── Message
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Start and end alignment for sender and receiver rows via the `align` prop
|
||||
- Avatar slot that anchors to the bottom of the message and stays clear of the footer
|
||||
- Header and footer slots for sender names, status, and message actions
|
||||
- Footer follows the message side; actions stay aligned on `align="end"` rows
|
||||
- Group wrapper for stacking consecutive messages from the same sender
|
||||
- Customizable styling through the `className` prop on every part
|
||||
|
||||
## Examples
|
||||
|
||||
### Avatar
|
||||
|
||||
Use `MessageAvatar` to render an avatar next to the message. Set `align="end"` on the message to align the avatar to the end of the message.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-avatar"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
| align | Description |
|
||||
| ------- | --------------------------------------------------- |
|
||||
| `start` | Align the message to the start of the conversation. |
|
||||
| `end` | Align the message to the end of the conversation. |
|
||||
|
||||
### Group
|
||||
|
||||
Use `MessageGroup` to stack consecutive messages from the same sender. Render an empty `MessageAvatar` on the earlier messages to keep them aligned with the avatar on the last one.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-group"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Header and Footer
|
||||
|
||||
Use `MessageHeader` for a sender name and `MessageFooter` for metadata such as a delivery or read status.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-header-footer"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Actions
|
||||
|
||||
Place message-level actions in `MessageFooter`, such as copy, retry, or feedback buttons.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-actions"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
### Attachment
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="message-attachment"
|
||||
previewClassName="h-auto theme-blue"
|
||||
/>
|
||||
|
||||
## Accessibility
|
||||
|
||||
`Message` is a presentational layout wrapper. Accessibility comes from the content you place inside it.
|
||||
|
||||
### Label icon-only actions
|
||||
|
||||
Action buttons in `MessageFooter` are usually icon-only, so give each one an `aria-label`.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<MessageFooter>
|
||||
<Button variant="ghost" size="icon" aria-label="Copy">
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</MessageFooter>
|
||||
```
|
||||
|
||||
### Status updates
|
||||
|
||||
For in-progress messages, use a [`Marker`](/docs/components/marker) with `role="status"` so assistive tech announces the update as it appears.
|
||||
|
||||
```tsx showLineNumbers
|
||||
<Message>
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Checking the logs...</MarkerContent>
|
||||
</Marker>
|
||||
</Message>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Message
|
||||
|
||||
The message row wrapper.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | ------------------ | --------- | ------------------------------------------------- |
|
||||
| `align` | `"start" \| "end"` | `"start"` | The alignment of the message in the conversation. |
|
||||
| `className` | `string` | - | Additional classes to apply to the row. |
|
||||
|
||||
### MessageGroup
|
||||
|
||||
Groups consecutive messages from the same sender.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ---------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the group root. |
|
||||
|
||||
### MessageAvatar
|
||||
|
||||
The avatar slot, aligned to the bottom of the message. When the message has a `MessageFooter`, the avatar shifts up to stay aligned with the message surface instead of the footer.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ----------------------------------------------- |
|
||||
| `className` | `string` | - | Additional classes to apply to the avatar slot. |
|
||||
|
||||
### MessageContent
|
||||
|
||||
Wraps the header, message surface, and footer.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the content slot. |
|
||||
|
||||
### MessageHeader
|
||||
|
||||
Displays content above the message, such as a sender name. Stays aligned to the start regardless of `align`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the header. |
|
||||
|
||||
### MessageFooter
|
||||
|
||||
Displays content below the message, such as status or actions. Aligns to the message side.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | -------- | ------- | ------------------------------------------ |
|
||||
| `className` | `string` | - | Additional classes to apply to the footer. |
|
||||
@@ -5,9 +5,11 @@
|
||||
"alert",
|
||||
"alert-dialog",
|
||||
"aspect-ratio",
|
||||
"attachment",
|
||||
"avatar",
|
||||
"badge",
|
||||
"breadcrumb",
|
||||
"bubble",
|
||||
"button",
|
||||
"button-group",
|
||||
"calendar",
|
||||
@@ -35,7 +37,10 @@
|
||||
"item",
|
||||
"kbd",
|
||||
"label",
|
||||
"marker",
|
||||
"menubar",
|
||||
"message",
|
||||
"message-scroller",
|
||||
"native-select",
|
||||
"navigation-menu",
|
||||
"pagination",
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
"components",
|
||||
"(root)",
|
||||
"changelog",
|
||||
"react",
|
||||
"forms",
|
||||
"installation",
|
||||
"dark-mode",
|
||||
"rtl",
|
||||
"utils",
|
||||
"registry"
|
||||
]
|
||||
}
|
||||
|
||||
277
apps/v4/content/docs/react/message-scroller.mdx
Normal file
277
apps/v4/content/docs/react/message-scroller.mdx
Normal file
@@ -0,0 +1,277 @@
|
||||
---
|
||||
title: Message Scroller
|
||||
description: Use the MessageScroller behavior directly from the @shadcn/react package with your own markup and styles.
|
||||
---
|
||||
|
||||
`MessageScroller` ships as a headless primitive in the `@shadcn/react` package.
|
||||
The package owns all of the scroll behavior, anchoring turns, following streamed
|
||||
output, preserving the reader's place as history loads, and tracking visibility,
|
||||
and renders no styles of its own.
|
||||
|
||||
The `message-scroller.tsx` component in the registry is a thin wrapper that adds
|
||||
Tailwind classes on top. Use the package directly when you want full control over
|
||||
the markup and styles, or when you are not using the registry.
|
||||
|
||||
For the behavior guide and live examples, see the
|
||||
[Message Scroller](/docs/components/radix/message-scroller) component.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @shadcn/react
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {
|
||||
MessageScroller,
|
||||
useMessageScroller,
|
||||
} from "@shadcn/react/message-scroller"
|
||||
```
|
||||
|
||||
The package exports a namespace object instead of flat components. The parts and
|
||||
behavior are the same as the styled component, just unstyled.
|
||||
|
||||
```tsx
|
||||
<MessageScroller.Provider>
|
||||
<MessageScroller.Root>
|
||||
<MessageScroller.Viewport>
|
||||
<MessageScroller.Content>
|
||||
{messages.map((message) => (
|
||||
<MessageScroller.Item
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
{/* your message UI */}
|
||||
</MessageScroller.Item>
|
||||
))}
|
||||
</MessageScroller.Content>
|
||||
</MessageScroller.Viewport>
|
||||
<MessageScroller.Button />
|
||||
</MessageScroller.Root>
|
||||
</MessageScroller.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
If you are coming from the styled component, the flat parts map to the namespace
|
||||
object like this.
|
||||
|
||||
| Styled component | Unstyled part |
|
||||
| ------------------------- | -------------------------- |
|
||||
| `MessageScrollerProvider` | `MessageScroller.Provider` |
|
||||
| `MessageScroller` | `MessageScroller.Root` |
|
||||
| `MessageScrollerViewport` | `MessageScroller.Viewport` |
|
||||
| `MessageScrollerContent` | `MessageScroller.Content` |
|
||||
| `MessageScrollerItem` | `MessageScroller.Item` |
|
||||
| `MessageScrollerButton` | `MessageScroller.Button` |
|
||||
|
||||
The hooks are imported the same way and behave identically, since they read from
|
||||
`MessageScroller.Provider`.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
useMessageScroller,
|
||||
useMessageScrollerScrollable,
|
||||
useMessageScrollerVisibility,
|
||||
} from "@shadcn/react/message-scroller"
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
Here is a complete example that brings its own styles and wires the scroller to
|
||||
the AI SDK.
|
||||
|
||||
```tsx showLineNumbers
|
||||
"use client"
|
||||
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import { MessageScroller } from "@shadcn/react/message-scroller"
|
||||
import { DefaultChatTransport } from "ai"
|
||||
|
||||
import { ChatInput } from "@/components/chat-input"
|
||||
|
||||
export function Chat() {
|
||||
const { messages, sendMessage, status } = useChat({
|
||||
transport: new DefaultChatTransport({ api: "/api/chat" }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-svh w-full flex-col">
|
||||
<MessageScroller.Provider>
|
||||
<MessageScroller.Root className="relative flex flex-1 flex-col overflow-hidden">
|
||||
<MessageScroller.Viewport className="flex flex-1 flex-col overflow-y-auto">
|
||||
<MessageScroller.Content className="flex flex-col gap-4 p-6 text-base">
|
||||
{messages.map((message, index) => (
|
||||
<MessageScroller.Item
|
||||
key={message.id}
|
||||
messageId={`message-${index}`}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
{message.parts.map((part, i) =>
|
||||
part.type === "text" ? (
|
||||
<span key={i}>{part.text}</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
</MessageScroller.Item>
|
||||
))}
|
||||
</MessageScroller.Content>
|
||||
</MessageScroller.Viewport>
|
||||
<MessageScroller.Button className="absolute bottom-2 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background px-3 py-1 text-sm font-medium inert:opacity-0">
|
||||
Jump to latest
|
||||
</MessageScroller.Button>
|
||||
</MessageScroller.Root>
|
||||
</MessageScroller.Provider>
|
||||
<ChatInput onSend={sendMessage} disabled={status !== "ready"} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### MessageScroller.Provider
|
||||
|
||||
The headless root. It owns scroll state and the behavior props, and provides
|
||||
them to the parts and the hooks. It renders no DOM of its own.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------------ | ----------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `autoScroll` | `boolean` | `false` | Follow new content only while the reader is already at the live edge. Wheel, touch, keyboard scroll, and explicit jumps release it. |
|
||||
| `defaultScrollPosition` | `"start" \| "end" \| "last-anchor"` | `"end"` | Opening position on the first non-empty render, applied once. `"last-anchor"` opens at the last `scrollAnchor` row and falls back to `"end"` when the turn fits or no anchor exists. |
|
||||
| `scrollEdgeThreshold` | `number` | `8` | Distance from either edge that still counts as being at the start or end. Controls state attributes and scroll button visibility. |
|
||||
| `scrollMargin` | `number` | `0` | Margin applied to the aligned edge for `scrollToMessage`, visibility, and programmatic targets. |
|
||||
| `scrollPreviousItemPeek` | `number` | `64` | Extra margin added to `scrollMargin` when a newly appended `scrollAnchor` item is positioned so part of the previous item stays visible. |
|
||||
|
||||
### MessageScroller.Root
|
||||
|
||||
The frame and layout container. It fills its parent, so use it inside a
|
||||
height-constrained layout, within a `MessageScroller.Provider`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ---------- | ----------------------------- | ------- | ---------------------------------- |
|
||||
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the frame element. |
|
||||
|
||||
The root mirrors the scroll-state attributes below (the viewport carries them
|
||||
too), so you can style the container by scroll state, such as edge fades on the
|
||||
frame.
|
||||
|
||||
| Data attribute | Value | Description |
|
||||
| -------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `data-scrollable` | `"start"` \| `"end"` \| `"start end"` \| _absent_ | Edges the viewport can scroll toward. Query one with `[data-scrollable~="end"]`; absent means it fits. |
|
||||
| `data-autoscrolling` | present | Present while the viewport is programmatically scrolling to the latest message. |
|
||||
|
||||
### MessageScroller.Viewport
|
||||
|
||||
The scrollable viewport.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------- |
|
||||
| `preserveScrollOnPrepend` | `boolean` | `true` | Keep the first visible message item stable when older rows are prepended. |
|
||||
| `role` | `string` | `"region"` | Landmark role for the labelled scrollable transcript viewport. |
|
||||
| `aria-label` | `string` | `"Messages"` | Accessible name for the scrollable chat transcript. |
|
||||
| `tabIndex` | `number` | `0` | Makes the transcript viewport keyboard-scrollable. |
|
||||
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the viewport element. |
|
||||
|
||||
| Data attribute | Value | Description |
|
||||
| -------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `data-scrollable` | `"start"` \| `"end"` \| `"start end"` \| _absent_ | Edges the viewport can scroll toward. Query one with `[data-scrollable~="end"]`; absent means it fits. |
|
||||
| `data-autoscrolling` | present | Present while the viewport is programmatically scrolling to the latest message. |
|
||||
|
||||
### MessageScroller.Content
|
||||
|
||||
The transcript content element. Every direct child should be a
|
||||
`MessageScroller.Item`.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------------- | ----------------------------- | ------------- | ----------------------------------------------------------------------- |
|
||||
| `role` | `string` | `"log"` | ARIA role applied to the message list for live announcements. |
|
||||
| `aria-relevant` | `string` | `"additions"` | Live-region updates to announce. Defaults to new transcript rows only. |
|
||||
| `aria-busy` | `boolean` | - | Marks the live region busy while a turn streams, if needed. |
|
||||
| `spacerClassName` | `string` | - | Class name for the internal spacer used to make room for anchored rows. |
|
||||
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the content element. |
|
||||
|
||||
### MessageScroller.Item
|
||||
|
||||
One transcript row: a message, marker, typing row, separator, or load-more row.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| -------------- | ----------------------------- | ------- | ------------------------------------------------------------------------------ |
|
||||
| `messageId` | `string` | - | Stable row id used by `scrollToMessage`, visibility, and prepend preservation. |
|
||||
| `scrollAnchor` | `boolean` | `false` | Marks this row as a turn boundary that can anchor newly appended turns. |
|
||||
| `...props` | `React.ComponentProps<"div">` | - | Props spread to the item element. |
|
||||
|
||||
| Data attribute | Value | Description |
|
||||
| -------------------- | --------------------- | ---------------------------------- |
|
||||
| `data-message-id` | `string` | Mirrors `messageId` when provided. |
|
||||
| `data-scroll-anchor` | `"true"` \| `"false"` | Mirrors `scrollAnchor`. |
|
||||
|
||||
### MessageScroller.Button
|
||||
|
||||
A button that scrolls to the start or end of the transcript. It is inert and
|
||||
removed from the tab order when there is nothing to scroll toward.
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------- | --------------------------------------- | ---------- | ------------------------------------------------------------------------ |
|
||||
| `behavior` | `ScrollBehavior` | `"smooth"` | Native scroll behavior used when the button scrolls to its target edge. |
|
||||
| `direction` | `"start" \| "end"` | `"end"` | Transcript edge the button scrolls toward. |
|
||||
| `children` | `React.ReactNode` | - | Custom button content. Defaults to the scroll icon and accessible label. |
|
||||
| `render` | `React.ReactElement \| render function` | - | Custom render target. |
|
||||
| `...props` | `React.ComponentProps<"button">` | - | Props spread to the button. |
|
||||
|
||||
| Data attribute | Value | Description |
|
||||
| ---------------- | --------------------- | ----------------------------------------- |
|
||||
| `data-direction` | `"start"` \| `"end"` | Mirrors `direction`. |
|
||||
| `data-active` | `"true"` \| `"false"` | Whether this button can currently scroll. |
|
||||
|
||||
### useMessageScroller
|
||||
|
||||
Imperative transcript controls.
|
||||
|
||||
| Method | Type | Description |
|
||||
| ----------------- | ------------------------------------------ | ------------------------------- |
|
||||
| `scrollToMessage` | `(messageId: string, options?) => boolean` | Scroll to a mounted message id. |
|
||||
| `scrollToEnd` | `(options?) => boolean` | Scroll to the latest message. |
|
||||
| `scrollToStart` | `(options?) => boolean` | Scroll to the top. |
|
||||
|
||||
All commands return `false` when the command could not be applied.
|
||||
`scrollToStart` and `scrollToEnd` return `false` only when the viewport is not
|
||||
mounted yet. `scrollToMessage` returns `false` when the target is not mounted and
|
||||
cannot be queued.
|
||||
|
||||
Command options:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | ------------------------------------------------- | ----------------------- | ---------------------------------------------------- |
|
||||
| `align` | `"start"` \| `"center"` \| `"end"` \| `"nearest"` | `"start"` | How a message target aligns in the viewport. |
|
||||
| `behavior` | `ScrollBehavior` | `"auto"` | Native scroll behavior for the command. |
|
||||
| `scrollMargin` | `number` | provider `scrollMargin` | Margin applied to the aligned edge for this command. |
|
||||
|
||||
### useMessageScrollerScrollable
|
||||
|
||||
Which edges the viewport can scroll toward, for sibling UI that needs the values
|
||||
in JavaScript. Prefer the `data-scrollable` attribute for styling the scroller
|
||||
itself.
|
||||
|
||||
| Value | Type | Description |
|
||||
| ------- | --------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `start` | `boolean` | Whether the viewport can scroll toward the start. Content is hidden above (`!start` means at the top). |
|
||||
| `end` | `boolean` | Whether the viewport can scroll toward the end. Content is hidden below (`!end` means at the bottom). |
|
||||
|
||||
### useMessageScrollerVisibility
|
||||
|
||||
Visibility state for outline, search, and active-turn UI. It subscribes
|
||||
separately from `useMessageScrollerScrollable`, so visibility work is only paid for
|
||||
when a consumer needs it.
|
||||
|
||||
| Value | Type | Description |
|
||||
| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `currentAnchorId` | `string \| null` | The current anchored turn, based on the last `scrollAnchor` item at or above the reading line. |
|
||||
| `visibleMessageIds` | `string[]` | Message ids intersecting the viewport, in document order. |
|
||||
|
||||
Filter `visibleMessageIds` in your app when you need a narrower outline, such as
|
||||
user messages, anchored turns, or search hits.
|
||||
4
apps/v4/content/docs/react/meta.json
Normal file
4
apps/v4/content/docs/react/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "@shadcn/react",
|
||||
"pages": ["message-scroller"]
|
||||
}
|
||||
422
apps/v4/content/docs/registry/api-reference.mdx
Normal file
422
apps/v4/content/docs/registry/api-reference.mdx
Normal file
@@ -0,0 +1,422 @@
|
||||
---
|
||||
title: API Reference
|
||||
description: Programmatic API for working with registries, schemas and presets.
|
||||
---
|
||||
|
||||
The `shadcn` package exposes a set of programmatic APIs in addition to the CLI.
|
||||
You can use these to fetch and resolve registry items, validate registry JSON,
|
||||
and build custom tooling on top of the registry.
|
||||
|
||||
Each API is available under a dedicated subpath import.
|
||||
|
||||
```ts
|
||||
import { getRegistryItems } from "shadcn/registry"
|
||||
import { registryItemSchema } from "shadcn/schema"
|
||||
```
|
||||
|
||||
<Callout className="mt-6">
|
||||
The CLI commands themselves are not part of the public API. Only the imports
|
||||
documented below are considered stable.
|
||||
</Callout>
|
||||
|
||||
## shadcn/registry
|
||||
|
||||
Fetch and resolve items from configured registries.
|
||||
|
||||
Most functions accept an options object. The two options below are common to all
|
||||
of them. In the examples that follow, `config` refers to this optional value —
|
||||
omit it to use the built-in registries.
|
||||
|
||||
### config
|
||||
|
||||
- **Type:** `Partial<Config>`
|
||||
- **Default:** built-in registries only
|
||||
|
||||
The resolved contents of your `components.json` file. Its `registries` field
|
||||
maps a namespace (e.g. `@acme`) to a URL and any authentication headers or
|
||||
environment variables required to reach it.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { getRegistryItems } from "shadcn/registry"
|
||||
|
||||
const items = await getRegistryItems(["@acme/login-form"], {
|
||||
config: {
|
||||
registries: {
|
||||
"@acme": "https://acme.com/r/{name}.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### useCache
|
||||
|
||||
- **Type:** `boolean`
|
||||
- **Default:** `true`
|
||||
|
||||
Registry responses are cached **in memory for the lifetime of the process**,
|
||||
keyed by the resolved URL. Because the in-flight promise is cached, concurrent
|
||||
requests for the same URL are de-duplicated into a single fetch.
|
||||
|
||||
Leave this enabled for one-off scripts and CLI runs. Set it to `false` in
|
||||
long-running processes (servers, watchers, the MCP server) where the registry
|
||||
can change between requests and you need fresh data each time.
|
||||
|
||||
```ts
|
||||
const fresh = await getRegistry("@shadcn", { useCache: false })
|
||||
```
|
||||
|
||||
### getRegistry
|
||||
|
||||
Fetch a single registry by name.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { getRegistry } from "shadcn/registry"
|
||||
|
||||
const registry = await getRegistry("@acme", {
|
||||
config, // optional Partial<Config>
|
||||
useCache: true,
|
||||
})
|
||||
```
|
||||
|
||||
### getRegistryItems
|
||||
|
||||
Fetch one or more registry items by their qualified names.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { getRegistryItems } from "shadcn/registry"
|
||||
|
||||
const items = await getRegistryItems(["@acme/button", "@acme/card"], {
|
||||
config,
|
||||
useCache: true,
|
||||
})
|
||||
```
|
||||
|
||||
Returns an array of registry items:
|
||||
|
||||
```json showLineNumbers
|
||||
[
|
||||
{
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"dependencies": ["@radix-ui/react-slot"],
|
||||
"files": [
|
||||
{
|
||||
"path": "ui/button.tsx",
|
||||
"type": "registry:ui",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### resolveRegistryItems
|
||||
|
||||
Resolve multiple items together with their registry dependencies, merged into a
|
||||
single tree. Unlike [`getRegistryItems`](#getregistryitems), which returns the
|
||||
items as a list, this walks each item's `registryDependencies` and flattens
|
||||
everything — files, dependencies, CSS variables — into one installable object.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { resolveRegistryItems } from "shadcn/registry"
|
||||
|
||||
const tree = await resolveRegistryItems(
|
||||
["@acme/button", "@acme/card", "@acme/dialog"],
|
||||
{ config }
|
||||
)
|
||||
```
|
||||
|
||||
Returns a single merged tree:
|
||||
|
||||
```json showLineNumbers
|
||||
{
|
||||
"dependencies": ["@radix-ui/react-slot", "@radix-ui/react-dialog"],
|
||||
"files": [
|
||||
{ "path": "ui/button.tsx", "type": "registry:ui", "content": "..." },
|
||||
{ "path": "ui/card.tsx", "type": "registry:ui", "content": "..." },
|
||||
{ "path": "ui/dialog.tsx", "type": "registry:ui", "content": "..." }
|
||||
],
|
||||
"cssVars": {
|
||||
"theme": {
|
||||
"font-heading": "Poppins, sans-serif"
|
||||
},
|
||||
"light": {
|
||||
"brand": "oklch(0.205 0.015 18)"
|
||||
},
|
||||
"dark": {
|
||||
"brand": "oklch(0.205 0.015 18)"
|
||||
}
|
||||
},
|
||||
"docs": ""
|
||||
}
|
||||
```
|
||||
|
||||
### getRegistries
|
||||
|
||||
Fetch the registry directory.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { getRegistries } from "shadcn/registry"
|
||||
|
||||
const registries = await getRegistries({ useCache: true })
|
||||
```
|
||||
|
||||
Returns an array of registry entries:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "@shadcn",
|
||||
"url": "https://ui.shadcn.com/r/{name}.json",
|
||||
"homepage": "https://ui.shadcn.com"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### searchRegistries
|
||||
|
||||
Search across one or more registries with fuzzy matching.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { searchRegistries } from "shadcn/registry"
|
||||
|
||||
const results = await searchRegistries(["@shadcn"], {
|
||||
query: "button",
|
||||
types: ["registry:component"],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
config,
|
||||
continueOnError: true, // skip (don't throw on) registries that fail to load
|
||||
})
|
||||
```
|
||||
|
||||
Returns matching items wrapped in pagination metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": { "total": 1, "offset": 0, "limit": 100, "hasMore": false },
|
||||
"items": [
|
||||
{
|
||||
"name": "button",
|
||||
"type": "registry:ui",
|
||||
"description": "A button component.",
|
||||
"registry": "@shadcn",
|
||||
"addCommandArgument": "@shadcn/button"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### loadRegistry
|
||||
|
||||
Read and resolve a local `registry.json` file from disk, following any
|
||||
`include` references, and return the registry catalog.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { loadRegistry } from "shadcn/registry"
|
||||
|
||||
const catalog = await loadRegistry({
|
||||
cwd: process.cwd(), // defaults to process.cwd()
|
||||
registryFile: "registry.json", // defaults to "registry.json"
|
||||
})
|
||||
```
|
||||
|
||||
The returned catalog lists every item but **omits file contents** — like a
|
||||
built `registry.json` index.
|
||||
|
||||
<Callout className="mt-6" title="How is this different from getRegistry?">
|
||||
[`getRegistry`](#getregistry) fetches a **remote** registry over the network
|
||||
(by namespace, URL or GitHub address) and expects the served catalog to
|
||||
already be flattened — it rejects catalogs that still use `include`.
|
||||
`loadRegistry` reads a **local** `registry.json` from disk and resolves
|
||||
`include` references itself.
|
||||
</Callout>
|
||||
|
||||
### loadRegistryItem
|
||||
|
||||
Read a single item from a local `registry.json` by name, with its file contents
|
||||
read from disk and inlined.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { loadRegistryItem } from "shadcn/registry"
|
||||
|
||||
const item = await loadRegistryItem("login-form", { cwd: process.cwd() })
|
||||
```
|
||||
|
||||
Returns a fully resolved registry item with file contents:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "login-form",
|
||||
"type": "registry:component",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/new-york/login-form.tsx",
|
||||
"type": "registry:component",
|
||||
"content": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
<Callout className="mt-6" title="How is this different from getRegistryItems?">
|
||||
[`getRegistryItems`](#getregistryitems) resolves items from a **remote**
|
||||
registry over the network. `loadRegistryItem` builds a single item on demand
|
||||
from your **local** source files, reading each file from disk. Use it in a
|
||||
dynamic route that serves `registry-item.json` responses.
|
||||
</Callout>
|
||||
|
||||
### Errors
|
||||
|
||||
All registry functions throw typed errors that extend `RegistryError`. Use the
|
||||
`RegistryErrorCode` enum or `instanceof` checks to handle them.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { RegistryError, RegistryNotFoundError } from "shadcn/registry"
|
||||
|
||||
try {
|
||||
await getRegistry("@unknown")
|
||||
} catch (error) {
|
||||
if (error instanceof RegistryNotFoundError) {
|
||||
// handle missing registry
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available error classes:
|
||||
|
||||
- `RegistryError`
|
||||
- `RegistryNotFoundError`
|
||||
- `RegistryUnauthorizedError`
|
||||
- `RegistryForbiddenError`
|
||||
- `RegistryFetchError`
|
||||
- `RegistryNotConfiguredError`
|
||||
- `RegistryLocalFileError`
|
||||
- `RegistryParseError`
|
||||
- `RegistryValidationError`
|
||||
- `RegistryItemNotFoundError`
|
||||
- `RegistriesIndexParseError`
|
||||
- `RegistryMissingEnvironmentVariablesError`
|
||||
- `RegistryInvalidNamespaceError`
|
||||
|
||||
## shadcn/schema
|
||||
|
||||
The Zod schemas used to validate `registry.json`, `registry-item.json` and
|
||||
`components.json`. Useful for validating registry data in your own tooling.
|
||||
|
||||
```ts
|
||||
import { registryItemSchema, registrySchema } from "shadcn/schema"
|
||||
|
||||
const result = registryItemSchema.safeParse(json)
|
||||
if (!result.success) {
|
||||
console.error(result.error)
|
||||
}
|
||||
```
|
||||
|
||||
Key schemas:
|
||||
|
||||
- `registrySchema`
|
||||
- `registryItemSchema`
|
||||
- `registryItemFileSchema`
|
||||
- `registryItemTypeSchema`
|
||||
- `registryItemCssVarsSchema`
|
||||
- `registryItemTailwindSchema`
|
||||
- `registryBaseColorSchema`
|
||||
- `configSchema`
|
||||
- `presetSchema`
|
||||
|
||||
Inferred types are exported alongside them:
|
||||
|
||||
- `Registry`
|
||||
- `RegistryItem`
|
||||
- `RegistryBaseItem`
|
||||
- `RegistryFontItem`
|
||||
- `Preset`
|
||||
- `ConfigJson`
|
||||
|
||||
## shadcn/preset
|
||||
|
||||
Encode, decode and validate theme presets, plus the preset option constants used
|
||||
by the theme editor.
|
||||
|
||||
### encodePreset
|
||||
|
||||
Encode a `Partial<PresetConfig>` into a short, URL-safe preset code. Any fields
|
||||
you omit fall back to `DEFAULT_PRESET_CONFIG`.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { encodePreset } from "shadcn/preset"
|
||||
|
||||
const code = encodePreset({
|
||||
style: "vega",
|
||||
baseColor: "stone",
|
||||
theme: "blue",
|
||||
radius: "large",
|
||||
font: "geist",
|
||||
})
|
||||
```
|
||||
|
||||
Returns a version-prefixed string:
|
||||
|
||||
```ts showLineNumbers
|
||||
"bJ4FLU0"
|
||||
```
|
||||
|
||||
### decodePreset
|
||||
|
||||
Decode a preset code back into a full `PresetConfig`. Returns `null` if the code
|
||||
is missing or invalid.
|
||||
|
||||
```ts showLineNumbers
|
||||
import { decodePreset } from "shadcn/preset"
|
||||
|
||||
const config = decodePreset("bJ4FLU0")
|
||||
```
|
||||
|
||||
Returns the resolved config (omitted fields are filled with their defaults):
|
||||
|
||||
```json
|
||||
{
|
||||
"style": "vega",
|
||||
"baseColor": "stone",
|
||||
"theme": "blue",
|
||||
"chartColor": "neutral",
|
||||
"iconLibrary": "lucide",
|
||||
"font": "geist",
|
||||
"fontHeading": "inherit",
|
||||
"radius": "large",
|
||||
"menuAccent": "subtle",
|
||||
"menuColor": "default"
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
decodePreset("not-a-code") // null
|
||||
```
|
||||
|
||||
### Other exports
|
||||
|
||||
Additional functions for validating codes and generating random presets:
|
||||
|
||||
- `isPresetCode`
|
||||
- `isValidPreset`
|
||||
- `generateRandomConfig`
|
||||
- `generateRandomPreset`
|
||||
- `toBase62`
|
||||
- `fromBase62`
|
||||
|
||||
Constants:
|
||||
|
||||
- `PRESET_BASES`
|
||||
- `PRESET_STYLES`
|
||||
- `PRESET_BASE_COLORS`
|
||||
- `PRESET_THEMES`
|
||||
- `PRESET_ICON_LIBRARIES`
|
||||
- `PRESET_FONTS`
|
||||
- `PRESET_FONT_HEADINGS`
|
||||
- `PRESET_RADII`
|
||||
- `PRESET_MENU_ACCENTS`
|
||||
- `PRESET_MENU_COLORS`
|
||||
- `PRESET_CHART_COLORS`
|
||||
- `DEFAULT_PRESET_CONFIG`
|
||||
@@ -10,6 +10,7 @@
|
||||
"authentication",
|
||||
"mcp",
|
||||
"open-in-v0",
|
||||
"api-reference",
|
||||
"registry-json",
|
||||
"registry-item-json"
|
||||
]
|
||||
|
||||
4
apps/v4/content/docs/utils/meta.json
Normal file
4
apps/v4/content/docs/utils/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Utilities",
|
||||
"pages": ["scroll-fade", "shimmer"]
|
||||
}
|
||||
176
apps/v4/content/docs/utils/scroll-fade.mdx
Normal file
176
apps/v4/content/docs/utils/scroll-fade.mdx
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: scroll-fade
|
||||
description: Utilities for adding a fade effect to the edges of a scroll container.
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="scroll-fade-demo"
|
||||
previewClassName="h-auto"
|
||||
/>
|
||||
|
||||
## Installation
|
||||
|
||||
If your project was set up with `npx shadcn@latest init`, you already have `scroll-fade`. It ships with the `shadcn` package, which the CLI imports in your global CSS file.
|
||||
|
||||
Otherwise, install the `shadcn` package:
|
||||
|
||||
```bash
|
||||
npm install shadcn
|
||||
```
|
||||
|
||||
Then import the shared utilities in your global CSS file:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "shadcn/tailwind.css";
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
| Class | Styles |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `scroll-fade` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));` <br /> `animation-timeline: scroll(self y);` |
|
||||
| `scroll-fade-y` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));` <br /> `animation-timeline: scroll(self y);` |
|
||||
| `scroll-fade-x` | `mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));` <br /> `animation-timeline: scroll(self inline);` |
|
||||
| `scroll-fade-t` | Fade mask on the top edge. <br /> `animation-timeline: scroll(self y);` |
|
||||
| `scroll-fade-b` | Fade mask on the bottom edge. <br /> `animation-timeline: scroll(self y);` |
|
||||
| `scroll-fade-l` | Fade mask on the left edge. <br /> `animation-timeline: scroll(self x);` |
|
||||
| `scroll-fade-r` | Fade mask on the right edge. <br /> `animation-timeline: scroll(self x);` |
|
||||
| `scroll-fade-s` | Fade mask on the start edge, mirrors in RTL. <br /> `animation-timeline: scroll(self inline);` |
|
||||
| `scroll-fade-e` | Fade mask on the end edge, mirrors in RTL. <br /> `animation-timeline: scroll(self inline);` |
|
||||
| `scroll-fade-<number>` | `--scroll-fade-size: calc(var(--spacing) * <number>);` |
|
||||
| `scroll-fade-[<value>]` | `--scroll-fade-size: <value>;` |
|
||||
| `scroll-fade-{t,b,s,e}-<number>` | `--scroll-fade-{t,b,s,e}-size: calc(var(--spacing) * <number>);` |
|
||||
| `scroll-fade-{t,b,s,e}-[<value>]` | `--scroll-fade-{t,b,s,e}-size: <value>;` |
|
||||
| `scroll-fade-none` | `--scroll-fade-mask: none;` |
|
||||
|
||||
Add `scroll-fade` or `scroll-fade-y` to the scroll container, i.e. the element that has `overflow-y-auto`.
|
||||
|
||||
```tsx
|
||||
<div className="scroll-fade overflow-y-auto">{/* ... */}</div>
|
||||
```
|
||||
|
||||
The fade is scroll-aware and tracks the scroll position:
|
||||
|
||||
- At rest, the top edge is crisp and the bottom edge fades to hint at more content.
|
||||
- As you scroll, a fade appears at the top and both edges stay faded mid-scroll.
|
||||
- At the end, the bottom edge sharpens to show you have reached the last item.
|
||||
|
||||
The fade is applied with `mask-image`, so it dissolves the content itself rather than overlaying a color. The mask uses a linear fade from transparent to black, so it adapts to any background without configuration. If your scroll area sits inside a card, put the background and border on a wrapper and `scroll-fade` on the inner scroller, so the fade dissolves the content and not the card.
|
||||
|
||||
The [`ScrollArea`](/docs/components/scroll-area) and [`MessageScroller`](/docs/components/message-scroller) components can use `scroll-fade` on their scrollable viewport.
|
||||
|
||||
## No Overflow, No Fade
|
||||
|
||||
If the content does not overflow, no fade is shown. You can apply `scroll-fade` to any list without checking whether it scrolls.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="scroll-fade-overflow"
|
||||
previewClassName="h-auto"
|
||||
/>
|
||||
|
||||
## Horizontal Scrolling
|
||||
|
||||
Use `scroll-fade-x` on containers that scroll horizontally, i.e. the element that has `overflow-x-auto`.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="scroll-fade-horizontal"
|
||||
previewClassName="h-64"
|
||||
/>
|
||||
|
||||
```tsx
|
||||
<div className="flex scroll-fade-x overflow-x-auto">{/* ... */}</div>
|
||||
```
|
||||
|
||||
The horizontal fade is direction-aware. In RTL layouts, the crisp edge and the fade follow the reading direction with no extra classes needed. `scroll-fade-<number>` and `scroll-fade-none` work the same for both axes.
|
||||
|
||||
## Edge Fades
|
||||
|
||||
Use edge utilities when only one edge should track the scroll position.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="scroll-fade-edge"
|
||||
previewClassName="h-auto"
|
||||
/>
|
||||
|
||||
```tsx
|
||||
<div className="scroll-fade-b overflow-y-auto">{/* ... */}</div>
|
||||
```
|
||||
|
||||
The edge utilities are scroll-aware. Start edges fade in after you scroll away from the start, and end edges fade out when you reach the end. Use `scroll-fade-t`, `scroll-fade-b`, `scroll-fade-l`, and `scroll-fade-r` for physical edges. Use `scroll-fade-s` and `scroll-fade-e` for logical inline edges that mirror in RTL.
|
||||
|
||||
## Fade Size
|
||||
|
||||
The fade depth defaults to `12%` of the container, capped at `40px` so tall scrollers stay subtle. Use `scroll-fade-<number>` to set a fixed size on the spacing scale instead, the same way `scroll-mt-<number>` works.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="scroll-fade-size"
|
||||
previewClassName="h-auto"
|
||||
/>
|
||||
|
||||
```tsx
|
||||
<div className="scroll-fade overflow-y-auto scroll-fade-24">{/* ... */}</div>
|
||||
```
|
||||
|
||||
For one-off values, use an arbitrary length or percentage:
|
||||
|
||||
```tsx
|
||||
<div className="scroll-fade overflow-y-auto scroll-fade-[15%]">{/* ... */}</div>
|
||||
```
|
||||
|
||||
To fade opposite edges by different amounts, use the per-edge modifiers `scroll-fade-t-<number>`, `scroll-fade-b-<number>`, `scroll-fade-s-<number>`, and `scroll-fade-e-<number>`. They override `scroll-fade-<number>` on the edge they target and accept arbitrary values too.
|
||||
|
||||
```tsx
|
||||
<div className="scroll-fade overflow-y-auto scroll-fade-b-8 scroll-fade-t-2">
|
||||
{/* ... */}
|
||||
</div>
|
||||
```
|
||||
|
||||
Use the logical `s`/`e` modifiers for horizontal scrollers so the sizes mirror in RTL.
|
||||
|
||||
The fade eases in and out over a fixed scroll distance rather than appearing instantly. That distance is the `--scroll-fade-reveal` variable, `96px` by default and independent of the fade depth. Lower it for a snappier reveal or raise it for a more gradual one:
|
||||
|
||||
```tsx
|
||||
<div className="scroll-fade overflow-y-auto [--scroll-fade-reveal:64px]">
|
||||
{/* ... */}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Disabling the Fade
|
||||
|
||||
Use `scroll-fade-none` to remove the fade. It works in any class order, so the typical use is responsive or stateful:
|
||||
|
||||
```tsx
|
||||
<div className="scroll-fade overflow-y-auto md:scroll-fade-none">
|
||||
{/* ... */}
|
||||
</div>
|
||||
```
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-rhea"
|
||||
name="scroll-fade-none"
|
||||
previewClassName="h-auto"
|
||||
/>
|
||||
|
||||
## Fallback
|
||||
|
||||
The scroll-aware behavior is implemented with [CSS scroll-driven animations](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_scroll-driven_animations), with no JavaScript and no scroll listeners. In browsers that do not support scroll-driven animations, `scroll-fade` falls back to a static fade on both edges, and edge utilities fall back to a static fade on the selected edge.
|
||||
|
||||
Since the mask is applied to the scroll container itself, a visible scrollbar fades with the content at the edges. Pair `scroll-fade` with `no-scrollbar`, which ships in the same package, if you want to hide the scrollbar entirely.
|
||||
|
||||
## RTL
|
||||
|
||||
To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl).
|
||||
|
||||
`scroll-fade-x` follows the reading direction. At rest, the start edge is crisp and the end edge fades. In RTL layouts that means a crisp right edge and a fade on the left, mirrored from LTR.
|
||||
|
||||
<ComponentPreview
|
||||
styleName="radix-nova"
|
||||
name="scroll-fade-rtl"
|
||||
direction="rtl"
|
||||
/>
|
||||
167
apps/v4/content/docs/utils/shimmer.mdx
Normal file
167
apps/v4/content/docs/utils/shimmer.mdx
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: shimmer
|
||||
description: Utilities for adding a shimmer effect to text elements.
|
||||
---
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-demo" />
|
||||
|
||||
## Installation
|
||||
|
||||
If your project was set up with `npx shadcn@latest init`, you already have `shimmer`. It ships with the `shadcn` package, which the CLI imports in your global CSS file.
|
||||
|
||||
Otherwise, install the `shadcn` package:
|
||||
|
||||
```bash
|
||||
npm install shadcn
|
||||
```
|
||||
|
||||
Then import the shared utilities in your global CSS file:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
@import "shadcn/tailwind.css";
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
| Class | Styles |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `shimmer` | `background-clip: text;` <br /> `animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;` |
|
||||
| `shimmer-once` | `animation-iteration-count: 1;` |
|
||||
| `shimmer-reverse` | `animation-direction: reverse;` |
|
||||
| `shimmer-none` | `--shimmer-image: none;` <br /> `--shimmer-text-fill: currentColor;` |
|
||||
| `shimmer-color-<color>` | `--shimmer-color: <color>;` |
|
||||
| `shimmer-color-[<value>]` | `--shimmer-color: <value>;` |
|
||||
| `shimmer-color-<color>/<pct>` | `--shimmer-color: color-mix(in oklch, <color> <pct>, transparent);` |
|
||||
| `shimmer-duration-<number>` | `--shimmer-duration: calc(<number> * 1ms);` |
|
||||
| `shimmer-spread-<number>` | `--shimmer-spread: calc(var(--spacing) * <number>);` |
|
||||
| `shimmer-spread-[<value>]` | `--shimmer-spread: <value>;` |
|
||||
| `shimmer-angle-<number>` | `--shimmer-angle: calc(<number> * 1deg);` |
|
||||
|
||||
Add `shimmer` to a text element.
|
||||
|
||||
```tsx
|
||||
<p className="shimmer text-muted-foreground">Generating response…</p>
|
||||
```
|
||||
|
||||
The shimmer is built on `currentColor`, so it adapts to the element:
|
||||
|
||||
- The highlight is derived from the text color, with no configuration needed.
|
||||
- It works on any color, from `text-muted-foreground` to brand colors.
|
||||
- In dark mode, the highlight automatically brightens to stay visible.
|
||||
|
||||
The effect is pure CSS. The text is painted with `background-clip: text`, and the highlight sweeps across it in a seamless loop.
|
||||
|
||||
## With Marker
|
||||
|
||||
The shimmer composes with any component that renders text. A common pattern is a [Marker](/docs/components/marker) showing a live status while the assistant is working:
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-marker" />
|
||||
|
||||
```tsx
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent className="shimmer">Thinking…</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
## Color
|
||||
|
||||
Use `shimmer-color-<color>` to set the highlight color explicitly. It accepts theme colors with an optional opacity modifier, or any arbitrary color value.
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-color" />
|
||||
|
||||
```tsx
|
||||
<p className="shimmer shimmer-color-blue-500/60">Generating response…</p>
|
||||
<p className="shimmer shimmer-color-[#378ADD]">Generating response…</p>
|
||||
```
|
||||
|
||||
## Duration
|
||||
|
||||
Use `shimmer-duration-<number>` to set the duration of one sweep in milliseconds. The default is `2000`, i.e. `2s`.
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-duration" />
|
||||
|
||||
```tsx
|
||||
<p className="shimmer shimmer-duration-1000">Generating response…</p>
|
||||
```
|
||||
|
||||
## Spread
|
||||
|
||||
Use `shimmer-spread-<number>` to set the width of the highlight band using the spacing scale. The default is `calc(3ch + 40px)`: a fixed base plus a `3ch` term that scales with the font size.
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-spread" />
|
||||
|
||||
```tsx
|
||||
<p className="shimmer shimmer-spread-24">Generating response…</p>
|
||||
```
|
||||
|
||||
For one-off values, use an arbitrary length or percentage:
|
||||
|
||||
```tsx
|
||||
<p className="shimmer shimmer-spread-[5rem]">Generating response…</p>
|
||||
```
|
||||
|
||||
## Angle
|
||||
|
||||
Use `shimmer-angle-<number>` to set the tilt of the highlight band in degrees. The default is `20`.
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-angle" />
|
||||
|
||||
```tsx
|
||||
<p className="shimmer shimmer-angle-45">Generating response…</p>
|
||||
```
|
||||
|
||||
## Reverse
|
||||
|
||||
Use `shimmer-reverse` to sweep the highlight in the opposite direction. In RTL layouts the sweep already follows the reading direction. See [RTL](#rtl).
|
||||
|
||||
```tsx
|
||||
<p className="shimmer shimmer-reverse">Generating response…</p>
|
||||
```
|
||||
|
||||
## Play Once
|
||||
|
||||
Use `shimmer-once` to play a single sweep instead of looping, useful as a reveal when streaming completes. Pair it with `shimmer-duration-<number>` to control how long the sweep takes.
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-once" />
|
||||
|
||||
```tsx
|
||||
<p className="shimmer shimmer-duration-1100 shimmer-once">
|
||||
Response generated.
|
||||
</p>
|
||||
```
|
||||
|
||||
## Disabling the Shimmer
|
||||
|
||||
Use `shimmer-none` to turn the effect off and render the text normally. It works in any class order, so the typical use is responsive or stateful:
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-none" />
|
||||
|
||||
```tsx
|
||||
<p className="shimmer md:shimmer-none">Generating response…</p>
|
||||
```
|
||||
|
||||
## Fallback
|
||||
|
||||
The shimmer is built on modern color features, [relative color syntax](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_colors/Relative_colors) and `color-mix()`, which are available in all current browsers. In older browsers without support, the highlight gradient is dropped and the text can render transparent. If you target older browsers, apply `shimmer` conditionally with a `supports-*` variant:
|
||||
|
||||
```tsx
|
||||
<p className="supports-[color:oklch(from_white_l_c_h)]:shimmer">
|
||||
Generating response…
|
||||
</p>
|
||||
```
|
||||
|
||||
## Reduced Motion
|
||||
|
||||
When the user prefers reduced motion, the animation is disabled automatically and the text renders normally. There is nothing to configure.
|
||||
|
||||
## RTL
|
||||
|
||||
To enable RTL support in shadcn/ui, see the [RTL configuration guide](/docs/rtl).
|
||||
|
||||
The sweep follows the reading direction, left to right in LTR and right to left in RTL, with no extra classes. Use `shimmer-reverse` to flip the direction manually.
|
||||
|
||||
<ComponentPreview styleName="radix-rhea" name="shimmer-rtl" />
|
||||
@@ -19,6 +19,7 @@ const eslintConfig = tseslint.config(
|
||||
"next-env.d.ts",
|
||||
".source/**",
|
||||
"**/__index__.tsx",
|
||||
"**/__components__.tsx",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
8941
apps/v4/examples/__components__.tsx
Normal file
8941
apps/v4/examples/__components__.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
82
apps/v4/examples/base/attachment-demo.tsx
Normal file
82
apps/v4/examples/base/attachment-demo.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { FileCodeIcon, XIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/styles/base-rhea/ui/attachment"
|
||||
import { Spinner } from "@/styles/base-rhea/ui/spinner"
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: "workspace.png",
|
||||
meta: "PNG · 820 KB",
|
||||
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
|
||||
alt: "Workspace",
|
||||
},
|
||||
{
|
||||
name: "desk-reference.jpg",
|
||||
meta: "JPG · 1.1 MB",
|
||||
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
|
||||
alt: "Desk",
|
||||
},
|
||||
{
|
||||
name: "office-reference.jpg",
|
||||
meta: "JPG · 940 KB",
|
||||
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
|
||||
alt: "Office",
|
||||
},
|
||||
]
|
||||
|
||||
export function AttachmentDemo() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-3 py-12">
|
||||
<AttachmentGroup>
|
||||
{images.map((image) => (
|
||||
<Attachment key={image.name} orientation="vertical">
|
||||
<AttachmentMedia variant="image">
|
||||
<img src={image.src} alt={image.alt} />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{image.name}</AttachmentTitle>
|
||||
<AttachmentDescription>{image.meta}</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
<Attachment state="uploading" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<Spinner />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Cancel upload">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
<Attachment className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileCodeIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>message-renderer.tsx</AttachmentTitle>
|
||||
<AttachmentDescription>TypeScript · 12 KB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Remove message-renderer.tsx">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
apps/v4/examples/base/attachment-group.tsx
Normal file
71
apps/v4/examples/base/attachment-group.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
FileCodeIcon,
|
||||
FileTextIcon,
|
||||
TableIcon,
|
||||
XIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/styles/base-rhea/ui/attachment"
|
||||
|
||||
type Item = {
|
||||
name: string
|
||||
meta: string
|
||||
icon?: LucideIcon
|
||||
src?: string
|
||||
}
|
||||
|
||||
const items: Item[] = [
|
||||
{ name: "briefing-notes.pdf", meta: "PDF · 1.4 MB", icon: FileTextIcon },
|
||||
{
|
||||
name: "workspace.png",
|
||||
meta: "PNG · 820 KB",
|
||||
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
|
||||
},
|
||||
{ name: "customers.csv", meta: "CSV · 18 KB", icon: TableIcon },
|
||||
{ name: "renderer.tsx", meta: "TSX · 12 KB", icon: FileCodeIcon },
|
||||
]
|
||||
|
||||
export function AttachmentGroupDemo() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-sm py-12">
|
||||
<AttachmentGroup className="w-full">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<Attachment key={item.name} className="w-64">
|
||||
{item.src ? (
|
||||
<AttachmentMedia variant="image">
|
||||
<img src={item.src} alt={item.name} />
|
||||
</AttachmentMedia>
|
||||
) : Icon ? (
|
||||
<AttachmentMedia>
|
||||
<Icon />
|
||||
</AttachmentMedia>
|
||||
) : null}
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{item.name}</AttachmentTitle>
|
||||
<AttachmentDescription>{item.meta}</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label={`Remove ${item.name}`}>
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
)
|
||||
})}
|
||||
</AttachmentGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
apps/v4/examples/base/attachment-image.tsx
Normal file
69
apps/v4/examples/base/attachment-image.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/styles/base-rhea/ui/attachment"
|
||||
|
||||
const images = [
|
||||
{
|
||||
name: "workspace.png",
|
||||
meta: "PNG · 820 KB",
|
||||
src: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
|
||||
alt: "Workspace",
|
||||
},
|
||||
{
|
||||
name: "desk-reference.jpg",
|
||||
meta: "JPG · 1.1 MB",
|
||||
src: "https://images.unsplash.com/photo-1497215728101-856f4ea42174?w=900&auto=format&fit=crop&q=80",
|
||||
alt: "Desk",
|
||||
},
|
||||
{
|
||||
name: "office-reference.jpg",
|
||||
meta: "JPG · 940 KB",
|
||||
src: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
|
||||
alt: "Office",
|
||||
},
|
||||
]
|
||||
|
||||
export function AttachmentImage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-sm py-12">
|
||||
<AttachmentGroup className="w-full">
|
||||
{images.map((image) => (
|
||||
<Attachment key={image.name} orientation="vertical">
|
||||
<AttachmentMedia variant="image">
|
||||
<img src={image.src} alt={image.alt} />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{image.name}</AttachmentTitle>
|
||||
<AttachmentDescription>{image.meta}</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label={`Remove ${image.name}`}>
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
<AttachmentTrigger
|
||||
render={
|
||||
<a
|
||||
href={image.src}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Open ${image.name}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Attachment>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
apps/v4/examples/base/attachment-sizes.tsx
Normal file
42
apps/v4/examples/base/attachment-sizes.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { FileTextIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/styles/base-rhea/ui/attachment"
|
||||
|
||||
export function AttachmentSizes() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-3 py-12">
|
||||
<Attachment size="default" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>Default attachment</AttachmentTitle>
|
||||
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
<Attachment size="sm" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>Small attachment</AttachmentTitle>
|
||||
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
<Attachment size="xs" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>Extra small attachment</AttachmentTitle>
|
||||
</AttachmentContent>
|
||||
</Attachment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
apps/v4/examples/base/attachment-states.tsx
Normal file
101
apps/v4/examples/base/attachment-states.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
FileTextIcon,
|
||||
FileWarningIcon,
|
||||
RefreshCwIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/styles/base-rhea/ui/attachment"
|
||||
import { Spinner } from "@/styles/base-rhea/ui/spinner"
|
||||
|
||||
export function AttachmentStates() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-2 py-12">
|
||||
<Attachment state="idle" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<ClockIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>selected-file.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>Ready to upload</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Remove selected-file.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
<Attachment state="uploading" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<Spinner />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>design-system.zip</AttachmentTitle>
|
||||
<AttachmentDescription>Uploading · 64%</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Cancel upload">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
<Attachment state="processing" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>market-research.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>Processing document</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Remove market-research.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
<Attachment state="error" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileWarningIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>financial-model.xlsx</AttachmentTitle>
|
||||
<AttachmentDescription>
|
||||
Upload failed. Try again.
|
||||
</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Retry upload">
|
||||
<RefreshCwIcon />
|
||||
</AttachmentAction>
|
||||
<AttachmentAction aria-label="Remove financial-model.xlsx">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
<Attachment state="done" className="w-full">
|
||||
<AttachmentMedia>
|
||||
<CheckIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>uploaded-report.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>Uploaded · 1.8 MB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Remove uploaded-report.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
60
apps/v4/examples/base/attachment-trigger.tsx
Normal file
60
apps/v4/examples/base/attachment-trigger.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { CopyIcon, FileSearchIcon, XIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/styles/base-rhea/ui/attachment"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/styles/base-rhea/ui/dialog"
|
||||
|
||||
export function AttachmentTriggerDemo() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-sm py-12">
|
||||
<Dialog>
|
||||
<Attachment className="w-full">
|
||||
<AttachmentMedia>
|
||||
<FileSearchIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>research-summary.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>Open preview dialog</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction aria-label="Copy link">
|
||||
<CopyIcon />
|
||||
</AttachmentAction>
|
||||
<AttachmentAction aria-label="Remove research-summary.pdf">
|
||||
<XIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<AttachmentTrigger aria-label="Preview research-summary.pdf" />
|
||||
}
|
||||
/>
|
||||
</Attachment>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>research-summary.pdf</DialogTitle>
|
||||
<DialogDescription>
|
||||
The attachment trigger fills the card and opens the dialog, while
|
||||
the actions stay independently clickable above it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
apps/v4/examples/base/bubble-alignment.tsx
Normal file
18
apps/v4/examples/base/bubble-alignment.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
|
||||
|
||||
export function BubbleAlignmentDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
This bubble is aligned to the start. This is the default alignment.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>
|
||||
This bubble is aligned to the end. Use this for user messages.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
apps/v4/examples/base/bubble-collapsible.tsx
Normal file
59
apps/v4/examples/base/bubble-collapsible.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
} from "@/styles/base-rhea/ui/collapsible"
|
||||
|
||||
const text = `The accessibility review found two focus states that were visually too subtle in dark mode.
|
||||
|
||||
I checked the dialog, menu, and drawer paths because each one renders focusable controls inside a layered surface.
|
||||
|
||||
The dialog and drawer are fine. The menu needs the hover and focus tokens split so keyboard focus stays visible when the pointer is not involved.
|
||||
|
||||
I also recommend keeping the change in the style file instead of the primitive so the other themes can choose their own focus treatment later.`
|
||||
|
||||
const previewLength = 180
|
||||
|
||||
export function BubbleCollapsible() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const isLong = text.length > previewLength
|
||||
const preview = `${text.slice(0, previewLength)}...`
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>How can I help you today?</BubbleContent>
|
||||
</Bubble>
|
||||
|
||||
<Bubble variant="muted" align="end">
|
||||
<BubbleContent className="whitespace-pre-line">
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div>{open || !isLong ? text : preview}</div>
|
||||
{isLong ? (
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="link"
|
||||
className="gap-1 p-0 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{open ? "Show less" : "Show more"}
|
||||
<ChevronDownIcon
|
||||
data-icon="inline-end"
|
||||
className="group-data-panel-open/button:rotate-180"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
) : null}
|
||||
</Collapsible>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
apps/v4/examples/base/bubble-demo.tsx
Normal file
48
apps/v4/examples/base/bubble-demo.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleGroup,
|
||||
BubbleReactions,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
|
||||
export function BubbleDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Hey there! what's up?</BubbleContent>
|
||||
</Bubble>
|
||||
<BubbleGroup>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>Hey! Want to see chat bubbles?</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
I can group messages, switch sides, and keep the whole thread easy
|
||||
to scan.
|
||||
</BubbleContent>
|
||||
<BubbleReactions role="img" aria-label="Reaction: thumbs up">
|
||||
<span>👍</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
</BubbleGroup>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Sure. Hit me with your best demo.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
Yes. You are reading a demo that is demoing itself. Very meta. Very
|
||||
on-brand.
|
||||
</BubbleContent>
|
||||
<BubbleReactions
|
||||
role="img"
|
||||
aria-label="Reactions: thumbs up, fire, eyes, and 2 more"
|
||||
>
|
||||
<span>👍</span>
|
||||
<span>🔥</span>
|
||||
<span>👀</span>
|
||||
<span>+2</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
apps/v4/examples/base/bubble-group-demo.tsx
Normal file
36
apps/v4/examples/base/bubble-group-demo.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleGroup,
|
||||
BubbleReactions,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
|
||||
export function BubbleGroupDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>Can you tell me what's the issue?</BubbleContent>
|
||||
</Bubble>
|
||||
<BubbleGroup>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>You tell me!</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>It worked yesterday. You broke it!</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Find the bug and fix it.</BubbleContent>
|
||||
<BubbleReactions aria-label="Reactions: eyes" align="start">
|
||||
<span>👀</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
</BubbleGroup>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
Want me to diff yesterday's you against today's you?
|
||||
It's a bit embarrassing.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
apps/v4/examples/base/bubble-link-button.tsx
Normal file
54
apps/v4/examples/base/bubble-link-button.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleGroup,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
|
||||
export function BubbleLinkButtonDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>How can I help you today?</BubbleContent>
|
||||
</Bubble>
|
||||
<BubbleGroup>
|
||||
<Bubble variant="tinted" align="end">
|
||||
<BubbleContent
|
||||
render={
|
||||
<button onClick={() => toast("You clicked forgot password")} />
|
||||
}
|
||||
>
|
||||
I forgot my password
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="tinted" align="end">
|
||||
<BubbleContent
|
||||
render={
|
||||
<button
|
||||
onClick={() => toast("You clicked help with subscription")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
I need help with my subscription
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="tinted" align="end">
|
||||
<BubbleContent
|
||||
render={
|
||||
<button
|
||||
onClick={() =>
|
||||
toast("You clicked something else. Talk to a human.")
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Something else. Talk to a human.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</BubbleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
apps/v4/examples/base/bubble-markdown.tsx
Normal file
24
apps/v4/examples/base/bubble-markdown.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Markdown } from "@/components/markdown"
|
||||
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
|
||||
|
||||
export function BubbleMarkdownDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Bubble align="end" variant="muted">
|
||||
<BubbleContent>
|
||||
<Markdown>{`Hello! Are you actually **thinking**?`}</Markdown>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="ghost">
|
||||
<BubbleContent>
|
||||
<Markdown>{`Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed.
|
||||
|
||||
This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render \`code\` in it.
|
||||
|
||||
Ghost bubbles are full width and can take the full width of the container.
|
||||
`}</Markdown>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
apps/v4/examples/base/bubble-popover.tsx
Normal file
55
apps/v4/examples/base/bubble-popover.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { InfoIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleReactions,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@/styles/base-rhea/ui/popover"
|
||||
|
||||
export function BubblePopoverDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4 py-12">
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Run the build script.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="destructive">
|
||||
<BubbleContent>Failed to run the command.</BubbleContent>
|
||||
<BubbleReactions>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Show error details"
|
||||
className="aria-expanded:text-destructive"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle className="text-sm">
|
||||
Command failed with exit code 1
|
||||
</PopoverTitle>
|
||||
<PopoverDescription className="text-sm">
|
||||
ENOENT: no such file or directory, open pnpm-lock.yaml
|
||||
</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
70
apps/v4/examples/base/bubble-reactions.tsx
Normal file
70
apps/v4/examples/base/bubble-reactions.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleReactions,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
|
||||
export function BubbleReactionsDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
|
||||
<Bubble variant="muted" align="end">
|
||||
<BubbleContent>
|
||||
I don't need tests, I know my code works.
|
||||
</BubbleContent>
|
||||
<BubbleReactions
|
||||
align="start"
|
||||
role="img"
|
||||
aria-label="Reactions: thumbs up, surprised"
|
||||
>
|
||||
<span>👍</span>
|
||||
<span>😮</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
Bold. Fine I'll add some tests. I'll let you know when
|
||||
they're done.
|
||||
</BubbleContent>
|
||||
<BubbleReactions
|
||||
role="img"
|
||||
aria-label="Reactions: eyes, rocket, and 2 more"
|
||||
>
|
||||
<span>👀</span>
|
||||
<span>🚀</span>
|
||||
<span>+2</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
<Bubble variant="default" align="end">
|
||||
<BubbleContent>
|
||||
Tests passed on the first try. All 142 of them. Looking good!
|
||||
</BubbleContent>
|
||||
<BubbleReactions
|
||||
side="top"
|
||||
align="start"
|
||||
role="img"
|
||||
aria-label="Reactions: party popper, clapping hands"
|
||||
>
|
||||
<span>🎉</span>
|
||||
<span>👏</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
<Bubble variant="destructive">
|
||||
<BubbleContent>Are you sure I can run this command?</BubbleContent>
|
||||
<BubbleReactions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => toast.success("You clicked yes, running command...")}
|
||||
>
|
||||
Yes, run it
|
||||
</Button>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
apps/v4/examples/base/bubble-tooltip.tsx
Normal file
34
apps/v4/examples/base/bubble-tooltip.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleReactions,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/styles/base-rhea/ui/tooltip"
|
||||
|
||||
export function BubbleTooltipDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-4 py-12">
|
||||
<Bubble variant="secondary">
|
||||
<BubbleContent>Did you remove the stale route?</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble align="end">
|
||||
<BubbleContent>Yes, removed it from the registry.</BubbleContent>
|
||||
<BubbleReactions>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button variant="ghost" size="icon-xs" />}>
|
||||
<CheckIcon />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Read on Jan 5, 2026 at 4:32 PM</TooltipContent>
|
||||
</Tooltip>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
apps/v4/examples/base/bubble-variants.tsx
Normal file
52
apps/v4/examples/base/bubble-variants.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Markdown } from "@/components/markdown"
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleReactions,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
|
||||
export function BubbleVariantsDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
|
||||
<Bubble>
|
||||
<BubbleContent>This is the default primary bubble.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="secondary" align="end">
|
||||
<BubbleContent>This is the secondary variant.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
This one is muted. It uses a lower emphasis color for the chat bubble.
|
||||
</BubbleContent>
|
||||
<BubbleReactions role="img" aria-label="Reaction: thumbs up">
|
||||
<span>👍</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
<Bubble variant="tinted" align="end">
|
||||
<BubbleContent>
|
||||
This one is tinted. The tint is a softer color derived from the
|
||||
primary color.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="outline">
|
||||
<BubbleContent>We can also use an outlined variant.</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="destructive" align="end">
|
||||
<BubbleContent>Or a destructive variant with a reaction.</BubbleContent>
|
||||
<BubbleReactions role="img" aria-label="Reaction: fire">
|
||||
<span>🔥</span>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
<Bubble variant="ghost">
|
||||
<BubbleContent>
|
||||
<Markdown>{`Ghost bubbles work for assistant text, **markdown**, and other content that should not be framed.
|
||||
|
||||
This is perfect for assistant messages that should not have a frame and can take the full width of the container. You can also render \`code\` in it.
|
||||
|
||||
Ghost bubbles are full width and can take the full width of the container.
|
||||
`}</Markdown>
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ function Calendar({
|
||||
: "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Minus, Plus } from "lucide-react"
|
||||
import { Bar, BarChart, ResponsiveContainer } from "recharts"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Badge } from "@/styles/base-rhea/ui/badge"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -14,121 +15,117 @@ import {
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
} from "@/styles/base-rhea/ui/drawer"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from "@/styles/base-rhea/ui/field"
|
||||
import { RadioGroup, RadioGroupItem } from "@/styles/base-rhea/ui/radio-group"
|
||||
|
||||
const data = [
|
||||
const deliveryTimes = [
|
||||
{
|
||||
goal: 400,
|
||||
value: "asap",
|
||||
id: "delivery-asap",
|
||||
label: "Standard delivery",
|
||||
description: "25–35 min · Driver assigned now",
|
||||
badge: "Fastest",
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
value: "5-00",
|
||||
id: "delivery-5-00",
|
||||
label: "5:00 PM – 5:15 PM",
|
||||
description: "Prep starts at 4:45 PM",
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
value: "5-30",
|
||||
id: "delivery-5-30",
|
||||
label: "5:30 PM – 5:45 PM",
|
||||
description: "Good if you're heading home",
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
value: "6-00",
|
||||
id: "delivery-6-00",
|
||||
label: "6:00 PM – 6:15 PM",
|
||||
description: "Most popular · High demand",
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 278,
|
||||
},
|
||||
{
|
||||
goal: 189,
|
||||
},
|
||||
{
|
||||
goal: 239,
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 278,
|
||||
},
|
||||
{
|
||||
goal: 189,
|
||||
},
|
||||
{
|
||||
goal: 349,
|
||||
value: "6-30",
|
||||
id: "delivery-6-30",
|
||||
label: "6:30 PM – 6:45 PM",
|
||||
description: "Last slot before kitchen closes",
|
||||
},
|
||||
]
|
||||
|
||||
export function DrawerDemo() {
|
||||
const [goal, setGoal] = React.useState(350)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [deliveryTime, setDeliveryTime] = React.useState("asap")
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
function onClick(adjustment: number) {
|
||||
setGoal(Math.max(200, Math.min(400, goal + adjustment)))
|
||||
function handleConfirm() {
|
||||
const selected = deliveryTimes.find((time) => time.value === deliveryTime)
|
||||
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
toast("Delivery time confirmed", {
|
||||
description: selected.label,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline">Open Drawer</Button>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
showSwipeHandle={isMobile}
|
||||
swipeDirection={isMobile ? "down" : "right"}
|
||||
>
|
||||
<DrawerTrigger render={<Button variant="secondary" />}>
|
||||
Open Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Move Goal</DrawerTitle>
|
||||
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="p-4 pb-0">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={() => onClick(-10)}
|
||||
disabled={goal <= 200}
|
||||
>
|
||||
<Minus />
|
||||
<span className="sr-only">Decrease</span>
|
||||
</Button>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-7xl font-bold tracking-tighter">
|
||||
{goal}
|
||||
</div>
|
||||
<div className="text-[0.70rem] text-muted-foreground uppercase">
|
||||
Calories/day
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={() => onClick(10)}
|
||||
disabled={goal >= 400}
|
||||
>
|
||||
<Plus />
|
||||
<span className="sr-only">Increase</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 h-[120px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<Bar
|
||||
dataKey="goal"
|
||||
style={
|
||||
{
|
||||
fill: "var(--chart-1)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Pick a delivery time</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
We'll prepare your order as soon as possible.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 scroll-fade overflow-y-auto p-4">
|
||||
<RadioGroup
|
||||
value={deliveryTime}
|
||||
onValueChange={setDeliveryTime}
|
||||
className="gap-2"
|
||||
>
|
||||
{deliveryTimes.map((time) => (
|
||||
<FieldLabel key={time.value} htmlFor={time.id}>
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldTitle className="flex items-center gap-2">
|
||||
{time.label}
|
||||
{time.badge ? (
|
||||
<Badge variant="secondary">{time.badge}</Badge>
|
||||
) : null}
|
||||
</FieldTitle>
|
||||
<FieldDescription>{time.description}</FieldDescription>
|
||||
</FieldContent>
|
||||
<RadioGroupItem value={time.value} id={time.id} />
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button onClick={handleConfirm} className="h-[34px]">
|
||||
Confirm Delivery Time
|
||||
</Button>
|
||||
<DrawerClose render={<Button variant="outline" />}>
|
||||
Cancel
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/styles/base-nova/ui/dialog"
|
||||
} from "@/styles/base-rhea/ui/dialog"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
import { Input } from "@/styles/base-nova/ui/input"
|
||||
import { Label } from "@/styles/base-nova/ui/label"
|
||||
} from "@/styles/base-rhea/ui/drawer"
|
||||
import { Input } from "@/styles/base-rhea/ui/input"
|
||||
import { Label } from "@/styles/base-rhea/ui/label"
|
||||
|
||||
export function DrawerDialogDemo() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
@@ -52,8 +52,8 @@ export function DrawerDialogDemo() {
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline">Edit Profile</Button>
|
||||
<DrawerTrigger render={<Button variant="outline" />}>
|
||||
Edit Profile
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader className="text-left">
|
||||
@@ -62,12 +62,7 @@ export function DrawerDialogDemo() {
|
||||
Make changes to your profile here. Click save when you're done.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<ProfileForm className="px-4" />
|
||||
<DrawerFooter className="pt-2">
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
<ProfileForm className="p-4" />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
111
apps/v4/examples/base/drawer-nested.tsx
Normal file
111
apps/v4/examples/base/drawer-nested.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-rhea/ui/drawer"
|
||||
|
||||
export function DrawerNested() {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const swipeDirection = isMobile ? "down" : "right"
|
||||
|
||||
return (
|
||||
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
|
||||
<DrawerTrigger render={<Button variant="secondary" />}>
|
||||
Open Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Drawer</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Open another drawer from the same direction.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Drawer showSwipeHandle={isMobile} swipeDirection={swipeDirection}>
|
||||
<DrawerTrigger render={<Button variant="outline" />}>
|
||||
Open Nested Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Nested Drawer</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
The parent drawer stays mounted behind this one.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Drawer
|
||||
showSwipeHandle={isMobile}
|
||||
swipeDirection={swipeDirection}
|
||||
>
|
||||
<DrawerTrigger render={<Button variant="outline" />}>
|
||||
Open Third Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Third Drawer</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Two drawers are stacked behind this one.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Drawer
|
||||
showSwipeHandle={isMobile}
|
||||
swipeDirection={swipeDirection}
|
||||
>
|
||||
<DrawerTrigger render={<Button variant="outline" />}>
|
||||
Open Fourth Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Fourth Drawer</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
This is the frontmost drawer in the stack.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<DrawerClose render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<DrawerClose render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<DrawerClose render={<Button variant="outline" />}>Close</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
31
apps/v4/examples/base/drawer-non-modal.tsx
Normal file
31
apps/v4/examples/base/drawer-non-modal.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-rhea/ui/drawer"
|
||||
|
||||
export function DrawerNonModal() {
|
||||
return (
|
||||
<Drawer modal={false} disablePointerDismissal swipeDirection="right">
|
||||
<DrawerTrigger render={<Button variant="outline" />}>
|
||||
Non Modal
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Non Modal Drawer</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose render={<Button />}>Close</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Minus, Plus } from "lucide-react"
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis } from "recharts"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import {
|
||||
useTranslation,
|
||||
type Translations,
|
||||
} from "@/components/language-selector"
|
||||
import { Badge } from "@/styles/base-nova/ui-rtl/badge"
|
||||
import { Button } from "@/styles/base-nova/ui-rtl/button"
|
||||
import {
|
||||
Drawer,
|
||||
@@ -19,62 +20,40 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui-rtl/drawer"
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from "@/styles/base-nova/ui-rtl/field"
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/styles/base-nova/ui-rtl/radio-group"
|
||||
|
||||
const data = [
|
||||
{
|
||||
goal: 400,
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 278,
|
||||
},
|
||||
{
|
||||
goal: 189,
|
||||
},
|
||||
{
|
||||
goal: 239,
|
||||
},
|
||||
{
|
||||
goal: 300,
|
||||
},
|
||||
{
|
||||
goal: 200,
|
||||
},
|
||||
{
|
||||
goal: 278,
|
||||
},
|
||||
{
|
||||
goal: 189,
|
||||
},
|
||||
{
|
||||
goal: 349,
|
||||
},
|
||||
]
|
||||
|
||||
const translations: Translations = {
|
||||
const translations = {
|
||||
en: {
|
||||
dir: "ltr",
|
||||
locale: "en-US",
|
||||
values: {
|
||||
trigger: "Open Drawer",
|
||||
title: "Move Goal",
|
||||
description: "Set your daily activity goal.",
|
||||
caloriesPerDay: "Calories/day",
|
||||
decrease: "Decrease",
|
||||
increase: "Increase",
|
||||
submit: "Submit",
|
||||
title: "Pick a delivery time",
|
||||
description: "We'll prepare your order as soon as possible.",
|
||||
confirm: "Confirm Delivery Time",
|
||||
cancel: "Cancel",
|
||||
toastTitle: "Delivery time confirmed",
|
||||
asapLabel: "Standard delivery",
|
||||
asapDescription: "25–35 min · Driver assigned now",
|
||||
asapBadge: "Fastest",
|
||||
slot500Label: "5:00 PM – 5:15 PM",
|
||||
slot500Description: "Prep starts at 4:45 PM",
|
||||
slot530Label: "5:30 PM – 5:45 PM",
|
||||
slot530Description: "Good if you're heading home",
|
||||
slot600Label: "6:00 PM – 6:15 PM",
|
||||
slot600Description: "Most popular · High demand",
|
||||
slot630Label: "6:30 PM – 6:45 PM",
|
||||
slot630Description: "Last slot before kitchen closes",
|
||||
},
|
||||
},
|
||||
ar: {
|
||||
@@ -82,13 +61,22 @@ const translations: Translations = {
|
||||
locale: "ar-EG",
|
||||
values: {
|
||||
trigger: "فتح الدرج",
|
||||
title: "نقل الهدف",
|
||||
description: "حدد هدف نشاطك اليومي.",
|
||||
caloriesPerDay: "سعرات حرارية/يوم",
|
||||
decrease: "تقليل",
|
||||
increase: "زيادة",
|
||||
submit: "إرسال",
|
||||
title: "اختر وقت التوصيل",
|
||||
description: "سنجهز طلبك في أقرب وقت ممكن.",
|
||||
confirm: "تأكيد وقت التوصيل",
|
||||
cancel: "إلغاء",
|
||||
toastTitle: "تم تأكيد وقت التوصيل",
|
||||
asapLabel: "توصيل قياسي",
|
||||
asapDescription: "25–35 دقيقة · تم تعيين السائق الآن",
|
||||
asapBadge: "الأسرع",
|
||||
slot500Label: "5:00 م – 5:15 م",
|
||||
slot500Description: "يبدأ التحضير في 4:45 م",
|
||||
slot530Label: "5:30 م – 5:45 م",
|
||||
slot530Description: "مناسب إذا كنت في الطريق إلى المنزل",
|
||||
slot600Label: "6:00 م – 6:15 م",
|
||||
slot600Description: "الأكثر شيوعًا · طلب مرتفع",
|
||||
slot630Label: "6:30 م – 6:45 م",
|
||||
slot630Description: "آخر موعد قبل إغلاق المطبخ",
|
||||
},
|
||||
},
|
||||
he: {
|
||||
@@ -96,97 +84,137 @@ const translations: Translations = {
|
||||
locale: "he-IL",
|
||||
values: {
|
||||
trigger: "פתח מגירה",
|
||||
title: "הזז מטרה",
|
||||
description: "הגדר את יעד הפעילות היומי שלך.",
|
||||
caloriesPerDay: "קלוריות/יום",
|
||||
decrease: "הקטן",
|
||||
increase: "הגדל",
|
||||
submit: "שלח",
|
||||
title: "בחר זמן משלוח",
|
||||
description: "נכין את ההזמנה שלך בהקדם האפשרי.",
|
||||
confirm: "אשר זמן משלוח",
|
||||
cancel: "בטל",
|
||||
toastTitle: "זמן המשלוח אושר",
|
||||
asapLabel: "משלוח רגיל",
|
||||
asapDescription: "25–35 דק׳ · נהג הוקצה כעת",
|
||||
asapBadge: "הכי מהיר",
|
||||
slot500Label: "17:00 – 17:15",
|
||||
slot500Description: "ההכנה מתחילה ב-16:45",
|
||||
slot530Label: "17:30 – 17:45",
|
||||
slot530Description: "מתאים אם אתה בדרך הביתה",
|
||||
slot600Label: "18:00 – 18:15",
|
||||
slot600Description: "הפופולרי ביותר · ביקוש גבוה",
|
||||
slot630Label: "18:30 – 18:45",
|
||||
slot630Description: "המשבצת האחרונה לפני סגירת המטבח",
|
||||
},
|
||||
},
|
||||
}
|
||||
} satisfies Translations
|
||||
|
||||
type TranslationKey = keyof typeof translations.en.values
|
||||
|
||||
const deliveryTimes: Array<{
|
||||
value: string
|
||||
id: string
|
||||
labelKey: TranslationKey
|
||||
descriptionKey: TranslationKey
|
||||
badgeKey?: TranslationKey
|
||||
}> = [
|
||||
{
|
||||
value: "asap",
|
||||
id: "delivery-asap-rtl",
|
||||
labelKey: "asapLabel",
|
||||
descriptionKey: "asapDescription",
|
||||
badgeKey: "asapBadge",
|
||||
},
|
||||
{
|
||||
value: "5-00",
|
||||
id: "delivery-5-00-rtl",
|
||||
labelKey: "slot500Label",
|
||||
descriptionKey: "slot500Description",
|
||||
},
|
||||
{
|
||||
value: "5-30",
|
||||
id: "delivery-5-30-rtl",
|
||||
labelKey: "slot530Label",
|
||||
descriptionKey: "slot530Description",
|
||||
},
|
||||
{
|
||||
value: "6-00",
|
||||
id: "delivery-6-00-rtl",
|
||||
labelKey: "slot600Label",
|
||||
descriptionKey: "slot600Description",
|
||||
},
|
||||
{
|
||||
value: "6-30",
|
||||
id: "delivery-6-30-rtl",
|
||||
labelKey: "slot630Label",
|
||||
descriptionKey: "slot630Description",
|
||||
},
|
||||
]
|
||||
|
||||
export function DrawerRtl() {
|
||||
const { dir, locale, language, t } = useTranslation(translations, "ar")
|
||||
const [goal, setGoal] = React.useState(350)
|
||||
const { dir, language, t } = useTranslation(translations, "ar")
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [deliveryTime, setDeliveryTime] = React.useState("asap")
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
function onClick(adjustment: number) {
|
||||
setGoal(Math.max(200, Math.min(400, goal + adjustment)))
|
||||
function handleConfirm() {
|
||||
const selected = deliveryTimes.find((time) => time.value === deliveryTime)
|
||||
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
toast(t.toastTitle, {
|
||||
description: t[selected.labelKey],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline">{t.trigger}</Button>
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
showSwipeHandle={isMobile}
|
||||
swipeDirection={isMobile ? "down" : "right"}
|
||||
>
|
||||
<DrawerTrigger render={<Button variant="secondary" />}>
|
||||
{t.trigger}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent dir={dir} data-lang={dir === "rtl" ? language : undefined}>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t.title}</DrawerTitle>
|
||||
<DrawerDescription>{t.description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="p-4 pb-0">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={() => onClick(-10)}
|
||||
disabled={goal <= 200}
|
||||
>
|
||||
<Minus />
|
||||
<span className="sr-only">{t.decrease}</span>
|
||||
</Button>
|
||||
<div className="flex-1 text-center">
|
||||
<div className="text-7xl font-bold tracking-tighter">
|
||||
{goal.toLocaleString(locale)}
|
||||
</div>
|
||||
<div className="text-[0.70rem] text-muted-foreground uppercase">
|
||||
{t.caloriesPerDay}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 rounded-full"
|
||||
onClick={() => onClick(10)}
|
||||
disabled={goal >= 400}
|
||||
>
|
||||
<Plus />
|
||||
<span className="sr-only">{t.increase}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 h-[120px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data}>
|
||||
<XAxis
|
||||
dataKey="goal"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.toLocaleString(locale)}
|
||||
reversed={dir === "rtl"}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="goal"
|
||||
style={
|
||||
{
|
||||
fill: "var(--chart-2)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>{t.submit}</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">{t.cancel}</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t.title}</DrawerTitle>
|
||||
<DrawerDescription>{t.description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 scroll-fade overflow-y-auto p-4">
|
||||
<RadioGroup
|
||||
value={deliveryTime}
|
||||
onValueChange={setDeliveryTime}
|
||||
className="gap-2"
|
||||
dir={dir}
|
||||
>
|
||||
{deliveryTimes.map((time) => (
|
||||
<FieldLabel key={time.value} htmlFor={time.id} dir={dir}>
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldTitle className="flex items-center gap-2">
|
||||
{t[time.labelKey]}
|
||||
{time.badgeKey ? (
|
||||
<Badge variant="secondary">{t[time.badgeKey]}</Badge>
|
||||
) : null}
|
||||
</FieldTitle>
|
||||
<FieldDescription dir={dir}>
|
||||
{t[time.descriptionKey]}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
<RadioGroupItem value={time.value} id={time.id} dir={dir} />
|
||||
</Field>
|
||||
</FieldLabel>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button onClick={handleConfirm} className="h-[34px]">
|
||||
{t.confirm}
|
||||
</Button>
|
||||
<DrawerClose render={<Button variant="outline" />}>
|
||||
{t.cancel}
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
|
||||
export function DrawerScrollableContent() {
|
||||
return (
|
||||
<Drawer direction="right">
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline">Scrollable Content</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Move Goal</DrawerTitle>
|
||||
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="no-scrollbar overflow-y-auto px-4">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<p key={index} className="mb-4 leading-normal">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
|
||||
enim ad minim veniam, quis nostrud exercitation ullamco laboris
|
||||
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat
|
||||
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
|
||||
sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from "@/styles/base-nova/ui/button"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -8,55 +8,26 @@ import {
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-nova/ui/drawer"
|
||||
|
||||
const DRAWER_SIDES = ["top", "right", "bottom", "left"] as const
|
||||
} from "@/styles/base-rhea/ui/drawer"
|
||||
|
||||
export function DrawerWithSides() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DRAWER_SIDES.map((side) => (
|
||||
<Drawer
|
||||
key={side}
|
||||
direction={
|
||||
side === "bottom" ? undefined : (side as "top" | "right" | "left")
|
||||
}
|
||||
>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline" className="capitalize">
|
||||
{side}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="data-[vaul-drawer-direction=bottom]:max-h-[50vh] data-[vaul-drawer-direction=top]:max-h-[50vh]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Move Goal</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Set your daily activity goal.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="no-scrollbar overflow-y-auto px-4">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<p key={index} className="mb-4 leading-normal">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
|
||||
do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco
|
||||
laboris nisi ut aliquip ex ea commodo consequat. Duis aute
|
||||
irure dolor in reprehenderit in voluptate velit esse cillum
|
||||
dolore eu fugiat nulla pariatur. Excepteur sint occaecat
|
||||
cupidatat non proident, sunt in culpa qui officia deserunt
|
||||
mollit anim id est laborum.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<Button>Submit</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
))}
|
||||
</div>
|
||||
<Drawer swipeDirection="left">
|
||||
<DrawerTrigger render={<Button variant="secondary" />}>
|
||||
Open Left Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Move Goal</DrawerTitle>
|
||||
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="size-full rounded-2xl bg-muted" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose render={<Button />}>Close</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
40
apps/v4/examples/base/drawer-snap-points.tsx
Normal file
40
apps/v4/examples/base/drawer-snap-points.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-rhea/ui/drawer"
|
||||
|
||||
const SNAP_POINTS = ["31rem", 1]
|
||||
|
||||
export function DrawerSnapPoints() {
|
||||
return (
|
||||
<Drawer snapPoints={SNAP_POINTS} showSwipeHandle>
|
||||
<DrawerTrigger render={<Button variant="outline" />}>
|
||||
Open Snap Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Snap points</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Drag the drawer to snap between a compact peek and a near
|
||||
full-height view.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose render={<Button />}>Close</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
35
apps/v4/examples/base/drawer-swipe-handle.tsx
Normal file
35
apps/v4/examples/base/drawer-swipe-handle.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/styles/base-rhea/ui/drawer"
|
||||
|
||||
export function DrawerSwipeHandle() {
|
||||
return (
|
||||
<Drawer showSwipeHandle>
|
||||
<DrawerTrigger render={<Button variant="secondary" />}>
|
||||
Open Drawer
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Drawer</DrawerTitle>
|
||||
<DrawerDescription>Drawer with a swipe handle.</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 p-4">
|
||||
<div className="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full" />
|
||||
</div>
|
||||
<DrawerFooter>
|
||||
<DrawerClose render={<Button />}>Close</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
43
apps/v4/examples/base/markdown-demo.tsx
Normal file
43
apps/v4/examples/base/markdown-demo.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { Markdown } from "@/components/markdown"
|
||||
|
||||
const markdown = `## Getting started
|
||||
|
||||
Markdown lets you write formatted text with a simple syntax.
|
||||
|
||||
### Features
|
||||
|
||||
- **Bold** and *italic* text
|
||||
- [Links](https://example.com) and \`inline code\`
|
||||
- Ordered and unordered lists
|
||||
- Tables, blockquotes, and code blocks
|
||||
|
||||
| Syntax | Result |
|
||||
| --- | --- |
|
||||
| \`**bold**\` | **bold** |
|
||||
| \`*italic*\` | *italic* |
|
||||
| \`\`code\`\` | \`code\` |
|
||||
|
||||
How about a quote? How does this look?
|
||||
|
||||
> The best way to learn markdown is to write it. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.
|
||||
|
||||
Now let's try a code block. It should have line numbers, a copy button and syntax highlighting.
|
||||
|
||||
\`\`\`tsx
|
||||
export function Greeting({ name }: { name: string }) {
|
||||
return <p>Hello, {name}!</p>
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
If you need more plugins, you can pass them to the \`Markdown\` component.
|
||||
`
|
||||
|
||||
export function MarkdownDemo() {
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<Markdown>{markdown}</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
apps/v4/examples/base/marker-border.tsx
Normal file
28
apps/v4/examples/base/marker-border.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { FileTextIcon, GitBranchIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
|
||||
|
||||
export function MarkerBorderDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-3 py-12">
|
||||
<Marker variant="border">
|
||||
<MarkerIcon>
|
||||
<GitBranchIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Switched to release-candidate</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="border">
|
||||
<MarkerIcon>
|
||||
<SearchIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Reviewed 8 related files</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="border">
|
||||
<MarkerIcon>
|
||||
<FileTextIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Opened implementation notes</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
apps/v4/examples/base/marker-demo.tsx
Normal file
32
apps/v4/examples/base/marker-demo.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { GitBranchIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
|
||||
import { Spinner } from "@/styles/base-rhea/ui/spinner"
|
||||
|
||||
export function MarkerDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Marker>
|
||||
<MarkerIcon>
|
||||
<GitBranchIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Switched to a new branch</MarkerContent>
|
||||
</Marker>
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent className="shimmer">Thinking...</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Conversation compacted</MarkerContent>
|
||||
</Marker>
|
||||
<Marker>
|
||||
<MarkerIcon>
|
||||
<SearchIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Explored 4 files</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
apps/v4/examples/base/marker-icon.tsx
Normal file
28
apps/v4/examples/base/marker-icon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BookOpenCheck, GitBranchIcon, SearchIcon } from "lucide-react"
|
||||
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
|
||||
|
||||
export function MarkerIconDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-12 py-12">
|
||||
<Marker>
|
||||
<MarkerIcon>
|
||||
<GitBranchIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Switched to a new branch</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="separator">
|
||||
<MarkerIcon>
|
||||
<SearchIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Explored 4 files</MarkerContent>
|
||||
</Marker>
|
||||
<Marker className="flex-col">
|
||||
<MarkerIcon>
|
||||
<BookOpenCheck />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Syncing completed</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
apps/v4/examples/base/marker-link-button.tsx
Normal file
33
apps/v4/examples/base/marker-link-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { GitBranchIcon, RotateCcwIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
|
||||
|
||||
export function MarkerLinkButtonDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Marker render={<a href="#links-and-buttons" />}>
|
||||
<MarkerIcon>
|
||||
<GitBranchIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>View the pull request</MarkerContent>
|
||||
</Marker>
|
||||
<Marker
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="transition-colors hover:text-foreground"
|
||||
onClick={() => toast("You clicked the revert button")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MarkerIcon>
|
||||
<RotateCcwIcon />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Revert this change</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
apps/v4/examples/base/marker-separator.tsx
Normal file
17
apps/v4/examples/base/marker-separator.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Marker, MarkerContent } from "@/styles/base-rhea/ui/marker"
|
||||
|
||||
export function MarkerSeparatorDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Today</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Worked for 42s</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Conversation compacted</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
apps/v4/examples/base/marker-shimmer.tsx
Normal file
14
apps/v4/examples/base/marker-shimmer.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Marker, MarkerContent } from "@/styles/base-rhea/ui/marker"
|
||||
|
||||
export function MarkerShimmerDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Marker role="status">
|
||||
<MarkerContent className="shimmer">Thinking...</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="separator" role="status">
|
||||
<MarkerContent className="shimmer">Reading 4 files</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
apps/v4/examples/base/marker-status.tsx
Normal file
21
apps/v4/examples/base/marker-status.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Marker, MarkerContent, MarkerIcon } from "@/styles/base-rhea/ui/marker"
|
||||
import { Spinner } from "@/styles/base-rhea/ui/spinner"
|
||||
|
||||
export function MarkerStatusDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Compacting conversation</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="separator" role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Running tests</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
apps/v4/examples/base/marker-variants.tsx
Normal file
17
apps/v4/examples/base/marker-variants.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Marker, MarkerContent } from "@/styles/base-rhea/ui/marker"
|
||||
|
||||
export function MarkerVariantsDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Marker>
|
||||
<MarkerContent>A default marker for inline notes.</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>A separator marker</MarkerContent>
|
||||
</Marker>
|
||||
<Marker variant="border">
|
||||
<MarkerContent>A border marker for row boundaries.</MarkerContent>
|
||||
</Marker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
apps/v4/examples/base/message-actions.tsx
Normal file
64
apps/v4/examples/base/message-actions.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
CopyIcon,
|
||||
RefreshCcwIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
|
||||
import { Button } from "@/styles/base-rhea/ui/button"
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageFooter,
|
||||
} from "@/styles/base-rhea/ui/message"
|
||||
|
||||
export function MessageActionsDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Message>
|
||||
<MessageContent>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
The install failure is coming from the workspace package.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter>
|
||||
<Button variant="ghost" size="icon" aria-label="Copy" title="Copy">
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" aria-label="Like" title="Like">
|
||||
<ThumbsUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Dislike"
|
||||
title="Dislike"
|
||||
>
|
||||
<ThumbsDownIcon />
|
||||
</Button>
|
||||
</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<Message align="end">
|
||||
<MessageContent>
|
||||
<Bubble>
|
||||
<BubbleContent>Okay drop me a link. Taking a look...</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter className="gap-2">
|
||||
<span className="font-normal text-destructive">Failed to send</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
title="Retry"
|
||||
aria-label="Retry"
|
||||
>
|
||||
<RefreshCcwIcon />
|
||||
</Button>
|
||||
</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
apps/v4/examples/base/message-attachment.tsx
Normal file
76
apps/v4/examples/base/message-attachment.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { DownloadIcon, FileTextIcon } from "lucide-react"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/styles/base-rhea/ui/attachment"
|
||||
import { Bubble, BubbleContent } from "@/styles/base-rhea/ui/bubble"
|
||||
import { Message, MessageContent } from "@/styles/base-rhea/ui/message"
|
||||
|
||||
export function MessageAttachmentDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-8 py-12">
|
||||
<Message align="end">
|
||||
<MessageContent>
|
||||
<Attachment orientation="vertical">
|
||||
<AttachmentMedia variant="image">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80"
|
||||
alt="Workspace"
|
||||
/>
|
||||
</AttachmentMedia>
|
||||
</Attachment>
|
||||
<Bubble>
|
||||
<BubbleContent>
|
||||
Here's the image. Can you add it to the PDF? Use it for the
|
||||
cover page.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<Message>
|
||||
<MessageContent>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
Done. Here's the PDF with the image added as the cover page.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
<Attachment>
|
||||
<AttachmentMedia>
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>sales-dashboard.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction
|
||||
type="button"
|
||||
title="Download"
|
||||
aria-label="Download"
|
||||
size="icon-sm"
|
||||
variant="secondary"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<Message align="end">
|
||||
<MessageContent>
|
||||
<Bubble>
|
||||
<BubbleContent>Thanks. Looks good.</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
apps/v4/examples/base/message-avatar.tsx
Normal file
71
apps/v4/examples/base/message-avatar.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/styles/base-rhea/ui/avatar"
|
||||
import {
|
||||
Bubble,
|
||||
BubbleContent,
|
||||
BubbleGroup,
|
||||
} from "@/styles/base-rhea/ui/bubble"
|
||||
import {
|
||||
Message,
|
||||
MessageAvatar,
|
||||
MessageContent,
|
||||
} from "@/styles/base-rhea/ui/message"
|
||||
|
||||
export function MessageAvatarDemo() {
|
||||
return (
|
||||
<div className="flex w-full max-w-sm flex-col gap-6 py-12">
|
||||
<Message>
|
||||
<MessageAvatar>
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/03.png" alt="@avatar" />
|
||||
<AvatarFallback>R</AvatarFallback>
|
||||
</Avatar>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
The build failed during dependency installation.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<Message align="end">
|
||||
<MessageAvatar>
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/10.png" alt="@avatar" />
|
||||
<AvatarFallback>R</AvatarFallback>
|
||||
</Avatar>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<Bubble>
|
||||
<BubbleContent>Can you share the exact error?</BubbleContent>
|
||||
</Bubble>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
<Message>
|
||||
<MessageAvatar>
|
||||
<Avatar>
|
||||
<AvatarImage src="/avatars/03.png" alt="@avatar" />
|
||||
<AvatarFallback>R</AvatarFallback>
|
||||
</Avatar>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<BubbleGroup>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>Here's the error from the logs</BubbleContent>
|
||||
</Bubble>
|
||||
<Bubble variant="muted">
|
||||
<BubbleContent>
|
||||
Something went wrong with the build. The libraries are not
|
||||
installed correctly. Try running the build again.
|
||||
</BubbleContent>
|
||||
</Bubble>
|
||||
</BubbleGroup>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user