feat(P01): 迁移 Taro 小程序项目代码

- 迁移前端源码 (src/)
- 迁移后端服务 (server/)
- 迁移配置文件 (package.json, tsconfig.json 等)
- 更新需求概要文档
- 更新架构设计文档
- 更新接口定义文档
- 更新环境配置文档
- 创建测试目录结构和配置

项目技术栈:
- Taro 4.1.9 (跨端框架)
- React 18
- TypeScript
- NestJS (后端)
- Tailwind CSS 4
- shadcn/ui 组件库
This commit is contained in:
Dev AI
2026-05-22 16:25:05 +08:00
parent 00ce240c01
commit fb348f3740
106 changed files with 13015 additions and 182 deletions
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Dev AI",
"role": "代码开发者",
"responsibilities": [
"编写业务代码",
"生成技术文档",
"维护代码质量"
],
"allowed_paths": [
"projects/*/src/",
"projects/*/docs/",
"shared/",
"review/*/task.md",
"review/*/acceptance.md",
"review/*/impact.md"
],
"forbidden_paths": [
"projects/*/tests/",
"reports/",
"review/*/feedback/"
],
"prompt_templates": {
"coding": ".ai/prompts/coding/",
"documentation": ".ai/prompts/coding/"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"name": "QA AI",
"role": "测试工程师",
"responsibilities": [
"编写测试用例",
"执行测试",
"生成测试报告",
"提供反馈"
],
"allowed_paths": [
"projects/*/tests/",
"reports/",
"review/*/acceptance.md",
"review/*/feedback/"
],
"forbidden_paths": [
"projects/*/src/",
"projects/*/docs/",
"shared/",
"review/*/task.md",
"review/*/impact.md"
],
"prompt_templates": {
"testing": ".ai/prompts/testing/"
}
}
+53
View File
@@ -0,0 +1,53 @@
{
"workflow": "human-ai-collaboration",
"roles": ["human", "dev-ai", "qa-ai"],
"stages": [
{
"name": "需求分析",
"actor": "human",
"output": "review/{task_id}/task.md"
},
{
"name": "开发实现",
"actor": "dev-ai",
"input": "review/{task_id}/task.md",
"output": ["projects/*/src/", "projects/*/docs/"]
},
{
"name": "影响评估",
"actor": "dev-ai",
"output": "review/{task_id}/impact.md"
},
{
"name": "验收标准定义",
"actor": "dev-ai",
"output": "review/{task_id}/acceptance.md"
},
{
"name": "测试设计",
"actor": "qa-ai",
"input": ["review/{task_id}/task.md", "review/{task_id}/acceptance.md"],
"output": "projects/*/tests/"
},
{
"name": "测试执行",
"actor": "qa-ai",
"output": ["reports/test-results/", "reports/reviews/"]
},
{
"name": "反馈提交",
"actor": "qa-ai",
"output": "review/{task_id}/feedback/round{round}.md"
},
{
"name": "验收确认",
"actor": "human",
"input": ["review/{task_id}/feedback/", "reports/test-results/"]
}
],
"ci_triggers": {
"on_push_to_main": ["run-tests", "generate-reports"],
"on_pr_open": ["run-tests", "code-review"],
"on_task_update": ["notify-qa-ai"]
}
}
+1
View File
@@ -0,0 +1 @@
# coding
+1
View File
@@ -0,0 +1 @@
# testing
+188
View File
@@ -0,0 +1,188 @@
# AI 角色定义与权限约定
## 团队架构
```
┌─────────────────────────────────────────────┐
│ 人类负责人 │
│ 需求分析 · 架构设计 · 最终决策 │
└───────────────────┬─────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Dev AI │ │ QA AI │
│ 代码编写 │ │ 测试设计 │
│ 文档生成 │ │ 测试执行 │
│ 影响评估 │ │ 质量保障 │
└───────────────┘ └───────────────┘
```
---
## 角色职责
### Dev AI (编码AI)
**职责范围:**
- ✅ 编写业务代码 (`projects/*/src/`)
- ✅ 生成技术文档 (`projects/*/docs/`)
- ✅ 定义验收标准 (`review/*/acceptance.md`)
- ✅ 评估变更影响 (`review/*/impact.md`)
- ✅ 维护共享资源 (`shared/`)
**禁止操作:**
- ❌ 修改测试代码 (`projects/*/tests/`)
- ❌ 修改测试报告 (`reports/`)
- ❌ 提交测试反馈 (`review/*/feedback/`)
### QA AI (测试AI)
**职责范围:**
- ✅ 编写测试用例 (`projects/*/tests/`)
- ✅ 执行测试并生成报告 (`reports/`)
- ✅ 补充验收标准 (`review/*/acceptance.md`)
- ✅ 提交测试反馈 (`review/*/feedback/`)
**禁止操作:**
- ❌ 修改业务代码 (`projects/*/src/`)
- ❌ 修改技术文档 (`projects/*/docs/`)
- ❌ 修改共享资源 (`shared/`)
- ❌ 修改任务描述和影响评估
### 人类负责人
**职责范围:**
- ✅ 可以修改所有目录
- ✅ 审核 AI 输出质量
- ✅ 解决 AI 之间的冲突
- ✅ 最终决策和验收
---
## 工作流程
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 需求分析 │ ──→ │ 开发实现 │ ──→ │ 影响评估 │
│ (人类) │ │ (Dev AI) │ │ (Dev AI) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 验收标准 │ ──→ │ 测试设计 │ ──→ │ 测试执行 │
│ (Dev AI) │ │ (QA AI) │ │ (QA AI) │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐ ┌─────────────┐
│ 反馈提交 │ ──→ │ 验收确认 │
│ (QA AI) │ │ (人类) │
└─────────────┘ └─────────────┘
```
### 详细流程说明
**1. 需求分析阶段**
- 人类负责人创建任务单
- 输出: `review/{task_id}/task.md`
**2. 开发实现阶段**
- Dev AI 根据任务描述编写代码
- 输出: `projects/*/src/`, `projects/*/docs/`
**3. 影响评估阶段**
- Dev AI 分析变更影响范围
- 输出: `review/{task_id}/impact.md`
**4. 验收标准定义**
- Dev AI 定义验收标准
- QA AI 可补充测试要点
- 输出: `review/{task_id}/acceptance.md`
**5. 测试设计阶段**
- QA AI 根据验收标准编写测试用例
- 输出: `projects/*/tests/`
**6. 测试执行阶段**
- QA AI 执行测试并生成报告
- 输出: `reports/test-results/`, `reports/quality-reports/`
**7. 反馈提交阶段**
- QA AI 提交测试反馈
- 输出: `review/{task_id}/feedback/round{round}.md`
**8. 验收确认阶段**
- 人类负责人审核测试结果
- 确认任务完成或返回修改
---
## 目录权限矩阵
| 目录路径 | Dev AI | QA AI | 人类 |
|---------|--------|-------|------|
| `.ai/` | ❌ | ❌ | ✅ |
| `shared/` | ✅ | ❌ | ✅ |
| `projects/*/src/` | ✅ | ❌ | ✅ |
| `projects/*/tests/` | ❌ | ✅ | ✅ |
| `projects/*/docs/` | ✅ | ❌ | ✅ |
| `review/*/task.md` | ❌ | ❌ | ✅ |
| `review/*/acceptance.md` | ✅ | ✅ | ✅ |
| `review/*/impact.md` | ✅ | ❌ | ✅ |
| `review/*/feedback/` | ❌ | ✅ | ✅ |
| `reports/` | ❌ | ✅ | ✅ |
| `.github/` | ❌ | ❌ | ✅ |
---
## 沟通规范
### Dev AI → QA AI
在 `review/{task_id}/` 目录提交:
- **验收标准** (`acceptance.md`) - 明确测试目标
- **变更影响范围** (`impact.md`) - 指导回归测试
- **环境准备** 参考项目级 `ENVIRONMENT.md`
### QA AI → Dev AI
在 `review/{task_id}/feedback/` 目录提交:
- **测试结果报告** (`round{round}.md`)
- **Bug清单** - 列出问题和严重程度
- **改进建议** - 代码优化建议
---
## 命名规范
### 项目命名
```
P01_项目名称 # P01 表示项目编号
```
### 任务编号
```
P01-001 # P01 项目编号 + 001 任务编号
```
### 分支命名
```
feature/P01-001-login # 功能开发
bugfix/P01-001-password # Bug修复
test/P01-001-testcases # 测试用例
```
### 提交信息
```
feat(P01-001): 实现用户登录功能
fix(P01-001): 修复密码验证问题
docs(P01-001): 更新接口文档
test(P01-001): 添加登录测试用例
```
---
## AI 配置文件说明
| 文件 | 说明 |
|------|------|
| `.ai/config/coder.json` | Dev AI 配置(权限、职责) |
| `.ai/config/tester.json` | QA AI 配置(权限、职责) |
| `.ai/config/workflow.json` | 工作流配置(阶段、触发器) |
| `.ai/prompts/coding/` | 编码提示词模板 |
| `.ai/prompts/testing/` | 测试提示词模板 |
+41
View File
@@ -0,0 +1,41 @@
# Dependencies
node_modules/
.pnpm-store/
node-compile-cache/
# Production
dist/
build/
dist-*/
# Misc
.DS_Store
.env
# .env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Taro specific
.taro/
# OS X
.DS_Store
# Key
# key/
+21
View File
@@ -0,0 +1,21 @@
loglevel=error
registry=https://registry.npmmirror.com
strictStorePkgContentCheck=false
verifyStoreIntegrity=false
network-concurrency=16
fetch-retries=3
fetch-timeout=60000
strict-peer-dependencies=false
auto-install-peers=true
lockfile=true
prefer-frozen-lockfile=true
resolution-mode=highest
# 不提示 npm 更新
update-notifier=false
+222 -26
View File
@@ -1,37 +1,233 @@
# P01_errlens_app - 环境准备指南
## 依赖要求
- Node.js >= 20.x
- npm >= 10.x
- 数据库: PostgreSQL 15+
## 项目信息
- **项目名称**: ErrLens 小程序应用
- **技术栈**: Taro 4 + React 18 + NestJS
- **支持平台**: 微信小程序、抖音小程序、H5
---
## 环境要求
### 基础环境
| 依赖项 | 版本要求 | 说明 |
|--------|---------|------|
| Node.js | >= 20.x | 推荐使用 nvm 管理 |
| pnpm | >= 9.0.0 | 包管理器 |
| Git | 最新版 | 代码版本控制 |
### 数据库(后端服务)
| 依赖项 | 版本要求 | 说明 |
|--------|---------|------|
| PostgreSQL | 15+ | 主数据库 |
| Supabase | 可选 | 云端数据库替代方案 |
### 开发工具(可选)
| 工具 | 说明 |
|------|------|
| VSCode | 推荐编辑器 |
| 微信开发者工具 | 微信小程序调试 |
| 抖音开发者工具 | 抖音小程序调试 |
---
## 安装步骤
### 1. 克隆项目
```bash
# 安装依赖
npm install
# 配置环境变量
cp .env.example .env
# 初始化数据库
npm run db:migrate
git clone <repository-url>
cd errlens-project/projects/P01_errlens_app
```
## 环境变量
| 变量名 | 说明 | 默认值 |
|--------|------|--------|
| PORT | 服务端口 | 3000 |
| DATABASE_URL | 数据库连接 | postgresql://localhost:5432/errlens |
| NODE_ENV | 运行环境 | development |
### 2. 安装前端依赖
## 运行命令
```bash
# 开发模式
npm run dev
# 使用 pnpm(推荐)
pnpm install
# 生产构建
npm run build
# 或使用 npm
npm install
```
# 运行测试
npm test
```
### 3. 安装后端依赖
```bash
cd server
pnpm install
cd ..
```
### 4. 配置环境变量
```bash
# 复制环境变量模板
cp .env.example .env
# 编辑 .env 文件,配置以下变量
```
**前端环境变量 (`.env`)**:
```bash
# 项目域名(可选,用于生产环境)
PROJECT_DOMAIN=
# API 基础路径
API_BASE_URL=/api
```
**后端环境变量 (`server/.env`)**:
```bash
# 服务端口
PORT=3000
# 数据库连接
DATABASE_URL=postgresql://user:password@localhost:5432/errlens
# Supabase(可选)
SUPABASE_URL=
SUPABASE_KEY=
# JWT 密钥
JWT_SECRET=your-secret-key
```
---
## 开发命令
### 前端开发
```bash
# 启动前端开发服务器(H5
pnpm dev:web
# 启动微信小程序开发模式
pnpm dev:weapp
# 启动抖音小程序开发模式
pnpm dev:tt
```
### 后端开发
```bash
# 启动后端开发服务器
pnpm dev:server
# 或
cd server
pnpm dev
```
### 完整开发模式
```bash
# 同时启动前端和后端
pnpm dev
```
### 构建命令
```bash
# 构建所有平台
pnpm build
# 仅构建前端
pnpm build:web
pnpm build:weapp
pnpm build:tt
# 仅构建后端
pnpm build:server
```
### 代码检查
```bash
# 运行 ESLint
pnpm lint
# 运行 TypeScript 检查
pnpm tsc
# 运行完整验证
pnpm validate
```
---
## 数据库迁移
### 开发环境
```bash
cd server
# 运行迁移
pnpm drizzle-kit migrate
# 或生成 SQL
pnpm drizzle-kit generate
```
---
## 项目结构
```
P01_errlens_app/
├── src/ # 前端源码
│ ├── components/ # UI 组件
│ ├── pages/ # 页面
│ ├── lib/ # 工具库
│ └── network.ts # API 封装
├── server/ # 后端源码 (NestJS)
│ └── src/
├── tests/ # 测试代码
│ ├── unit/ # 单元测试
│ ├── integration/ # 集成测试
│ └── e2e/ # E2E 测试
├── docs/ # 项目文档
│ ├── 01_需求概要.md
│ ├── 02_架构设计.md
│ └── 03_接口定义.md
└── config/ # 构建配置
```
---
## 常见问题
### Q: pnpm install 失败?
A: 确保 Node.js >= 20.xpnpm >= 9.0.0
### Q: H5 开发正常,但小程序报错?
A: 检查是否使用了不支持的 API 或组件,参考跨端兼容性文档
### Q: 后端服务启动失败?
A: 检查 PostgreSQL 是否运行,环境变量是否正确配置
---
## 端口说明
| 服务 | 端口 | 说明 |
|------|------|------|
| 前端 H5 | 5000 | 开发服务器 |
| 后端 API | 3000 | NestJS 服务 |
| 微信开发者工具 | - | 自动加载 |
| 抖音开发者工具 | - | 自动加载 |
---
**文档版本**v1.0.0
**最后更新**2026-05-22
+12
View File
@@ -0,0 +1,12 @@
// babel-preset-taro 更多选项和默认值:
// https://docs.taro.zone/docs/next/babel-config
module.exports = {
presets: [
['taro', {
framework: 'react',
ts: true,
compiler: 'vite',
useBuiltIns: false
}]
]
}
+9
View File
@@ -0,0 +1,9 @@
import type { UserConfigExport } from "@tarojs/cli"
export default {
mini: {
debugReact: true,
},
h5: {}
} satisfies UserConfigExport<'vite'>
+238
View File
@@ -0,0 +1,238 @@
import fs from 'node:fs';
import path from 'node:path';
import tailwindcss from '@tailwindcss/postcss';
import { UnifiedViteWeappTailwindcssPlugin } from 'weapp-tailwindcss/vite';
import { defineConfig, type UserConfigExport } from '@tarojs/cli';
import type { PluginItem } from '@tarojs/taro/types/compile/config/project';
import dotenv from 'dotenv';
import devConfig from './dev';
import prodConfig from './prod';
import pkg from '../package.json';
dotenv.config({ path: path.resolve(__dirname, '../.env.local') });
const generateTTProjectConfig = (outputRoot: string) => {
const config = {
miniprogramRoot: './',
projectname: 'coze-mini-program',
appid: process.env.TARO_APP_TT_APPID || '',
setting: {
urlCheck: false,
es6: false,
postcss: false,
minified: false,
},
};
const outputDir = path.resolve(__dirname, '..', outputRoot);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(
path.resolve(outputDir, 'project.config.json'),
JSON.stringify(config, null, 2),
);
};
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
export default defineConfig<'vite'>(async (merge, _env) => {
const outputRootMap: Record<string, string> = {
weapp: 'dist',
tt: 'dist-tt',
h5: 'dist-web',
};
const defaultOutputRoot = outputRootMap[process.env.TARO_ENV || ''] || 'dist';
const outputRoot = process.env.OUTPUT_ROOT?.trim() || defaultOutputRoot;
const isH5 = process.env.TARO_ENV === 'h5';
const buildMiniCIPluginConfig = () => {
const hasWeappConfig = !!process.env.TARO_APP_WEAPP_APPID;
const hasTTConfig = !!process.env.TARO_APP_TT_EMAIL;
if (!hasWeappConfig && !hasTTConfig) {
return [];
}
const miniCIConfig: Record<string, any> = {
version: pkg.version,
desc: pkg.description,
};
if (hasWeappConfig) {
miniCIConfig.weapp = {
appid: process.env.TARO_APP_WEAPP_APPID,
privateKeyPath: 'key/private.appid.key',
};
}
if (hasTTConfig) {
miniCIConfig.tt = {
email: process.env.TARO_APP_TT_EMAIL,
password: process.env.TARO_APP_TT_PASSWORD,
setting: {
skipDomainCheck: true,
},
};
}
return [['@tarojs/plugin-mini-ci', miniCIConfig]] as PluginItem[];
};
const baseConfig: UserConfigExport<'vite'> = {
projectName: 'coze-mini-program',
date: '2026-1-13',
alias: {
'@': path.resolve(__dirname, '..', 'src'),
},
designWidth: 750,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2,
},
sourceRoot: 'src',
outputRoot,
plugins: ['@tarojs/plugin-generator', ...buildMiniCIPluginConfig()],
defineConstants: {
PROJECT_DOMAIN: JSON.stringify(
process.env.PROJECT_DOMAIN ||
process.env.COZE_PROJECT_DOMAIN_DEFAULT ||
'',
),
TARO_ENV: JSON.stringify(process.env.TARO_ENV),
},
copy: {
patterns: [],
options: {},
},
...(process.env.TARO_ENV === 'tt' && {
tt: {
appid: process.env.TARO_APP_TT_APPID,
projectName: 'coze-mini-program',
},
}),
jsMinimizer: 'esbuild',
framework: 'react',
compiler: {
type: 'vite',
vitePlugins: [
{
name: 'postcss-config-loader-plugin',
config(config) {
// 通过 postcss 配置注册 tailwindcss 插件
if (typeof config.css?.postcss === 'object') {
config.css?.postcss.plugins?.unshift(tailwindcss());
}
},
},
{
name: 'hmr-config-plugin',
config() {
if (!process.env.PROJECT_DOMAIN) {
return;
}
return {
server: {
hmr: {
overlay: true,
path: '/hot/vite-hmr',
port: 6000,
clientPort: 443,
timeout: 30000,
},
},
};
},
},
...(isH5
? []
: [
UnifiedViteWeappTailwindcssPlugin({
rem2rpx: true,
cssEntries: [path.resolve(__dirname, '../src/app.css')],
}),
]),
...(process.env.TARO_ENV === 'tt'
? [
{
name: 'generate-tt-project-config',
closeBundle() {
generateTTProjectConfig(outputRoot);
},
},
]
: []),
],
},
mini: {
postcss: {
pxtransform: {
enable: true,
config: {},
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
},
h5: {
publicPath: './',
staticDirectory: 'static',
router: {
mode: 'hash',
},
devServer: {
port: 5000,
host: '0.0.0.0',
open: false,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
miniCssExtractPluginOption: {
ignoreOrder: true,
filename: 'css/[name].[hash].css',
chunkFilename: 'css/[name].[chunkhash].css',
},
postcss: {
autoprefixer: {
enable: true,
config: {},
},
pxtransform: {
enable: true,
config: {
platform: 'h5',
},
},
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
},
},
rn: {
appName: 'coze-mini-program',
postcss: {
cssModules: {
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
},
},
},
};
process.env.BROWSERSLIST_ENV = process.env.NODE_ENV;
if (process.env.NODE_ENV === 'development') {
// 本地开发构建配置(不混淆压缩)
return merge({}, baseConfig, devConfig);
}
// 生产构建配置(默认开启压缩混淆等)
return merge({}, baseConfig, prodConfig);
});
+34
View File
@@ -0,0 +1,34 @@
import type { UserConfigExport } from '@tarojs/cli';
export default {
mini: {},
h5: {
legacy: false,
/**
* WebpackChain 插件配置
* @docs https://github.com/neutrinojs/webpack-chain
*/
// webpackChain (chain) {
// /**
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
// */
// chain.plugin('analyzer')
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
// /**
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
// */
// const path = require('path')
// const Prerender = require('prerender-spa-plugin')
// const staticDir = path.join(__dirname, '..', 'dist')
// chain
// .plugin('prerender')
// .use(new Prerender({
// staticDir,
// routes: [ '/pages/index/index' ],
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
// }))
// }
},
} satisfies UserConfigExport<'vite'>;
+101 -14
View File
@@ -1,21 +1,108 @@
# P01_errlens_app - 需求概要
## 项目概述
ErrLens 是一个 AI 辅助编程工具,旨在帮助开发者快速定位和修复代码错误。
## 核心功能
1. **错误检测** - 实时分析代码中的潜在问题
2. **智能修复** - 自动生成修复建议
3. **代码审查** - 提供代码质量评估
4. **学习建议** - 根据错误类型提供学习资源
ErrLens 小程序应用是一个基于 **Taro 4 框架**开发的多端小程序项目,支持微信小程序、抖音小程序和 H5 平台。
## 用户角色
- 初级开发者:学习编程时获得实时反馈
- 中级开发者:提高代码质量和效率
- 团队负责人:监控团队代码质量
## 项目定位
- **产品类型**:AI 辅助编程工具的移动端入口
- **目标用户**:开发者、编程学习者、代码审查人员
- **核心价值**:随时随地访问 ErrLens 的代码错误检测和修复建议功能
## 技术栈
- 前端:React + TypeScript
- 后端:Node.js + Express
- 数据库:PostgreSQL
- AI 服务:自定义模型 + OpenAI API
### 前端框架
| 技术 | 版本 | 说明 |
|------|------|------|
| Taro | 4.1.9 | 跨端开发框架 |
| React | 18.x | UI 框架 |
| TypeScript | 5.x | 类型安全 |
| Tailwind CSS | 4.x | 原子化样式 |
| Zustand | 5.x | 状态管理 |
### 后端框架
| 技术 | 版本 | 说明 |
|------|------|------|
| NestJS | 10.x | Node.js 服务端框架 |
| Express | 5.x | HTTP 服务器 |
| PostgreSQL | 15+ | 关系数据库 |
| Drizzle ORM | 0.45.x | ORM 工具 |
### 集成服务
| 服务 | 说明 |
|------|------|
| Supabase | 数据库连接 |
| S3 兼容存储 | 文件存储 |
| Coze SDK | AI 能力集成 |
## 核心功能模块
### 1. 首页模块
- [ ] 欢迎页面展示
- [ ] 功能快捷入口
- [ ] 最新动态/公告
### 2. 代码分析模块
- [ ] 代码上传/粘贴
- [ ] 错误检测结果展示
- [ ] 修复建议生成
### 3. 用户模块
- [ ] 用户登录/注册
- [ ] 个人中心
- [ ] 历史记录
### 4. 设置模块
- [ ] 主题切换
- [ ] 通知设置
- [ ] 关于我们
## 页面结构
```
pages/
├── index/ # 首页
├── analyze/ # 代码分析
├── history/ # 历史记录
├── profile/ # 个人中心
└── settings/ # 设置页面
```
## 组件库
项目使用 **Taro 版 shadcn/ui** 组件库,位于 `src/components/ui/`
| 组件类型 | 示例组件 |
|---------|---------|
| 基础组件 | Button, Input, Textarea |
| 布局组件 | Card, Dialog, Drawer, Sheet |
| 数据展示 | Table, Badge, Avatar |
| 导航组件 | Tabs, Breadcrumb |
| 反馈组件 | Toast, Alert, Progress |
## 多端支持
| 平台 | 状态 | 说明 |
|------|------|------|
| 微信小程序 | ✅ 支持 | 主流平台 |
| 抖音小程序 | ✅ 支持 | 字节系平台 |
| H5 | ✅ 支持 | Web 端预览 |
## 用户体验目标
- **加载速度**:首屏加载 < 2s
- **交互流畅**:帧率 >= 60fps
- **跨端一致**:各端 UI 表现一致
- **离线可用**:支持本地缓存
## 安全要求
- 用户数据加密存储
- API 请求鉴权
- 敏感信息脱敏
---
**文档版本**v1.0.0
**最后更新**2026-05-22
+199 -51
View File
@@ -1,59 +1,207 @@
# P01_errlens_app - 架构设计
## 系统架构
采用微服务架构,前后端分离。
## 整体架构
### 架构图
```
┌─────────────────────────────────────────────────────┐
前端层
React + TypeScript + Tailwind CSS
└──────────────────────┬──────────────────────────────┘
│ HTTP/WebSocket
┌─────────────────────────────────────────────────────┐
API网关
Express + Middleware
└──────────────────────┬──────────────────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
代码分析 │ │ AI服务 用户管理 │
Service │ Service│ Service
└──────────┘ └──────────┘ └──────────┘
│ │ │
└─────────────────┼─────────────────┘
──────────────┐
│ PostgreSQL
──────────────
┌─────────────────────────────────────────────────────────────
小程序客户端
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ 首页 │ │ 分析 │ │ 历史 │ │ 我的 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
┌────────────────────────────────────────┴────┐ │
组件库 (shadcn/ui)
└────────────────────┬─────────────────────────┘
│ │ │
┌────────────────────┴─────────────────────────┐
│ │ 状态管理 (Zustand) │ │
│ └────────────────────┬─────────────────────────┘ │
│ │ │
┌────────────────────┴─────────────────────────┐
│ Network 层 (API 封装)
└────────────────────┬─────────────────────────┘
└───────────────────────┼───────────────────────────────────┘
│ HTTP
┌───────────────────────────────────────────────────────────┐
后端服务 (NestJS)
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 用户模块 │ │ 分析模块 │ │ 历史模块 │ │ 系统模块 │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │ │
│ ┌────┴────────────┴────────────┴────────────┴────┐ │
│ │ Service 层 │ │
│ └────────────────────┬─────────────────────────┘ │
│ │ │
│ ┌────────────────────┴─────────────────────────┐ │
│ │ 数据层 (Drizzle ORM) │ │
│ └────────────────────┬─────────────────────────┘ │
└───────────────────────┼───────────────────────────────────┘
┌─────────────────┐
│ PostgreSQL │
│ Supabase │
└─────────────────┘
```
## 核心模块
### 1. 代码分析模块
- 解析源代码
- 静态分析检测
- 错误分类与评级
### 2. AI 服务模块
- 调用 AI 模型
- 生成修复建议
- 优化提示词
### 3. 用户管理模块
- 用户认证授权
- 使用统计
- 个性化配置
## 目录结构
```
src/
├── api/ # REST API 路由
├── controllers/ # 业务逻辑
├── services/ # 核心服务
├── models/ # 数据模型
├── middleware/ # 中间件
└── utils/ # 工具函数
```
P01_errlens_app/
├── src/ # 前端源码
│ ├── app.config.ts # Taro 应用配置
│ ├── app.tsx # 根组件
│ ├── app.css # 全局样式
│ ├── index.html # H5 入口
│ │
│ ├── components/ # 组件
│ │ └── ui/ # shadcn/ui 组件库
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ └── ... (50+ 组件)
│ │
│ ├── pages/ # 页面
│ │ └── index/ # 首页
│ │ ├── index.tsx
│ │ ├── index.config.ts
│ │ └── index.css
│ │
│ ├── lib/ # 工具库
│ │ ├── utils.ts # 通用工具
│ │ ├── platform.ts # 平台检测
│ │ ├── measure.ts # 尺寸测量
│ │ └── hooks/ # 自定义 Hooks
│ │
│ ├── presets/ # 预设配置
│ │ ├── index.tsx
│ │ ├── env.ts
│ │ ├── h5-container.tsx
│ │ └── ...
│ │
│ └── network.ts # API 请求封装
├── server/ # 后端源码 (NestJS)
│ ├── src/
│ │ ├── app.module.ts # 根模块
│ │ ├── app.controller.ts # 根控制器
│ │ ├── app.service.ts # 根服务
│ │ └── main.ts # 入口文件
│ │
│ ├── nest-cli.json
│ ├── tsconfig.json
│ └── package.json
├── config/ # 构建配置
│ ├── index.ts # 通用配置
│ ├── dev.ts # 开发环境配置
│ └── prod.ts # 生产环境配置
├── types/ # 类型定义
│ ├── global.d.ts
│ └── lucide.d.ts
├── tests/ # 测试代码
│ ├── unit/ # 单元测试
│ ├── integration/ # 集成测试
│ └── e2e/ # E2E 测试
├── docs/ # 项目文档
│ ├── 01_需求概要.md
│ ├── 02_架构设计.md
│ └── 03_接口定义.md
├── package.json # 前端依赖
├── tsconfig.json # TypeScript 配置
├── babel.config.js # Babel 配置
├── eslint.config.mjs # ESLint 配置
├── stylelint.config.mjs # Stylelint 配置
├── project.config.json # 微信小程序配置
└── ENVIRONMENT.md # 环境准备指南
```
## 核心模块设计
### 1. Network 层
```typescript
// src/network.ts
// API 请求封装,自动添加项目域名前缀
// 支持 request / uploadFile / downloadFile
```
**职责**
- 统一处理 API 请求
- 自动添加域名和路径前缀
- 请求/响应日志打印
- 错误处理
### 2. 组件库
**位置**`src/components/ui/`
**组件分类**
- 基础组件:Button, Input, Badge, Avatar
- 布局组件:Card, Dialog, Drawer, Sheet
- 数据展示:Table, Progress, Skeleton
- 导航组件:Tabs, Breadcrumb
- 反馈组件:Toast, Alert, Tooltip
### 3. 状态管理
**方案**Zustand
**特点**
- 轻量级
- 无 Provider 嵌套
- TypeScript 友好
### 4. 后端模块
```
server/src/
├── controllers/ # 控制器
├── services/ # 业务逻辑
├── modules/ # NestJS 模块
├── entities/ # 数据实体
├── dto/ # 数据传输对象
└── interceptors/ # 拦截器
```
## 多端适配策略
### 平台检测
```typescript
import { Taro, ENV_TYPE } from '@tarojs/taro'
const isWeapp = Taro.getEnv() === ENV_TYPE.WEAPP // 微信小程序
const isTT = Taro.getEnv() === ENV_TYPE.TT // 抖音小程序
const isH5 = Taro.getEnv() === ENV_TYPE.WEB // H5
```
### 跨端规则
| 场景 | 适配方案 |
|------|---------|
| Text 换行 | 添加 `block` 类 |
| Input 样式 | View 包裹,样式放 View |
| Fixed + Flex | 使用 inline style |
| 原生组件 | 平台检测 + 降级方案 |
## 部署架构
### 开发环境
- 前端:H5 端口 5000
- 后端:Node 端口 3000
### 生产环境
- 微信小程序:构建 weapp 包
- 抖音小程序:构建 tt 包
- H5:构建 web 静态资源
---
**文档版本**v1.0.0
**最后更新**2026-05-22
+258 -59
View File
@@ -1,54 +1,41 @@
# P01_errlens_app - 接口定义
## API 基础路径
`/api/v1`
## 接口规范
## 认证方式
JWT Token,放在 Authorization 头:
```
Authorization: Bearer <token>
```
### 基础信息
- **Base URL**: `/api`(开发环境通过 Vite Proxy 代理到 `http://localhost:3000/api`
- **请求格式**: JSON
- **响应格式**: Envelope Pattern `{ code, msg, data }`
## 接口列表
### 通用响应结构
### 1. 代码分析
#### POST /api/v1/analyze
分析代码中的错误
**请求体:**
```json
```typescript
// 成功响应
{
"code": "string",
"language": "string",
"options": {
"strict": true
}
code: 200,
msg: "success",
data: { ... }
}
// 错误响应
{
code: 400 | 401 | 403 | 404 | 500,
msg: "错误信息",
data: null
}
```
**响应:**
```json
{
"success": true,
"errors": [
{
"line": 10,
"column": 5,
"type": "error",
"message": "变量未定义",
"suggestion": "建议在使用前定义变量"
}
]
}
---
## 用户模块
### 1. 用户登录
```
POST /api/auth/login
```
### 2. 用户管理
#### POST /api/v1/users/login
用户登录
**请求体:**
**请求参数**
```json
{
"email": "string",
@@ -56,40 +43,252 @@ Authorization: Bearer <token>
}
```
**响应**
**响应示例**
```json
{
"success": true,
"token": "string",
"user": {
"id": "string",
"email": "string",
"name": "string"
"code": 200,
"msg": "success",
"data": {
"token": "jwt_token_here",
"user": {
"id": "uuid",
"email": "user@example.com",
"nickname": "用户名"
}
}
}
```
### 3. 修复建议
---
#### POST /api/v1/fix
获取修复建议
### 2. 用户注册
**请求体:**
```
POST /api/auth/register
```
**请求参数**
```json
{
"email": "string",
"password": "string",
"nickname": "string"
}
```
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": {
"id": "uuid",
"email": "user@example.com",
"nickname": "用户名"
}
}
```
---
### 3. 获取用户信息
```
GET /api/users/profile
```
**请求头**
```
Authorization: Bearer <token>
```
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": {
"id": "uuid",
"email": "user@example.com",
"nickname": "用户名",
"avatar": "https://...",
"createdAt": "2026-05-22T00:00:00Z"
}
}
```
---
## 代码分析模块
### 1. 上传代码分析
```
POST /api/analyze
```
**请求头**
```
Authorization: Bearer <token>
```
**请求参数**
```json
{
"code": "string",
"error": {
"type": "string",
"message": "string"
"language": "javascript | python | typescript | ..."
}
```
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": {
"taskId": "uuid",
"status": "completed",
"results": [
{
"line": 10,
"column": 5,
"severity": "error",
"message": "缺少分号",
"suggestion": "在行末添加分号"
}
]
}
}
```
**响应:**
---
### 2. 获取分析结果
```
GET /api/analyze/:taskId
```
**响应示例**
```json
{
"success": true,
"fixedCode": "string",
"explanation": "string"
"code": 200,
"msg": "success",
"data": {
"taskId": "uuid",
"status": "completed",
"results": [...]
}
}
```
```
---
### 3. 获取历史记录
```
GET /api/analyze/history
```
**查询参数**
```
page: number (default: 1)
pageSize: number (default: 20)
```
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": {
"list": [
{
"id": "uuid",
"codeSnippet": "function hello() {...}",
"language": "javascript",
"resultCount": 3,
"createdAt": "2026-05-22T00:00:00Z"
}
],
"total": 100,
"page": 1,
"pageSize": 20
}
}
```
---
## 文件上传模块
### 1. 上传文件
```
POST /api/upload
```
**请求头**
```
Authorization: Bearer <token>
Content-Type: multipart/form-data
```
**请求参数**
```
file: binary
```
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": {
"url": "https://storage.example.com/files/xxx.png",
"filename": "xxx.png",
"size": 1024
}
}
```
---
## 错误码定义
| 错误码 | 说明 |
|-------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权 / Token 过期 |
| 403 | 权限不足 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
---
## API 测试命令
### 开发环境测试
```bash
# 登录接口
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"123456"}'
# 获取用户信息
curl -X GET http://localhost:3000/api/users/profile \
-H "Authorization: Bearer <token>"
# 代码分析
curl -X POST http://localhost:3000/api/analyze \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"code":"console.log(1)","language":"javascript"}'
```
---
**文档版本**v1.0.0
**最后更新**2026-05-22
+251
View File
@@ -0,0 +1,251 @@
import { FlatCompat } from '@eslint/eslintrc';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const REMOTE_CSS_IMPORT_PATTERN =
/@import\s+(?:url\(\s*['"]?((?:https?:)?\/\/[^'")\s]+)['"]?\s*\)|['"]((?:https?:)?\/\/[^'"\s]+)['"])/g;
const cssImportGuardPlugin = {
processors: {
css: {
preprocess(text) {
const lines = text.split('\n');
const virtualLines = lines.map(line => {
const matches = [...line.matchAll(REMOTE_CSS_IMPORT_PATTERN)];
if (matches.length === 0) {
return '';
}
return matches
.map(match => {
const url = match[1] ?? match[2];
return `__cssExternalImport(${JSON.stringify(url)});`;
})
.join(' ');
});
return [virtualLines.join('\n')];
},
postprocess(messages) {
return messages.flat();
},
supportsAutofix: false,
},
},
};
const baseRestrictedSyntaxRules = [
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message:
'工程规范:请勿在 src 目录下直接使用 process.env\n如需获取 URL 请求前缀,请使用已经注入全局的 PROJECT_DOMAIN',
},
{
selector:
":matches(ExportNamedDeclaration, ExportDefaultDeclaration) :matches([id.name='Network'], [declaration.id.name='Network'])",
message:
"工程规范:禁止自行定义 Network,项目已提供 src/network.ts,请直接使用: import { Network } from '@/network'",
},
{
selector:
'Literal[value=/(^|\\s)(?:[^\\s:]+:)*(bg|text|border|divide|outline|ring|ring-offset|from|to|via|decoration|shadow|accent|caret|fill|stroke)-[a-z0-9-]+\\/([0-9]+|\\[[^\\]]+\\])/], TemplateElement[value.raw=/(^|\\s)(?:[^\\s:]+:)*(bg|text|border|divide|outline|ring|ring-offset|from|to|via|decoration|shadow|accent|caret|fill|stroke)-[a-z0-9-]+\\/([0-9]+|\\[[^\\]]+\\])/]',
message:
'微信小程序兼容性:禁用 Tailwind 颜色不透明度简写(如 bg-primary/10),该语法在微信小程序下 opacity 会丢失。请拆分写(如 bg-primary bg-opacity-10)。',
},
{
selector:
'Literal[value=/(^|\\s)peer-[a-z0-9-]+\\b/], TemplateElement[value.raw=/(^|\\s)peer-[a-z0-9-]+\\b/]',
message:
'微信小程序兼容性:不支持 Tailwind 的 peer-*(如 peer-checked、peer-disabled)。',
},
{
selector:
'Literal[value=/(^|\\s)group-[a-z0-9-]+\\b/], TemplateElement[value.raw=/(^|\\s)group-[a-z0-9-]+\\b/]',
message: '微信小程序兼容性:不支持 Tailwind 的 group-*(如 group-hover)。',
},
{
selector:
'Literal[value=/\\b(?!gap(?:-x|-y)?-)[a-zA-Z0-9-]+\\-[0-9]+\\.[0-9]+\\b/], TemplateElement[value.raw=/\\b(?!gap(?:-x|-y)?-)[a-zA-Z0-9-]+\\-[0-9]+\\.[0-9]+\\b/]',
message:
'微信小程序兼容性:禁用 Tailwind 小数值类名(如 space-y-1.5、w-0.5),请用整数替代(如 space-y-2、w-1)。',
},
{
selector:
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\:has\\(/], TemplateElement[value.raw=/\\:has\\(/])",
message: '微信小程序兼容性:WXSS 不支持 :has(...)(会导致预览上传失败)。',
},
{
selector:
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/(^|\\s)has-[^\\s]+/], TemplateElement[value.raw=/(^|\\s)has-[^\\s]+/])",
message:
'微信小程序兼容性:禁用 Tailwind 的 has-* 变体(会生成 :has,导致预览上传失败)。',
},
{
selector:
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\[&>\\*/], TemplateElement[value.raw=/\\[&>\\*/])",
message:
'微信小程序兼容性:禁用 [&>*...](可能生成非法 WXSS,如 >:last-child)。请改为 [&>view] 等明确标签。',
},
{
selector:
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\[&[^\\]]*\\[data-/], TemplateElement[value.raw=/\\[&[^\\]]*\\[data-/])",
message:
'微信小程序兼容性:禁用 Tailwind 任意选择器里的属性选择器(如 [&>[data-...]]),可能导致预览上传失败。',
},
{
selector:
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\[[^\\]]*&[^\\]]*~[^\\]]*\\]/], TemplateElement[value.raw=/\\[[^\\]]*&[^\\]]*~[^\\]]*\\]/])",
message: '微信小程序兼容性:WXSS 不支持 ~(会导致预览上传失败)。',
},
{
selector:
"CallExpression[callee.name='__cssExternalImport'] > Literal[value=/^(?:https?:)?\\/\\//]",
message:
'微信小程序兼容性:禁止在 CSS/WXSS 中使用远程 @import(如 Google Fonts)。请改为本地静态资源或删除该导入。',
},
{
selector:
"JSXAttribute[name.name='color'][value.type='Literal'][value.value='currentColor'], JSXAttribute[name.name='color'] > JSXExpressionContainer > Literal[value='currentColor']",
message:
'lucide-react-taro 规范:禁止使用 color="currentColor",小程序端不会按预期继承颜色。请改为显式颜色值,或通过 LucideTaroProvider 提供默认颜色。',
},
];
const pageRestrictedSyntaxRules = [
{
selector:
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Button']",
message:
"组件规范:Button 优先使用 '@/components/ui/button',不要在页面中直接使用 '@tarojs/components' 的 Button。",
},
{
selector:
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Input']",
message:
"组件规范:Input 优先使用 '@/components/ui/input',不要在页面中直接使用 '@tarojs/components' 的 Input。",
},
{
selector:
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Textarea']",
message:
"组件规范:Textarea 优先使用 '@/components/ui/textarea',不要在页面中直接使用 '@tarojs/components' 的 Textarea。",
},
{
selector:
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Label']",
message:
"组件规范:Label 优先使用 '@/components/ui/label',不要在页面中直接使用 '@tarojs/components' 的 Label。",
},
{
selector:
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Switch']",
message:
"组件规范:Switch 优先使用 '@/components/ui/switch',不要在页面中直接使用 '@tarojs/components' 的 Switch。",
},
{
selector:
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Slider']",
message:
"组件规范:Slider 优先使用 '@/components/ui/slider',不要在页面中直接使用 '@tarojs/components' 的 Slider。",
},
{
selector:
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Progress']",
message:
"组件规范:Progress 优先使用 '@/components/ui/progress',不要在页面中直接使用 '@tarojs/components' 的 Progress。",
},
];
const indexPageRestrictedSyntaxRules = [
{
selector: 'JSXText[value=/\\s*应用开发中\\s*/]',
message:
'工程规范:检测到首页 (src/pages/index/index.tsx) 仍为默认占位页面,这会导致用户无法进入新增页面,请根据用户需求开发实际的首页功能与界面。如果已经开发了新的首页,也需要删除旧首页,并更新 src/app.config.ts 文件',
},
];
export default [
...compat.extends('taro/react'),
{
rules: {
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'jsx-quotes': ['error', 'prefer-double'],
'react-hooks/exhaustive-deps': 'off',
'tailwindcss/classnames-order': 'off',
'tailwindcss/no-custom-classname': 'off',
},
},
{
files: ['src/**/*.{js,jsx,ts,tsx}'],
ignores: ['src/network.ts'],
rules: {
'no-restricted-syntax': ['error', ...baseRestrictedSyntaxRules],
'no-restricted-properties': [
'error',
{
object: 'Taro',
property: 'request',
message:
"工程规范:请使用 Network.request 替代 Taro.request,导入方式: import { Network } from '@/network'",
},
{
object: 'Taro',
property: 'uploadFile',
message:
"工程规范:请使用 Network.uploadFile 替代 Taro.uploadFile,导入方式: import { Network } from '@/network'",
},
{
object: 'Taro',
property: 'downloadFile',
message:
"工程规范:请使用 Network.downloadFile 替代 Taro.downloadFile,导入方式: import { Network } from '@/network'",
},
],
},
},
{
files: ['src/**/*.css'],
plugins: {
local: cssImportGuardPlugin,
},
processor: 'local/css',
rules: {
'no-undef': 'off',
'no-restricted-syntax': ['error', ...baseRestrictedSyntaxRules],
},
},
{
files: ['src/pages/**/*.tsx'],
rules: {
'no-restricted-syntax': [
'error',
...baseRestrictedSyntaxRules,
...pageRestrictedSyntaxRules,
],
},
},
{
files: ['src/pages/index/index.tsx'],
rules: {
'no-restricted-syntax': [
'error',
...baseRestrictedSyntaxRules,
...pageRestrictedSyntaxRules,
...indexPageRestrictedSyntaxRules,
],
},
},
{
ignores: ['dist/**', 'dist-*/**', 'node_modules/**'],
},
];
+113
View File
@@ -0,0 +1,113 @@
{
"name": "@errlens/p01-mini-program",
"version": "1.0.0",
"private": true,
"description": "ErrLens 小程序应用 - 基于 Taro 框架的多端小程序项目",
"scripts": {
"build": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n lint,tsc,web,weapp,tt,server -c red,blue,green,yellow,cyan,magenta \"pnpm lint:build\" \"pnpm tsc\" \"pnpm build:web\" \"pnpm build:weapp\" \"pnpm build:tt\" \"pnpm build:server\"",
"build:pack": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n weapp,tt -c yellow,cyan \"pnpm build:weapp\" \"pnpm build:tt\"",
"build:server": "pnpm --filter server build",
"build:tt": "taro build --type tt",
"build:weapp": "taro build --type weapp",
"build:web": "taro build --type h5",
"dev": "pnpm exec concurrently --kill-others --kill-signal SIGKILL -n web,server -c blue,green \"pnpm dev:web\" \"pnpm dev:server\"",
"dev:server": "pnpm --filter server dev",
"dev:tt": "taro build --type tt --watch",
"dev:weapp": "taro build --type weapp --watch",
"dev:web": "taro build --type h5 --watch",
"preinstall": "npx only-allow pnpm",
"postinstall": "weapp-tw patch",
"kill:all": "pkill -9 -f 'concurrently' 2>/dev/null || true; pkill -9 -f 'nest start' 2>/dev/null || true; pkill -9 -f 'taro build' 2>/dev/null || true; pkill -9 -f 'node.*dev' 2>/dev/null || true; echo 'All dev processes cleaned'",
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx,css}\"",
"lint:build": "eslint \"src/**/*.{js,jsx,ts,tsx,css}\" --max-warnings=0 --quiet",
"lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx,css}\" --fix",
"new": "taro new",
"preview:tt": "taro build --type tt --preview",
"preview:weapp": "taro build --type weapp --preview",
"tsc": "npx tsc --noEmit --skipLibCheck",
"validate": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n lint,tsc -c red,blue \"pnpm lint:build\" \"pnpm tsc\""
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,css}": [
"eslint"
]
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
],
"dependencies": {
"@babel/runtime": "^7.24.4",
"@tarojs/components": "4.1.9",
"@tarojs/helper": "4.1.9",
"@tarojs/plugin-framework-react": "4.1.9",
"@tarojs/plugin-platform-h5": "4.1.9",
"@tarojs/plugin-platform-tt": "4.1.9",
"@tarojs/plugin-platform-weapp": "4.1.9",
"@tarojs/react": "4.1.9",
"@tarojs/runtime": "4.1.9",
"@tarojs/shared": "4.1.9",
"@tarojs/taro": "4.1.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react-taro": "^1.4.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.9"
},
"devDependencies": {
"@babel/core": "^7.24.4",
"@babel/plugin-transform-class-properties": "7.25.9",
"@babel/preset-react": "^7.24.1",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.18",
"@tarojs/cli": "4.1.9",
"@tarojs/plugin-generator": "4.1.9",
"@tarojs/plugin-mini-ci": "^4.1.9",
"@tarojs/vite-runner": "4.1.9",
"@types/minimatch": "^5",
"@types/react": "^18.0.0",
"@vitejs/plugin-react": "^4.3.0",
"babel-preset-taro": "4.1.9",
"concurrently": "^9.2.1",
"dotenv": "^17.2.3",
"eslint": "^8.57.0",
"eslint-config-taro": "4.1.9",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.4.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"less": "^4.2.0",
"lint-staged": "^16.1.2",
"miniprogram-ci": "^2.1.26",
"only-allow": "^1.2.2",
"postcss": "^8.5.6",
"react-refresh": "^0.14.0",
"stylelint": "^16.4.0",
"stylelint-config-standard": "^38.0.0",
"tailwindcss": "^4.1.18",
"terser": "^5.30.4",
"tt-ide-cli": "^0.1.31",
"typescript": "^5.4.5",
"vite": "^4.2.0",
"weapp-tailwindcss": "^4.10.3"
},
"packageManager": "pnpm@9.0.0",
"engines": {
"pnpm": ">=9.0.0"
},
"pnpm": {
"patchedDependencies": {
"@tarojs/plugin-mini-ci@4.1.9": "patches/@tarojs__plugin-mini-ci@4.1.9.patch"
}
},
"templateInfo": {
"name": "default",
"typescript": true,
"css": "Less",
"framework": "React"
}
}
@@ -0,0 +1,16 @@
{
"miniprogramRoot": "./dist",
"projectname": "coze-mini-program",
"description": "test",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"compileHotReLoad": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram",
"packOptions": { "ignore": [{ "type": "folder", "value": "./assets" }] }
}
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"exclude": ["node_modules", "dist", ".git", "../dist", "../src"]
},
"webpack": true
}
@@ -0,0 +1,38 @@
{
"name": "server",
"version": "1.0.0",
"private": true,
"description": "NestJS server application",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.958.0",
"@aws-sdk/lib-storage": "^3.958.0",
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "2.95.3",
"coze-coding-dev-sdk": "^0.7.16",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"express": "5.2.1",
"pg": "^8.16.3",
"rxjs": "^7.8.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "5.0.6",
"@types/node": "^22.10.2",
"drizzle-kit": "^0.31.8",
"typescript": "^5.7.2"
}
}
@@ -0,0 +1,23 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from '@/app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('hello')
getHello(): { status: string; data: string } {
return {
status: 'success',
data: this.appService.getHello()
};
}
@Get('health')
getHealth(): { status: string; data: string } {
return {
status: 'success',
data: new Date().toISOString(),
};
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AppController } from '@/app.controller';
import { AppService } from '@/app.service';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello, welcome to coze coding mini-program server!';
}
}
@@ -0,0 +1,23 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class HttpStatusInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// 如果是 POST 请求且状态码为 201,改为 200
if (request.method === 'POST' && response.statusCode === 201) {
response.status(200);
}
return next.handle();
}
}
@@ -0,0 +1,49 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from '@/app.module';
import * as express from 'express';
import { HttpStatusInterceptor } from '@/interceptors/http-status.interceptor';
function parsePort(): number {
const args = process.argv.slice(2);
const portIndex = args.indexOf('-p');
if (portIndex !== -1 && args[portIndex + 1]) {
const port = parseInt(args[portIndex + 1], 10);
if (!isNaN(port) && port > 0 && port < 65536) {
return port;
}
}
return 3000;
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: true,
credentials: true,
});
app.setGlobalPrefix('api');
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ limit: '50mb', extended: true }));
// 全局拦截器:统一将 POST 请求的 201 状态码改为 200
app.useGlobalInterceptors(new HttpStatusInterceptor());
// 1. 开启优雅关闭 Hooks (关键!)
app.enableShutdownHooks();
// 2. 解析端口
const port = parsePort();
try {
await app.listen(port);
console.log(`Server running on http://localhost:${port}`);
} catch (err) {
if (err.code === 'EADDRINUSE') {
console.error(`❌ 端口 \({port} 被占用! 请运行 'npx kill-port \){port}' 然后重试。`);
process.exit(1);
} else {
throw err;
}
}
console.log(`Application is running on: http://localhost:3000`);
}
bootstrap();
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["./src/*"]
}
}
}
-1
View File
@@ -1 +0,0 @@
# src
@@ -0,0 +1,11 @@
export default defineAppConfig({
pages: [
'pages/index/index'
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
}
})
+156
View File
@@ -0,0 +1,156 @@
/* stylelint-disable selector-type-no-unknown */
/* stylelint-disable at-rule-no-unknown */
/* stylelint-disable number-max-precision */
@import url('weapp-tailwindcss');
/* 小程序页面容器高度设置,确保垂直居中生效 */
page {
height: 100%;
}
:root,
page,
root-portal {
--background: lab(100% 0 0);
--foreground: lab(2.75381% 0 0);
--card: lab(100% 0 0);
--card-foreground: lab(2.75381% 0 0);
--popover: lab(100% 0 0);
--popover-foreground: lab(2.75381% 0 0);
--primary: lab(7.78201% -0.0000149012 0);
--primary-foreground: lab(98.26% 0 0);
--secondary: lab(96.52% -0.0000298023 0.0000119209);
--secondary-foreground: lab(7.78201% -0.0000149012 0);
--muted: lab(96.52% -0.0000298023 0.0000119209);
--muted-foreground: lab(48.496% 0 0);
--accent: lab(96.52% -0.0000298023 0.0000119209);
--accent-foreground: lab(7.78201% -0.0000149012 0);
--destructive: lab(48.4493% 77.4328 61.5452);
--destructive-foreground: lab(96.4152% 3.22586 1.14673);
--border: lab(90.952% 0 -0.0000119209);
--input: lab(90.952% 0 -0.0000119209);
--ring: lab(66.128% -0.0000298023 0.0000119209);
--sidebar: lab(98.26% 0 0);
--sidebar-foreground: lab(2.75381% 0 0);
--sidebar-primary: lab(7.78201% -0.0000149012 0);
--sidebar-primary-foreground: lab(98.26% 0 0);
--sidebar-accent: lab(96.52% -0.0000298023 0.0000119209);
--sidebar-accent-foreground: lab(7.78201% -0.0000149012 0);
--sidebar-border: lab(90.952% 0 -0.0000119209);
--sidebar-ring: lab(66.128% -0.0000298023 0.0000119209);
--surface: lab(97.68% -0.0000298023 0.0000119209);
--code: var(--surface);
--code-highlight: lab(95.36% 0 0);
--code-number: lab(48.96% 0 0);
--selection: lab(2.75381% 0 0);
--selection-foreground: lab(100% 0 0);
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
}
.dark {
--background: lab(2.75381% 0 0);
--foreground: lab(98.26% 0 0);
--card: lab(7.78201% -0.0000149012 0);
--card-foreground: lab(98.26% 0 0);
--popover: lab(7.78201% -0.0000149012 0);
--popover-foreground: lab(98.26% 0 0);
--primary: lab(90.952% 0 -0.0000119209);
--primary-foreground: lab(7.78201% -0.0000149012 0);
--secondary: lab(15.204% 0 -0.00000596046);
--secondary-foreground: lab(98.26% 0 0);
--muted: lab(15.204% 0 -0.00000596046);
--muted-foreground: lab(66.128% -0.0000298023 0.0000119209);
--accent: lab(27.036% 0 0);
--accent-foreground: lab(98.26% 0 0);
--destructive: lab(63.7053% 60.745 31.3109);
--destructive-foreground: lab(49.0747% 69.3434 49.6251);
--border: lab(100% 0 0 / 10%);
--input: lab(100% 0 0 / 15%);
--ring: lab(48.496% 0 0);
--sidebar: lab(7.78201% -0.0000149012 0);
--sidebar-foreground: lab(98.26% 0 0);
--sidebar-primary: lab(36.9089% 35.0961 -85.6872);
--sidebar-primary-foreground: lab(98.26% 0 0);
--sidebar-accent: lab(15.204% 0 -0.00000596046);
--sidebar-accent-foreground: lab(98.26% 0 0);
--sidebar-border: lab(100% 0 0 / 10%);
--sidebar-ring: lab(34.924% 0 0);
--surface: lab(7.22637% -0.0000149012 0);
--surface-foreground: lab(66.128% -0.0000298023 0.0000119209);
--code: var(--surface);
--code-highlight: lab(15.32% 0 0);
--code-number: lab(67.52% -0.0000298023 0);
--selection: lab(90.952% 0 -0.0000119209);
--selection-foreground: lab(7.78201% -0.0000149012 0);
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-foreground: var(--foreground);
--color-background: var(--background);
--color-selection: var(--selection);
--color-selection-foreground: var(--selection-foreground);
--color-code: var(--code);
--radius-full: 9999px;
}
page,
view,
text,
button,
input,
textarea,
label,
scroll-view,
image {
border-color: var(--border);
}
::selection {
background-color: var(--selection);
color: var(--selection-foreground);
}
+16
View File
@@ -0,0 +1,16 @@
import { PropsWithChildren } from 'react';
import { LucideTaroProvider } from 'lucide-react-taro';
import '@/app.css';
import { Toaster } from '@/components/ui/toast';
import { Preset } from './presets';
const App = ({ children }: PropsWithChildren) => {
return (
<LucideTaroProvider defaultColor="#000" defaultSize={24}>
<Preset>{children}</Preset>
<Toaster />
</LucideTaroProvider>
);
};
export default App;
@@ -0,0 +1,159 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { ChevronsUpDown } from "lucide-react-taro"
import { cn } from "@/lib/utils"
const AccordionContext = React.createContext<{
value?: string | string[]
onValueChange?: (value: string | string[]) => void
type?: "single" | "multiple"
} | null>(null)
const Accordion = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
type?: "single" | "multiple"
value?: string | string[]
defaultValue?: string | string[]
onValueChange?: (value: string | string[]) => void
collapsible?: boolean
}
>(({ className, type = "single", value: valueProp, defaultValue, onValueChange, collapsible = false, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | string[]>(
defaultValue || (type === "multiple" ? [] : "")
)
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (itemValue: string) => {
let newValue: string | string[]
if (type === "multiple") {
const current = Array.isArray(value) ? value : []
if (current.includes(itemValue)) {
newValue = current.filter(v => v !== itemValue)
} else {
newValue = [...current, itemValue]
}
} else {
if (value === itemValue && collapsible) {
newValue = ""
} else {
newValue = itemValue
}
}
if (valueProp === undefined) {
setValueState(newValue)
}
onValueChange?.(newValue)
}
return (
<AccordionContext.Provider value={{ value, onValueChange: handleValueChange, type }}>
<View ref={ref} className={className} {...props} />
</AccordionContext.Provider>
)
})
Accordion.displayName = "Accordion"
const AccordionItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { value: string }
>(({ className, value, ...props }, ref) => (
<View ref={ref} className={cn("border-b", className)} {...props} data-value={value} />
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => {
// Need to find the parent AccordionItem's value.
// In React Native/Taro we can't easily traverse up DOM.
// So we assume AccordionItem passes context or we need to explicitly pass value?
// Radix does this via context nesting.
// Let's create a context for Item.
return (
<AccordionItemContext.Consumer>
{(itemValue) => <AccordionTriggerInternal itemValue={itemValue} className={className} ref={ref} {...props}>{children}</AccordionTriggerInternal>}
</AccordionItemContext.Consumer>
)
})
AccordionTrigger.displayName = "AccordionTrigger"
// Helper context for Item
const AccordionItemContext = React.createContext<string>("")
// Update AccordionItem to provide context
const AccordionItemWithContext = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { value: string }
>(({ className, value, children, ...props }, ref) => (
<AccordionItemContext.Provider value={value}>
<View ref={ref} className={cn("border-b", className)} {...props}>
{children}
</View>
</AccordionItemContext.Provider>
))
AccordionItemWithContext.displayName = "AccordionItem"
const AccordionTriggerInternal = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { itemValue: string }
>(({ className, children, itemValue, ...props }, ref) => {
const context = React.useContext(AccordionContext)
const isOpen = Array.isArray(context?.value)
? context?.value.includes(itemValue)
: context?.value === itemValue
return (
<View className="flex">
<View
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all",
className
)}
onClick={() => context?.onValueChange?.(itemValue)}
{...props}
>
{children}
<ChevronsUpDown className={cn("shrink-0 transition-transform duration-200", isOpen && "rotate-180")} size={16} color="inherit" />
</View>
</View>
)
})
const AccordionContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => (
<AccordionItemContext.Consumer>
{(itemValue) => <AccordionContentInternal itemValue={itemValue} className={className} ref={ref} {...props}>{children}</AccordionContentInternal>}
</AccordionItemContext.Consumer>
))
AccordionContent.displayName = "AccordionContent"
const AccordionContentInternal = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { itemValue: string }
>(({ className, children, itemValue, ...props }, ref) => {
const context = React.useContext(AccordionContext)
const isOpen = Array.isArray(context?.value)
? context?.value.includes(itemValue)
: context?.value === itemValue
if (!isOpen) return null
return (
<View
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<View className={cn("pb-4 pt-0", className)}>{children}</View>
</View>
)
})
export { Accordion, AccordionItemWithContext as AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,260 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Portal } from "@/components/ui/portal"
import { useKeyboardOffset } from "@/lib/hooks/use-keyboard-offset"
const AlertDialogContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
} | null>(null)
const usePresence = (open: boolean | undefined, durationMs: number) => {
const [present, setPresent] = React.useState(!!open)
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
if (open) {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = null
setPresent(true)
return
}
timeoutRef.current = setTimeout(() => setPresent(false), durationMs)
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [open, durationMs])
return present
}
const AlertDialog = ({
children,
open: openProp,
defaultOpen = false,
onOpenChange
}: {
children: React.ReactNode,
open?: boolean,
defaultOpen?: boolean,
onOpenChange?: (open: boolean) => void
}) => {
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
return (
<AlertDialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
{children}
</AlertDialogContext.Provider>
)
}
const AlertDialogTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => {
const context = React.useContext(AlertDialogContext)
return (
<View
ref={ref}
className={className}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(true)
}}
{...props}
>
{children}
</View>
)
})
AlertDialogTrigger.displayName = "AlertDialogTrigger"
const AlertDialogPortal = ({ children }) => {
const context = React.useContext(AlertDialogContext)
const present = usePresence(context?.open, 200)
if (!present) return null
return <Portal>{children}</Portal>
}
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const context = React.useContext(AlertDialogContext)
const state = context?.open ? "open" : "closed"
return (
<View
ref={ref}
data-state={state}
className={cn(
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
onClick={(e) => {
e.stopPropagation()
// Unlike Dialog, AlertDialog typically forces explicit action/cancel.
// But user might want it to close on overlay click.
// Standard shadcn/radix alert dialog usually does NOT close on overlay click?
// Radix Alert Dialog does NOT close on overlay click by default.
// We will leave it as is (no close on click) or optional?
// For now, let's follow standard pattern: it blocks interaction.
}}
{...props}
/>
)
})
AlertDialogOverlay.displayName = "AlertDialogOverlay"
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, style, ...props }, ref) => {
const context = React.useContext(AlertDialogContext)
const offset = useKeyboardOffset()
const state = context?.open ? "open" : "closed"
return (
<AlertDialogPortal>
<View className="fixed inset-0 z-50">
<AlertDialogOverlay />
<View
ref={ref}
data-state={state}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl",
className
)}
style={{
...(style as object),
top: offset > 0 ? `calc(50% - ${offset / 2}px)` : undefined
}}
onClick={(e) => e.stopPropagation()}
{...props}
>
{children}
</View>
</View>
</AlertDialogPortal>
)
})
AlertDialogContent.displayName = "AlertDialogContent"
const AlertDialogHeader = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = "AlertDialogTitle"
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName = "AlertDialogDescription"
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof buttonVariants>
>(({ className, variant, size, onClick, ...props }, ref) => {
const context = React.useContext(AlertDialogContext)
return (
<View
ref={ref}
className={cn(buttonVariants({ variant, size }), "w-full sm:w-auto", className)}
onClick={(e) => {
context?.onOpenChange?.(false)
onClick?.(e)
}}
{...props}
/>
)
})
AlertDialogAction.displayName = "AlertDialogAction"
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof buttonVariants>
>(({ className, variant = "outline", size, onClick, ...props }, ref) => {
const context = React.useContext(AlertDialogContext)
return (
<View
ref={ref}
className={cn(
buttonVariants({ variant, size }),
"mt-2 sm:mt-0 w-full sm:w-auto",
className
)}
onClick={(e) => {
context?.onOpenChange?.(false)
onClick?.(e)
}}
{...props}
/>
)
})
AlertDialogCancel.displayName = "AlertDialogCancel"
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
@@ -0,0 +1,60 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg+*]:pl-7 [&>svg+*+*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive border-opacity-50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<View
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,36 @@
import * as React from "react"
import { View } from "@tarojs/components"
const AspectRatio = React.forwardRef<
React.ElementRef<typeof View>,
Omit<React.ComponentPropsWithoutRef<typeof View>, "style"> & {
ratio?: number
style?: React.CSSProperties
}
>(({ className, ratio = 1 / 1, style, ...props }, ref) => (
<View
ref={ref}
style={{
position: 'relative',
width: '100%',
paddingBottom: `${100 / ratio}%`,
...style
}}
className={className}
{...props}
>
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{props.children}
</View>
</View>
))
AspectRatio.displayName = "AspectRatio"
export { AspectRatio }
@@ -0,0 +1,84 @@
import * as React from "react"
import { View, Image } from "@tarojs/components"
import { cn } from "@/lib/utils"
const AvatarContext = React.createContext<{
status: "loading" | "error" | "loaded"
setStatus: (status: "loading" | "error" | "loaded") => void
} | null>(null)
const Avatar = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const [status, setStatus] = React.useState<"loading" | "error" | "loaded">("loading")
return (
<AvatarContext.Provider value={{ status, setStatus }}>
<View
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
</AvatarContext.Provider>
)
})
Avatar.displayName = "Avatar"
const AvatarImage = React.forwardRef<
React.ElementRef<typeof Image>,
React.ComponentPropsWithoutRef<typeof Image>
>(({ className, src, ...props }, ref) => {
const context = React.useContext(AvatarContext)
const handleLoad = (e) => {
context?.setStatus("loaded")
props.onLoad?.(e)
}
const handleError = (e) => {
context?.setStatus("error")
props.onError?.(e)
}
return (
<Image
ref={ref}
src={src}
className={cn(
"aspect-square h-full w-full",
className,
context?.status !== "loaded" && "w-0 h-0 opacity-0 absolute"
)}
onLoad={handleLoad}
onError={handleError}
{...props}
/>
)
})
AvatarImage.displayName = "AvatarImage"
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const context = React.useContext(AvatarContext)
if (context?.status === "loaded") return null
return (
<View
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
)
})
AvatarFallback.displayName = "AvatarFallback"
export { Avatar, AvatarImage, AvatarFallback }
@@ -0,0 +1,37 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary hover:bg-opacity-80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary hover:bg-opacity-80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive hover:bg-opacity-80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.ComponentPropsWithoutRef<typeof View>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<View className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,117 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { ChevronRight, Ellipsis } from "lucide-react-taro"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <View ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"flex flex-wrap items-center gap-2 break-words text-sm text-muted-foreground sm:gap-3",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("inline-flex items-center gap-2", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
asChild?: boolean
href?: string
}
>(({ asChild, className, href, ...props }, ref) => {
const linkProps = href ? { url: href } : {}
return (
<View
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...linkProps}
{...props as any}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-4 [&>svg]:h-4 flex items-center", className)}
{...props}
>
{children ?? <ChevronRight size={16} color="inherit" />}
</View>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<Ellipsis color="#737373" size={16} />
<View className="sr-only">More</View>
</View>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,83 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch",
{
variants: {
orientation: {
horizontal:
"[&>view:nth-child(n+2)]:rounded-l-none [&>view:nth-child(n+2)]:border-l-0 [&>view:nth-last-child(n+2)]:rounded-r-none",
vertical:
"flex-col [&>view:nth-child(n+2)]:rounded-t-none [&>view:nth-child(n+2)]:border-t-0 [&>view:nth-last-child(n+2)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof buttonGroupVariants>) {
return (
<View
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentPropsWithoutRef<typeof View> & {
asChild?: boolean
}) {
return (
<View
className={cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentPropsWithoutRef<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
@@ -0,0 +1,67 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background active:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary hover:bg-opacity-90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive hover:bg-opacity-90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary hover:bg-opacity-80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ComponentPropsWithoutRef<typeof View>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
disabled?: boolean
className?: string
}
const Button = React.forwardRef<React.ElementRef<typeof View>, ButtonProps>(
({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
const tabIndex = (props as { tabIndex?: number }).tabIndex ?? (disabled ? -1 : 0)
return (
<View
className={cn(
buttonVariants({ variant, size, className }),
disabled && "opacity-50 pointer-events-none"
)}
ref={ref}
{...({ tabIndex } as { tabIndex?: number })}
hoverClass={
disabled
? undefined
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,394 @@
import * as React from "react"
import { Picker, Text, View } from "@tarojs/components"
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react-taro"
import {
addDays,
addMonths,
endOfMonth,
endOfWeek,
format,
isAfter,
isBefore,
isSameDay,
isSameMonth,
startOfMonth,
startOfWeek,
subMonths,
} from "date-fns"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type DateRange = { from?: Date; to?: Date }
type CommonProps = {
className?: string
month?: Date
defaultMonth?: Date
onMonthChange?: (month: Date) => void
showOutsideDays?: boolean
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
disabled?: ((date: Date) => boolean) | Date[]
captionLayout?: "label" | "dropdown"
fromYear?: number
toYear?: number
}
type SingleProps = CommonProps & {
mode?: "single"
selected?: Date
onSelect?: (date: Date | undefined) => void
}
type RangeProps = CommonProps & {
mode: "range"
selected?: DateRange
onSelect?: (range: DateRange | undefined) => void
}
type CalendarProps = SingleProps | RangeProps
function isDateDisabled(date: Date, disabled?: CalendarProps["disabled"]) {
if (!disabled) return false
if (Array.isArray(disabled)) return disabled.some((d) => isSameDay(d, date))
return disabled(date)
}
function isInRange(date: Date, range?: DateRange) {
if (!range?.from || !range?.to) return false
return (
(isAfter(date, range.from) || isSameDay(date, range.from)) &&
(isBefore(date, range.to) || isSameDay(date, range.to))
)
}
function getSingleSelected(props: CalendarProps) {
return props.mode === "range" ? undefined : props.selected
}
function getRangeSelected(props: CalendarProps) {
return props.mode === "range" ? props.selected : undefined
}
function Calendar({
className,
month,
defaultMonth,
onMonthChange,
showOutsideDays = true,
weekStartsOn = 0,
disabled,
captionLayout = "dropdown",
fromYear,
toYear,
...props
}: CalendarProps) {
const singleSelected = getSingleSelected({ month, defaultMonth, onMonthChange, showOutsideDays, weekStartsOn, disabled, className, ...props } as CalendarProps)
const rangeSelected = getRangeSelected({ month, defaultMonth, onMonthChange, showOutsideDays, weekStartsOn, disabled, className, ...props } as CalendarProps)
const initialMonth = React.useMemo(() => {
if (month) return month
if (defaultMonth) return defaultMonth
if (singleSelected) return singleSelected
if (rangeSelected?.from) return rangeSelected.from
return new Date()
}, [defaultMonth, month, rangeSelected?.from, singleSelected])
const [uncontrolledMonth, setUncontrolledMonth] = React.useState<Date>(
initialMonth
)
const visibleMonth = month ?? uncontrolledMonth
const setMonth = React.useCallback(
(next: Date) => {
if (!month) setUncontrolledMonth(next)
onMonthChange?.(next)
},
[month, onMonthChange]
)
const captionHasDropdown = captionLayout === "dropdown"
const captionHasButtons = true
const yearOptions = React.useMemo(() => {
const baseYear = new Date().getFullYear()
const visibleYear = visibleMonth.getFullYear()
const min = fromYear ?? baseYear - 100
const max = toYear ?? baseYear + 20
const start = Math.min(min, visibleYear)
const end = Math.max(max, visibleYear)
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
}, [fromYear, toYear, visibleMonth])
const monthOptions = React.useMemo(() => {
return Array.from({ length: 12 }, (_, i) => i + 1)
}, [])
const yearIndex = React.useMemo(() => {
const y = visibleMonth.getFullYear()
const idx = yearOptions.indexOf(y)
return idx >= 0 ? idx : 0
}, [visibleMonth, yearOptions])
const monthIndex = React.useMemo(() => {
return visibleMonth.getMonth()
}, [visibleMonth])
const setYear = React.useCallback(
(year: number) => {
setMonth(new Date(year, visibleMonth.getMonth(), 1))
},
[setMonth, visibleMonth]
)
const setMonthOfYear = React.useCallback(
(monthOfYear: number) => {
setMonth(new Date(visibleMonth.getFullYear(), monthOfYear - 1, 1))
},
[setMonth, visibleMonth]
)
const gridStart = React.useMemo(() => {
return startOfWeek(startOfMonth(visibleMonth), { weekStartsOn })
}, [visibleMonth, weekStartsOn])
const gridEnd = React.useMemo(() => {
return endOfWeek(endOfMonth(visibleMonth), { weekStartsOn })
}, [visibleMonth, weekStartsOn])
const weeks = React.useMemo(() => {
const days: Date[] = []
for (
let d = gridStart;
!isAfter(d, gridEnd);
d = addDays(d, 1)
) {
days.push(d)
}
const rows: Date[][] = []
for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7))
return rows
}, [gridEnd, gridStart])
const weekdays = React.useMemo(() => {
const labels = ["日", "一", "二", "三", "四", "五", "六"]
return Array.from({ length: 7 }).map((_, i) => labels[(i + weekStartsOn) % 7])
}, [weekStartsOn])
const handleSelect = React.useCallback(
(date: Date) => {
if (isDateDisabled(date, disabled)) return
if (props.mode === "range") {
const current = props.selected
let next: DateRange
if (!current?.from || (current.from && current.to)) {
next = { from: date, to: undefined }
} else if (current.from && !current.to) {
if (isBefore(date, current.from)) {
next = { from: date, to: current.from }
} else {
next = { from: current.from, to: date }
}
} else {
next = { from: date, to: undefined }
}
props.onSelect?.(next)
return
}
props.onSelect?.(date)
},
[disabled, props]
)
return (
<View
className={cn(
"bg-background w-fit rounded-md p-3",
"flex flex-col gap-3 border-2",
className
)}
>
<View className="flex items-center justify-between">
{captionHasButtons ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setMonth(subMonths(visibleMonth, 1))}
>
<ChevronLeft size={16} color="inherit" />
</Button>
) : (
<View className="h-8 w-8" />
)}
{captionHasDropdown ? (
<View className="flex items-center gap-2">
<Picker
mode="selector"
range={yearOptions}
value={yearIndex}
onChange={(e) => setYear(yearOptions[Number(e.detail.value)]!)}
>
<Button variant="ghost" className="h-8 px-2">
<Text className="text-sm font-medium">
{visibleMonth.getFullYear()}
</Text>
<ChevronDown size={16} className="opacity-50" color="inherit" />
</Button>
</Picker>
<Picker
mode="selector"
range={monthOptions}
value={monthIndex}
onChange={(e) =>
setMonthOfYear(monthOptions[Number(e.detail.value)]!)
}
>
<Button variant="ghost" className="h-8 px-2">
<Text className="text-sm font-medium">
{String(visibleMonth.getMonth() + 1).padStart(2, "0")}
</Text>
<ChevronDown size={16} className="opacity-50" color="inherit" />
</Button>
</Picker>
</View>
) : (
<Text className="text-sm font-medium">
{format(visibleMonth, "yyyy年MM月")}
</Text>
)}
{captionHasButtons ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setMonth(addMonths(visibleMonth, 1))}
>
<ChevronRight size={16} color="inherit" />
</Button>
) : (
<View className="h-8 w-8" />
)}
</View>
<View className="flex">
{weekdays.map((label) => (
<View key={label} className="flex flex-1 items-center justify-center">
<Text className="text-muted-foreground text-xs font-normal">
{label}
</Text>
</View>
))}
</View>
<View className="flex flex-col gap-2">
{weeks.map((week, rowIndex) => (
<View key={rowIndex} className="flex">
{week.map((date) => {
const outside = !isSameMonth(date, visibleMonth)
const hidden = outside && !showOutsideDays
const disabledDay = isDateDisabled(date, disabled)
const today = isSameDay(date, new Date())
const range = rangeSelected
const selectedSingle = singleSelected
? isSameDay(date, singleSelected)
: false
const rangeStart = range?.from ? isSameDay(date, range.from) : false
const rangeEnd = range?.to ? isSameDay(date, range.to) : false
const rangeMiddle =
!!range?.from && !!range?.to && isInRange(date, range) && !rangeStart && !rangeEnd
return (
<View
key={date.toISOString()}
className={cn("flex flex-1 items-center justify-center", hidden && "invisible")}
>
<CalendarDayButton
date={date}
outside={outside}
today={today}
disabled={disabledDay}
selectedSingle={selectedSingle}
rangeStart={rangeStart}
rangeMiddle={rangeMiddle}
rangeEnd={rangeEnd}
onPress={handleSelect}
/>
</View>
)
})}
</View>
))}
</View>
</View>
)
}
type CalendarDayButtonProps = {
date: Date
outside: boolean
today: boolean
disabled: boolean
selectedSingle: boolean
rangeStart: boolean
rangeMiddle: boolean
rangeEnd: boolean
onPress: (date: Date) => void
}
function CalendarDayButton({
date,
outside,
today,
disabled,
selectedSingle,
rangeStart,
rangeMiddle,
rangeEnd,
onPress,
}: CalendarDayButtonProps) {
const base = "h-8 w-8 p-0 flex items-center justify-center rounded-md"
const outsideClass = outside ? "text-muted-foreground" : ""
const todayClass = today ? "bg-accent text-accent-foreground" : ""
const selectedSingleClass = selectedSingle
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
: ""
const rangeStartClass = rangeStart
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
: ""
const rangeEndClass = rangeEnd
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
: ""
const rangeMiddleClass = rangeMiddle
? "bg-accent text-accent-foreground rounded-none"
: ""
const rangeCapClass = rangeStart || rangeEnd ? "rounded-md" : ""
return (
<Button
variant="ghost"
size="icon"
className={cn(
base,
outsideClass,
todayClass,
selectedSingleClass,
rangeMiddleClass,
rangeStartClass,
rangeEndClass,
rangeCapClass,
disabled && "opacity-50 pointer-events-none"
)}
onClick={disabled ? undefined : () => onPress(date)}
>
<Text className="text-sm">{format(date, "d")}</Text>
</Button>
)
}
export { Calendar, CalendarDayButton }
@@ -0,0 +1,108 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
// 创建一个上下文来跟踪卡片内部的状态
const CardContext = React.createContext<{ hasHeader: boolean }>({
hasHeader: false,
})
const Card = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => {
// 检查子元素中是否有 CardHeader
const hasHeader = React.Children.toArray(children).some(
(child) => React.isValidElement(child) && (child.type as any).displayName === "CardHeader"
)
return (
<CardContext.Provider value={{ hasHeader }}>
<View
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
>
{children}
</View>
</CardContext.Provider>
)
})
Card.displayName = "Card"
const CardHeader = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("flex flex-col space-y-2 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const { hasHeader } = React.useContext(CardContext)
return (
<View
ref={ref}
className={cn("p-6", hasHeader && "pt-0", className)}
{...props}
/>
)
})
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const { hasHeader } = React.useContext(CardContext)
// 注意:Footer 通常也跟在 Content 后面,所以这里逻辑可以更精细,
// 但为了简单通用,如果卡片有 Header,Footer 默认 pt-0 也是合理的。
return (
<View
ref={ref}
className={cn("flex items-center p-6", hasHeader && "pt-0", className)}
{...props}
/>
)
})
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,228 @@
import * as React from "react"
import { View, Swiper, SwiperItem } from "@tarojs/components"
import { ArrowLeft, ArrowRight } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselProps = {
opts?: {
loop?: boolean
autoplay?: boolean
interval?: number
duration?: number
displayMultipleItems?: number
}
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
className?: string
children?: React.ReactNode
}
export type CarouselApi = {
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
scrollTo: (index: number) => void
selectedScrollSnap: () => number
}
type CarouselContextProps = {
orientation: "horizontal" | "vertical"
current: number
setCurrent: (index: number) => void
count: number
setCount: (count: number) => void
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
opts?: CarouselProps["opts"]
}
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
React.ElementRef<typeof View>,
CarouselProps
>(({ opts, orientation = "horizontal", setApi, className, children, ...props }, ref) => {
const [current, setCurrent] = React.useState(0)
const [count, setCount] = React.useState(0)
const scrollPrev = React.useCallback(() => {
setCurrent((prev) => Math.max(0, prev - 1))
}, [])
const scrollNext = React.useCallback(() => {
setCurrent((prev) => Math.min(count - 1, prev + 1))
}, [count])
const canScrollPrev = current > 0
const canScrollNext = current < count - 1
const scrollTo = React.useCallback((index: number) => {
setCurrent(index)
}, [])
const selectedScrollSnap = React.useCallback(() => current, [current])
React.useEffect(() => {
if (setApi) {
setApi({
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
scrollTo,
selectedScrollSnap,
})
}
}, [setApi, scrollPrev, scrollNext, canScrollPrev, canScrollNext, scrollTo, selectedScrollSnap])
return (
<CarouselContext.Provider
value={{
orientation,
current,
setCurrent,
count,
setCount,
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
opts,
}}
>
<View
ref={ref}
className={cn("relative w-full", className)}
{...props}
>
{children}
</View>
</CarouselContext.Provider>
)
})
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
React.ElementRef<typeof Swiper>,
React.ComponentPropsWithoutRef<typeof Swiper>
>(({ className, children, ...props }, ref) => {
const { orientation, current, setCurrent, setCount, opts } = useCarousel()
React.useEffect(() => {
const childCount = React.Children.count(children)
setCount(childCount)
}, [children, setCount])
return (
<View className={cn("overflow-hidden", className)}>
<Swiper
ref={ref}
className="h-full w-full"
vertical={orientation === "vertical"}
current={current}
onChange={(e) => setCurrent(e.detail.current)}
circular={opts?.loop}
autoplay={opts?.autoplay}
interval={opts?.interval || 5000}
duration={opts?.duration || 500}
displayMultipleItems={opts?.displayMultipleItems || 1}
{...props}
>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
return <SwiperItem className="h-full w-full">{child}</SwiperItem>
}
return null
})}
</Swiper>
</View>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = ({ className, children, ...props }: React.ComponentProps<typeof View>) => {
return (
<View className={cn("h-full w-full", className)} {...props}>
{children}
</View>
)
}
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full z-10 bg-background bg-opacity-80 backdrop-blur-sm",
orientation === "horizontal"
? "-left-12 top-0 bottom-0 my-auto"
: "-top-12 left-0 right-0 mx-auto rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft size={16} color="inherit" />
<View className="sr-only">Previous slide</View>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full z-10 bg-background bg-opacity-80 backdrop-blur-sm",
orientation === "horizontal"
? "-right-12 top-0 bottom-0 my-auto"
: "-bottom-12 left-0 right-0 mx-auto rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight size={16} color="inherit" />
<View className="sr-only">Next slide</View>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
@@ -0,0 +1,58 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { Check } from "lucide-react-taro"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof View>,
Omit<React.ComponentPropsWithoutRef<typeof View>, "onClick"> & {
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
}
>(({ className, checked: checkedProp, defaultChecked, onCheckedChange, disabled, ...props }, ref) => {
const [checkedState, setCheckedState] = React.useState<boolean>(
defaultChecked ?? false
)
const isControlled = checkedProp !== undefined
const checked = isControlled ? checkedProp : checkedState
const handleClick = (e) => {
if (disabled) return
e.stopPropagation()
const newChecked = !checked
if (!isControlled) {
setCheckedState(newChecked)
}
onCheckedChange?.(newChecked)
}
const tabIndex = (props as any).tabIndex ?? (disabled ? -1 : 0)
return (
<View
ref={ref}
className={cn(
"h-4 w-4 shrink-0 rounded-sm border-2 border-primary ring-offset-background flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary text-primary-foreground" : "bg-transparent",
className
)}
{...({ tabIndex } as any)}
hoverClass={
disabled
? undefined
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
}
onClick={handleClick}
{...props}
>
{checked && <Check color="#fff" size={12} strokeWidth={3} className="text-current" />}
</View>
)
})
Checkbox.displayName = "Checkbox"
export { Checkbox }
@@ -0,0 +1,169 @@
import { View, Text } from '@tarojs/components'
import { useMemo } from 'react'
import { Copy } from 'lucide-react-taro'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import Taro from '@tarojs/taro'
import type { FC } from 'react'
type TokenType =
| 'keyword'
| 'string'
| 'comment'
| 'number'
| 'function'
| 'tag'
| 'attr'
| 'operator'
| 'plain'
interface Token {
type: TokenType
content: string
}
const RULES: { type: TokenType; regex: RegExp }[] = (
[
{ type: 'comment', regex: /\/\/.*/ },
{ type: 'comment', regex: /\/\*[\s\S]*?\*\// },
{
type: 'string',
regex: /"(?:\\.|[^\\"])*"|'(?:\\.|[^\\'])*'|`(?:\\.|[^\\`])*`/,
},
{
type: 'keyword',
regex: /\b(?:import|from|export|default|const|let|var|function|return|if|else|switch|case|break|continue|for|while|do|try|catch|finally|throw|new|this|super|class|extends|implements|interface|type|enum|namespace|as|async|await|yield|void|delete|typeof|instanceof|in|of|null|undefined|true|false)\b/,
},
{ type: 'number', regex: /\b\d+(\.\d+)?\b/ },
{ type: 'tag', regex: /<\/?[a-zA-Z][a-zA-Z0-9]*\b/ },
{ type: 'attr', regex: /\b[a-z][a-z0-9]*(?==)/i },
{ type: 'function', regex: /\b[a-zA-Z_$][a-zA-Z0-9_$]*(?=\s*\()/ },
{ type: 'operator', regex: /[+\-*/%=<>!&|^~]/ },
] as { type: TokenType; regex: RegExp }[]
).map(rule => ({
...rule,
regex: new RegExp(rule.regex.source, (rule.regex.flags || '') + 'y'),
}))
function tokenize(code: string): Token[] {
const tokens: Token[] = []
let lastIndex = 0
while (lastIndex < code.length) {
let matchFound = false
for (const rule of RULES) {
rule.regex.lastIndex = lastIndex
const match = rule.regex.exec(code)
if (match) {
tokens.push({ type: rule.type, content: match[0] })
lastIndex += match[0].length
matchFound = true
break
}
}
if (!matchFound) {
let plainContent = code[lastIndex]
lastIndex++
// Look ahead for next match to group plain text
while (lastIndex < code.length) {
let nextMatch = false
for (const rule of RULES) {
rule.regex.lastIndex = lastIndex
const match = rule.regex.exec(code)
if (match) {
nextMatch = true
break
}
}
if (nextMatch) break
plainContent += code[lastIndex]
lastIndex++
}
tokens.push({ type: 'plain', content: plainContent })
}
}
return tokens
}
interface CodeBlockProps {
code: string
className?: string
style?: React.CSSProperties
scrollAreaClassName?: string
showCopyButton?: boolean
language?: string
}
const getTokenColor = (type: TokenType) => {
switch (type) {
case 'keyword': return '#D73A49' // red
case 'string': return '#032F62' // dark blue
case 'comment': return '#6A737D' // gray
case 'number': return '#005CC5' // blue
case 'function': return '#6F42C1' // purple
case 'tag': return '#005CC5' // blue
case 'attr': return '#6F42C1' // purple
case 'operator': return '#D73A49' // red
default: return '#24292E'
}
}
const CodeBlock: FC<CodeBlockProps> = ({
code,
className,
style,
scrollAreaClassName,
showCopyButton = true,
language
}) => {
const tokens = useMemo(() => tokenize(code), [code])
const copyCode = async () => {
await Taro.setClipboardData({ data: code })
Taro.showToast({ title: '已复制', icon: 'success' })
}
return (
<View className={cn("relative w-full overflow-hidden", className)} style={style}>
<ScrollArea
orientation="both"
className={cn("bg-code rounded-lg w-full", scrollAreaClassName)}
>
<View className="p-4 inline-block box-border min-w-full">
{language && (
<Text className="absolute top-2 left-2 text-xs text-muted-foreground uppercase font-mono pointer-events-none">
{language}
</Text>
)}
<Text className="text-xs font-mono whitespace-pre">
{tokens.map((token, i) => (
<Text
key={i}
style={{ color: getTokenColor(token.type) }}
>
{token.content}
</Text>
))}
</Text>
</View>
</ScrollArea>
{showCopyButton && (
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-6 w-6"
onClick={copyCode}
>
<Copy size={12} color="#a3a3a3" />
</Button>
)}
</View>
)
}
export { CodeBlock }
@@ -0,0 +1,71 @@
import * as React from "react"
import { View } from "@tarojs/components"
const CollapsibleContext = React.createContext<{
open: boolean
onOpenChange: (open: boolean) => void
} | null>(null)
const Collapsible = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
disabled?: boolean
}
>(({ open: openProp, defaultOpen, onOpenChange, disabled, ...props }, ref) => {
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (disabled) return
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
return (
<CollapsibleContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
<View ref={ref} {...props} />
</CollapsibleContext.Provider>
)
})
Collapsible.displayName = "Collapsible"
const CollapsibleTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
asChild?: boolean
}
>(({ className, onClick, asChild, ...props }, ref) => {
const context = React.useContext(CollapsibleContext)
return (
<View
ref={ref}
className={className}
onClick={(e) => {
context?.onOpenChange(!context.open)
onClick?.(e)
}}
{...props}
/>
)
})
CollapsibleTrigger.displayName = "CollapsibleTrigger"
const CollapsibleContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const context = React.useContext(CollapsibleContext)
if (!context?.open) return null
return <View ref={ref} className={className} {...props} />
})
CollapsibleContent.displayName = "CollapsibleContent"
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,385 @@
import * as React from "react"
import { View, Input, ScrollView } from "@tarojs/components"
import Taro from "@tarojs/taro"
import { Search } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const CommandContext = React.createContext<{
search: string
deferredSearch: string
setSearch: (search: string) => void
} | null>(null)
const CommandItemsContext = React.createContext<{
setItemState: (id: string, state: ItemState) => void
removeItem: (id: string) => void
hasAnyMatch: () => boolean
groupHasAnyMatch: (groupId: string) => boolean
itemsSize: number
} | null>(null)
type ItemState = { match: boolean; groupId?: string }
const GroupContext = React.createContext<{ groupId?: string } | null>(null)
function getNodeText(node: React.ReactNode): string {
if (node == null || typeof node === "boolean") return ""
if (typeof node === "string" || typeof node === "number") return String(node)
if (Array.isArray(node)) return node.map(getNodeText).join(" ")
if (React.isValidElement(node)) return getNodeText(node.props?.children)
return ""
}
const Command = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => {
const [search, setSearch] = React.useState("")
// 使用 deferredSearch 来延迟搜索过滤逻辑,确保输入框在输入时保持响应,解决微信小程序中的输入抖动和文字消失问题
const deferredSearch = React.useDeferredValue(search)
const [, setItemsTick] = React.useState(0)
const itemsRef = React.useRef<Map<string, ItemState>>(new Map())
const tickRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
return () => {
if (tickRef.current) clearTimeout(tickRef.current)
}
}, [])
const triggerItemsUpdate = React.useCallback(() => {
if (tickRef.current) return
// 使用短延时批处理项目状态更新,减少重绘频率
tickRef.current = setTimeout(() => {
setItemsTick((v) => v + 1)
tickRef.current = null
}, 16)
}, [])
const setItemState = React.useCallback((id: string, state: ItemState) => {
const prev = itemsRef.current.get(id)
if (prev?.match === state.match && prev?.groupId === state.groupId) return
itemsRef.current.set(id, state)
triggerItemsUpdate()
}, [triggerItemsUpdate])
const removeItem = React.useCallback((id: string) => {
if (!itemsRef.current.has(id)) return
itemsRef.current.delete(id)
triggerItemsUpdate()
}, [triggerItemsUpdate])
const hasAnyMatch = React.useCallback(() => {
for (const s of itemsRef.current.values()) {
if (s.match) return true
}
return false
}, [])
const groupHasAnyMatch = React.useCallback((groupId: string) => {
for (const s of itemsRef.current.values()) {
if (s.groupId === groupId && s.match) return true
}
return false
}, [])
const searchContextValue = React.useMemo(() => ({
search,
deferredSearch,
setSearch,
}), [search, deferredSearch])
const itemsContextValue = React.useMemo(() => ({
setItemState,
removeItem,
hasAnyMatch,
groupHasAnyMatch,
itemsSize: itemsRef.current.size,
}), [setItemState, removeItem, hasAnyMatch, groupHasAnyMatch])
return (
<CommandContext.Provider value={searchContextValue}>
<CommandItemsContext.Provider value={itemsContextValue}>
<View
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
>
{children}
</View>
</CommandItemsContext.Provider>
</CommandContext.Provider>
)
})
Command.displayName = "Command"
const CommandDialog = ({ children, ...props }) => {
const { open: openProp, defaultOpen, onOpenChange, ...rest } = props as any
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) setOpenState(newOpen)
onOpenChange?.(newOpen)
}
const enhancedChildren = React.useMemo(() => {
const enhance = (node: React.ReactNode): React.ReactNode =>
React.Children.map(node, (child) => {
if (!React.isValidElement(child)) return child
if (child.type === CommandInput) {
if (child.props?.focus === false) return child
return React.cloneElement(child as any, {
focus: open,
className: cn(child.props?.className, "pr-11")
})
}
if (!child.props?.children) return child
return React.cloneElement(child as any, undefined, enhance(child.props.children))
})
return enhance(children)
}, [children, open])
return (
<Dialog open={open} onOpenChange={handleOpenChange} {...rest}>
<DialogContent
className="overflow-hidden p-0 shadow-lg"
closeClassName="top-3"
>
<Command>{enhancedChildren}</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentPropsWithoutRef<typeof Input> & { focus?: boolean }
>(({ className, placeholderClass, focus = true, ...props }, ref) => {
const context = React.useContext(CommandContext)
const [localValue, setLocalValue] = React.useState(context?.search ?? "")
const lastSyncedSearchRef = React.useRef(context?.search ?? "")
const [inputFocus, setInputFocus] = React.useState(false)
const focusTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
// 只有当 context.search 与上次同步的值不同,且与当前输入值也不同时,才进行强制同步(通常是外部重置了搜索内容)
if (context?.search !== lastSyncedSearchRef.current && context?.search !== localValue) {
setLocalValue(context?.search ?? "")
lastSyncedSearchRef.current = context?.search ?? ""
}
}, [context?.search, localValue])
React.useEffect(() => {
if (focusTimerRef.current) clearTimeout(focusTimerRef.current)
focusTimerRef.current = null
if (!focus) {
setInputFocus(false)
return
}
setInputFocus(false)
const schedule = () => {
focusTimerRef.current = setTimeout(() => {
setInputFocus(true)
focusTimerRef.current = null
}, 0)
}
if (typeof (Taro as any)?.nextTick === "function") {
;(Taro as any).nextTick(schedule)
} else {
schedule()
}
return () => {
if (focusTimerRef.current) clearTimeout(focusTimerRef.current)
focusTimerRef.current = null
}
}, [focus])
return (
<View
className="flex h-11 items-center border-b px-3"
data-slot="command-input-wrapper"
>
<Search className="mr-2 shrink-0 opacity-50" size={16} color="inherit" />
<Input
ref={ref}
className={cn(
"min-w-0 flex-1 rounded-md bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
placeholderClass={cn("text-muted-foreground", placeholderClass)}
value={localValue}
onInput={(e) => {
const v = e.detail.value
setLocalValue(v)
lastSyncedSearchRef.current = v
context?.setSearch(v)
}}
focus={inputFocus}
{...props}
/>
</View>
)
})
CommandInput.displayName = "CommandInput"
const CommandList = React.forwardRef<
React.ElementRef<typeof ScrollView>,
React.ComponentPropsWithoutRef<typeof ScrollView>
>(({ className, ...props }, ref) => (
<ScrollView
scrollY
ref={ref}
className={cn("overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = "CommandList"
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const context = React.useContext(CommandItemsContext)
const show = context ? context.itemsSize > 0 && !context.hasAnyMatch() : false
if (!show) return null
return (
<View
ref={ref}
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
})
CommandEmpty.displayName = "CommandEmpty"
const CommandGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { heading?: React.ReactNode }
>(({ className, heading, children, ...props }, ref) => {
const context = React.useContext(CommandItemsContext)
const groupId = React.useId()
const show =
!context || context.itemsSize === 0 || context.groupHasAnyMatch(groupId)
return (
<GroupContext.Provider value={{ groupId }}>
<View
ref={ref}
data-slot="command-group"
className={cn("overflow-hidden p-1 text-foreground", className)}
style={!show ? ({ display: "none" } as any) : undefined}
{...props}
>
{heading && (
<View
data-slot="command-group-heading"
className="px-2 py-2 text-xs font-medium text-muted-foreground"
>
{heading}
</View>
)}
{children}
</View>
</GroupContext.Provider>
)
})
CommandGroup.displayName = "CommandGroup"
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = "CommandSeparator"
const CommandItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value?: string
onSelect?: (value: string) => void
disabled?: boolean
}
>(({ className, value, onSelect, disabled, children, ...props }, ref) => {
const context = React.useContext(CommandContext)
const itemsContext = React.useContext(CommandItemsContext)
const group = React.useContext(GroupContext)
const id = React.useId()
const computedValue = React.useMemo(() => (value ?? getNodeText(children)).trim(), [value, children])
const search = (context?.deferredSearch ?? "").trim().toLowerCase()
const match = React.useMemo(() =>
!search || (!!computedValue && computedValue.toLowerCase().includes(search))
, [search, computedValue])
React.useEffect(() => {
if (!itemsContext) return
itemsContext.setItemState(id, { match, groupId: group?.groupId })
return () => itemsContext.removeItem(id)
}, [itemsContext, id, match, group?.groupId])
return (
<View
ref={ref}
data-slot="command-item"
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-2 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
disabled && "opacity-50 pointer-events-none",
className
)}
style={!match ? ({ display: "none" } as any) : undefined}
onClick={() => !disabled && onSelect?.(computedValue)}
{...props}
>
{children}
</View>
)
})
CommandItem.displayName = "CommandItem"
const CommandShortcut = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => {
return (
<View
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
@@ -0,0 +1,614 @@
import * as React from "react"
import { View, ScrollView } from "@tarojs/components"
import Taro from "@tarojs/taro"
import { Check, ChevronRight, Circle } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { isH5 } from "@/lib/platform"
import { computePosition, getRectById, getViewport } from "@/lib/measure"
import { Portal } from "@/components/ui/portal"
const ContextMenuContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
position: { x: number; y: number }
setPosition: (pos: { x: number; y: number }) => void
activeSubId?: string | null
setActiveSubId: (id: string | null) => void
} | null>(null)
interface ContextMenuProps {
children: React.ReactNode
onOpenChange?: (open: boolean) => void
}
const ContextMenu = ({ children, onOpenChange }: ContextMenuProps) => {
const [open, setOpen] = React.useState(false)
const [position, setPosition] = React.useState({ x: 0, y: 0 })
const [activeSubId, setActiveSubId] = React.useState<string | null>(null)
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen)
if (!newOpen) setActiveSubId(null)
onOpenChange?.(newOpen)
}
return (
<ContextMenuContext.Provider
value={{ open, onOpenChange: handleOpenChange, position, setPosition, activeSubId, setActiveSubId }}
>
{children}
</ContextMenuContext.Provider>
)
}
const ContextMenuTrigger = React.forwardRef<
any,
React.ComponentPropsWithoutRef<typeof View> & { disabled?: boolean }
>(({ className, children, disabled, ...props }, ref) => {
const context = React.useContext(ContextMenuContext)
const touchPos = React.useRef({ x: 0, y: 0 })
const handleTrigger = (x: number, y: number) => {
if (disabled) return
context?.setPosition({ x, y })
context?.onOpenChange?.(true)
}
if (isH5()) {
const { onLongPress: _onLongPress, onTouchStart: _onTouchStart, ...rest } = props as any
return (
<div
ref={ref}
className={className}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
handleTrigger(e.clientX, e.clientY)
}}
{...rest}
>
{children}
</div>
)
}
return (
<View
ref={ref}
className={className}
onTouchStart={(e) => {
const touch = (e as unknown as { touches?: Array<{ pageX: number; pageY: number }> }).touches?.[0]
if (!touch) return
touchPos.current = { x: touch.pageX, y: touch.pageY }
}}
onLongPress={(e) => {
e.stopPropagation()
handleTrigger(touchPos.current.x, touchPos.current.y)
}}
{...props}
>
{children}
</View>
)
})
ContextMenuTrigger.displayName = "ContextMenuTrigger"
const ContextMenuGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} className={className} {...props} />
))
ContextMenuGroup.displayName = "ContextMenuGroup"
const ContextMenuPortal = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>
}
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => {
const context = React.useContext(ContextMenuContext)
const contentId = React.useRef(`context-menu-${Math.random().toString(36).slice(2, 10)}`)
const [adjustedPos, setAdjustedPos] = React.useState<{ x: number; y: number } | null>(null)
React.useEffect(() => {
if (!context?.open) {
setAdjustedPos(null)
return
}
let cancelled = false
const compute = async () => {
const { width: vw, height: vh } = getViewport()
let { x, y } = context.position
if (isH5() && typeof document !== "undefined") {
const el = document.getElementById(contentId.current)
const rect = el?.getBoundingClientRect()
if (rect) {
if (x + rect.width > vw) x = vw - rect.width - 8
if (y + rect.height > vh) y = vh - rect.height - 8
}
if (!cancelled) setAdjustedPos({ x, y })
return
}
const query = Taro.createSelectorQuery()
query
.select(`#${contentId.current}`)
.boundingClientRect((res) => {
if (cancelled) return
const rect = Array.isArray(res) ? res[0] : res
if (rect?.width) {
if (x + rect.width > vw) x = vw - rect.width - 8
if (y + rect.height > vh) y = vh - rect.height - 8
}
setAdjustedPos({ x, y })
})
.exec()
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [context?.open, context?.position])
if (!context?.open) return null
const contentStyle: React.CSSProperties = adjustedPos
? { left: adjustedPos.x, top: adjustedPos.y }
: { left: context.position.x, top: context.position.y }
return (
<Portal>
<View
className="fixed inset-0 z-50 bg-transparent"
onClick={() => context.onOpenChange?.(false)}
// @ts-ignore
onContextMenu={(e) => {
e.preventDefault()
context.onOpenChange?.(false)
}}
/>
<View
ref={ref}
id={contentId.current}
data-slot="context-menu-content"
data-state="open"
className={cn(
"fixed z-50 min-w-32 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
style={contentStyle}
onClick={(e) => e.stopPropagation()}
{...props}
>
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
{children}
</ScrollView>
</View>
</Portal>
)
})
ContextMenuContent.displayName = "ContextMenuContent"
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, inset, disabled, closeOnSelect = true, children, onClick, ...props }, ref) => {
const context = React.useContext(ContextMenuContext)
return (
<View
ref={ref}
data-slot="context-menu-item"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
if (disabled) return
e.stopPropagation()
onClick?.(e)
if (closeOnSelect) context?.onOpenChange?.(false)
}}
{...props}
>
{children}
</View>
)
})
ContextMenuItem.displayName = "ContextMenuItem"
const ContextMenuRadioGroupContext = React.createContext<{
value?: string
onValueChange?: (value: string) => void
} | null>(null)
interface ContextMenuRadioGroupProps extends React.ComponentPropsWithoutRef<typeof View> {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}
const ContextMenuRadioGroup = React.forwardRef<
React.ElementRef<typeof View>,
ContextMenuRadioGroupProps
>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (next: string) => {
if (valueProp === undefined) {
setValueState(next)
}
onValueChange?.(next)
}
return (
<ContextMenuRadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
<View ref={ref} {...props} />
</ContextMenuRadioGroupContext.Provider>
)
})
ContextMenuRadioGroup.displayName = "ContextMenuRadioGroup"
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
checked?: boolean
inset?: boolean
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
const context = React.useContext(ContextMenuContext)
return (
<View
ref={ref}
data-slot="context-menu-checkbox-item"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
if (disabled) return
e.stopPropagation()
onClick?.(e)
if (closeOnSelect) context?.onOpenChange?.(false)
}}
{...props}
>
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
{checked && <Check size={16} color="inherit" />}
</View>
{children}
</View>
)
})
ContextMenuCheckboxItem.displayName = "ContextMenuCheckboxItem"
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value: string
checked?: boolean
inset?: boolean
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, children, value, checked: checkedProp, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
const context = React.useContext(ContextMenuContext)
const group = React.useContext(ContextMenuRadioGroupContext)
const checked = checkedProp !== undefined ? checkedProp : group?.value === value
return (
<View
ref={ref}
data-slot="context-menu-radio-item"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
if (disabled) return
e.stopPropagation()
group?.onValueChange?.(value)
onClick?.(e)
if (closeOnSelect) context?.onOpenChange?.(false)
}}
{...props}
>
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
{checked && <Circle className="fill-current" size={8} color="inherit" />}
</View>
{children}
</View>
)
})
ContextMenuRadioItem.displayName = "ContextMenuRadioItem"
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<View
ref={ref}
className={cn(
"px-2 py-1 text-xs font-medium text-muted-foreground",
inset && "pl-7",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = "ContextMenuLabel"
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = "ContextMenuSeparator"
const ContextMenuShortcut = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => {
return (
<View
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
const ContextMenuSubContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
triggerId: string
} | null>(null)
interface ContextMenuSubProps {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const ContextMenuSub = ({ open: openProp, defaultOpen, onOpenChange, children }: ContextMenuSubProps) => {
const parent = React.useContext(ContextMenuContext)
const baseIdRef = React.useRef(`context-menu-sub-${Math.random().toString(36).slice(2, 10)}`)
const [openState, setOpenState] = React.useState(defaultOpen || false)
const isActive = parent?.activeSubId === baseIdRef.current
const open = openProp !== undefined ? openProp : openState && isActive
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (openProp === undefined) {
setOpenState(nextOpen)
if (nextOpen) {
parent?.setActiveSubId(baseIdRef.current)
} else if (parent?.activeSubId === baseIdRef.current) {
parent?.setActiveSubId(null)
}
} else {
if (nextOpen) {
parent?.setActiveSubId(baseIdRef.current)
} else if (parent?.activeSubId === baseIdRef.current) {
parent?.setActiveSubId(null)
}
}
onOpenChange?.(nextOpen)
},
[onOpenChange, openProp, parent]
)
React.useEffect(() => {
if (defaultOpen) {
setOpenState(true)
parent?.setActiveSubId(baseIdRef.current)
}
}, [])
React.useEffect(() => {
if (parent?.open === false && open) {
handleOpenChange(false)
}
}, [handleOpenChange, open, parent?.open])
return (
<ContextMenuSubContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}>
{children}
</ContextMenuSubContext.Provider>
)
}
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
disabled?: boolean
}
>(({ className, inset, disabled, children, onClick, ...props }, ref) => {
const subContext = React.useContext(ContextMenuSubContext)
return (
<View
{...props}
ref={ref}
id={subContext?.triggerId}
data-slot="context-menu-sub-trigger"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={subContext?.open ? "open" : "closed"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
e.stopPropagation()
if (disabled) return
subContext?.onOpenChange?.(!subContext.open)
onClick?.(e)
}}
>
{children}
<ChevronRight className="ml-auto opacity-50" size={16} color="inherit" />
</View>
)
})
interface ContextMenuSubContentProps extends React.ComponentPropsWithoutRef<typeof View> {
align?: "start" | "center" | "end"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof View>,
ContextMenuSubContentProps
>(({ className, align = "start", side = "right", sideOffset = 4, children, ...props }, ref) => {
const parent = React.useContext(ContextMenuContext)
const subContext = React.useContext(ContextMenuSubContext)
const contentId = React.useRef(`context-menu-sub-content-${Math.random().toString(36).slice(2, 10)}`)
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
React.useEffect(() => {
if (!parent?.open || !subContext?.open) {
setPosition(null)
return
}
let cancelled = false
const compute = async () => {
if (!subContext?.triggerId) return
const [triggerRect, contentRect] = await Promise.all([
getRectById(subContext.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
setPosition(
computePosition({
triggerRect,
contentRect,
align,
side,
sideOffset,
})
)
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, parent?.open, side, sideOffset, subContext?.open, subContext?.triggerId])
if (!parent?.open || !subContext?.open) return null
const baseClassName =
"fixed z-50 min-w-[96px] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
const contentStyle = position
? ({ left: position.left, top: position.top } as React.CSSProperties)
: ({
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
return (
<Portal>
<View
{...props}
ref={ref}
id={contentId.current}
data-slot="context-menu-sub-content"
data-state="open"
data-side={side}
className={cn(baseClassName, className)}
style={contentStyle}
onClick={(e) => e.stopPropagation()}
>
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
{children}
</ScrollView>
</View>
</Portal>
)
})
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
@@ -0,0 +1,256 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { X } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { Portal } from "@/components/ui/portal"
import { useKeyboardOffset } from "@/lib/hooks/use-keyboard-offset"
const DialogContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
} | null>(null)
const usePresence = (open: boolean | undefined, durationMs: number) => {
const [present, setPresent] = React.useState(!!open)
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
if (open) {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = null
setPresent(true)
return
}
timeoutRef.current = setTimeout(() => setPresent(false), durationMs)
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [open, durationMs])
return present
}
interface DialogProps {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
modal?: boolean
}
const Dialog = ({ children, open: openProp, defaultOpen, onOpenChange }: DialogProps) => {
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
return (
<DialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
{children}
</DialogContext.Provider>
)
}
const DialogTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
>(({ className, children, asChild, ...props }, ref) => {
const context = React.useContext(DialogContext)
return (
<View
ref={ref}
className={cn("w-fit", className)}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(true)
}}
{...props}
>
{children}
</View>
)
})
DialogTrigger.displayName = "DialogTrigger"
const DialogPortal = ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(DialogContext)
const present = usePresence(context?.open, 200)
if (!present) return null
return <Portal>{children}</Portal>
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, onClick, ...props }, ref) => {
const context = React.useContext(DialogContext)
const state = context?.open ? "open" : "closed"
return (
<View
ref={ref}
data-state={state}
className={cn(
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
onClick={(e) => {
e.stopPropagation()
onClick?.(e)
context?.onOpenChange?.(false)
}}
{...props}
/>
)
})
DialogOverlay.displayName = "DialogOverlay"
interface DialogContentProps extends React.ComponentPropsWithoutRef<typeof View> {
closeClassName?: string
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof View>,
DialogContentProps
>(({ className, children, style, closeClassName, ...props }, ref) => {
const context = React.useContext(DialogContext)
const offset = useKeyboardOffset()
const state = context?.open ? "open" : "closed"
return (
<DialogPortal>
<View
className="fixed inset-0 z-50"
onClick={() => context?.onOpenChange?.(false)}
>
<DialogOverlay />
<View
ref={ref}
data-state={state}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl",
className
)}
style={{
...(style as object),
top: offset > 0 ? `calc(50% - ${offset / 2}px)` : undefined
}}
onClick={(e) => e.stopPropagation()}
{...props}
>
{children}
<View
data-slot="dialog-close"
className={cn(
"absolute right-4 top-4 flex h-6 w-6 items-center justify-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
closeClassName
)}
data-state={state}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(false)
}}
>
<X size={16} color="inherit" />
<View className="sr-only">Close</View>
</View>
</View>
</View>
</DialogPortal>
)
})
DialogContent.displayName = "DialogContent"
const DialogHeader = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = "DialogTitle"
const DialogDescription = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = "DialogDescription"
const DialogClose = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const context = React.useContext(DialogContext)
return (
<View
ref={ref}
className={className}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(false)
}}
{...props}
/>
)
})
DialogClose.displayName = "DialogClose"
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,192 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
import { Portal } from "@/components/ui/portal"
const DrawerContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
} | null>(null)
interface DrawerProps extends React.ComponentPropsWithoutRef<typeof View> {
shouldScaleBackground?: boolean
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const Drawer = ({
shouldScaleBackground = true,
children,
open: openProp,
defaultOpen,
onOpenChange,
...props
}: DrawerProps) => {
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
return (
<DrawerContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
<View {...props}>{children}</View>
</DrawerContext.Provider>
)
}
Drawer.displayName = "Drawer"
const DrawerTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
>(({ className, children, asChild, ...props }, ref) => {
const context = React.useContext(DrawerContext)
return (
<View
ref={ref}
className={cn("w-fit", className)}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(true)
}}
{...props}
>
{children}
</View>
)
})
DrawerTrigger.displayName = "DrawerTrigger"
const DrawerPortal = ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(DrawerContext)
if (!context?.open) return null
return <Portal>{children}</Portal>
}
const DrawerClose = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
>(({ className, children, asChild, ...props }, ref) => {
const context = React.useContext(DrawerContext)
return (
<View
ref={ref}
className={className}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(false)
}}
{...props}
>
{children}
</View>
)
})
DrawerClose.displayName = "DrawerClose"
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const context = React.useContext(DrawerContext)
return (
<View
ref={ref}
className={cn(
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
onClick={() => context?.onOpenChange?.(false)}
{...props}
/>
)
})
DrawerOverlay.displayName = "DrawerOverlay"
const DrawerContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<View
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom duration-300",
className
)}
{...props}
>
<View className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</View>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn("grid gap-2 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = "DrawerTitle"
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = "DrawerDescription"
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -0,0 +1,561 @@
import * as React from "react"
import { ScrollView, View } from "@tarojs/components"
import { Check, ChevronRight } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { isH5 } from "@/lib/platform"
import { computePosition, getRectById } from "@/lib/measure"
import { Portal } from "@/components/ui/portal"
const DropdownMenuContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
triggerId: string
} | null>(null)
interface DropdownMenuProps {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const DropdownMenu = ({ open: openProp, defaultOpen, onOpenChange, children }: DropdownMenuProps) => {
const baseIdRef = React.useRef(`dropdown-menu-${Math.random().toString(36).slice(2, 10)}`)
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
return (
<DropdownMenuContext.Provider
value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}
>
{children}
</DropdownMenuContext.Provider>
)
}
const DropdownMenuTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, onClick, ...props }, ref) => {
const context = React.useContext(DropdownMenuContext)
return (
<View
{...props}
ref={ref}
id={context?.triggerId}
data-slot="dropdown-menu-trigger"
className={className}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(!context.open)
onClick?.(e)
}}
>
{children}
</View>
)
})
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
const DropdownMenuGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} data-slot="dropdown-menu-group" className={className} {...props} />
))
DropdownMenuGroup.displayName = "DropdownMenuGroup"
const DropdownMenuPortal = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>
}
const DropdownMenuRadioGroupContext = React.createContext<{
value?: string
onValueChange?: (value: string) => void
} | null>(null)
interface DropdownMenuRadioGroupProps extends React.ComponentPropsWithoutRef<typeof View> {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}
const DropdownMenuRadioGroup = React.forwardRef<
React.ElementRef<typeof View>,
DropdownMenuRadioGroupProps
>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (next: string) => {
if (valueProp === undefined) {
setValueState(next)
}
onValueChange?.(next)
}
return (
<DropdownMenuRadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
<View ref={ref} {...props} />
</DropdownMenuRadioGroupContext.Provider>
)
})
DropdownMenuRadioGroup.displayName = "DropdownMenuRadioGroup"
const DropdownMenuSubContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
triggerId: string
} | null>(null)
interface DropdownMenuSubProps {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const DropdownMenuSub = ({ open: openProp, defaultOpen, onOpenChange, children }: DropdownMenuSubProps) => {
const parent = React.useContext(DropdownMenuContext)
const baseIdRef = React.useRef(`dropdown-menu-sub-${Math.random().toString(36).slice(2, 10)}`)
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
React.useEffect(() => {
if (parent?.open === false && open) {
handleOpenChange(false)
}
}, [open, parent?.open])
return (
<DropdownMenuSubContext.Provider
value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}
>
{children}
</DropdownMenuSubContext.Provider>
)
}
interface DropdownMenuContentProps extends React.ComponentPropsWithoutRef<typeof View> {
align?: "start" | "center" | "end"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof View>,
DropdownMenuContentProps
>(({ className, align = "start", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
const context = React.useContext(DropdownMenuContext)
const contentId = React.useRef(`dropdown-menu-content-${Math.random().toString(36).slice(2, 10)}`)
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
React.useEffect(() => {
if (!context?.open) {
setPosition(null)
return
}
let cancelled = false
const compute = async () => {
if (!context?.triggerId) return
const [triggerRect, contentRect] = await Promise.all([
getRectById(context.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
setPosition(
computePosition({
triggerRect,
contentRect,
align,
side,
sideOffset,
})
)
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, context?.open, context?.triggerId, side, sideOffset])
React.useEffect(() => {
if (!context?.open) return
if (!isH5() || typeof document === "undefined") return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
context?.onOpenChange?.(false)
}
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [context?.open])
if (!context?.open) return null
const baseClassName =
"fixed z-50 min-w-32 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
const contentStyle = position
? ({ left: position.left, top: position.top } as React.CSSProperties)
: ({
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
return (
<Portal>
<View className="fixed inset-0 z-50 bg-transparent" onClick={() => context.onOpenChange?.(false)} />
<View
{...props}
ref={ref}
id={contentId.current}
data-slot="dropdown-menu-content"
data-state="open"
data-side={side}
className={cn(baseClassName, className)}
style={contentStyle}
onClick={(e) => e.stopPropagation()}
>
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
{children}
</ScrollView>
</View>
</Portal>
)
})
DropdownMenuContent.displayName = "DropdownMenuContent"
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
variant?: "default" | "destructive"
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, inset, variant = "default", disabled, closeOnSelect = true, onClick, ...props }, ref) => {
const context = React.useContext(DropdownMenuContext)
return (
<View
{...props}
ref={ref}
data-slot="dropdown-menu-item"
data-inset={inset ? "" : undefined}
data-variant={variant}
data-disabled={disabled ? "" : undefined}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive data-[variant=destructive]:focus:bg-opacity-10 data-[variant=destructive]:focus:text-destructive",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
if (disabled) return
onClick?.(e)
if (closeOnSelect) context?.onOpenChange?.(false)
}}
/>
)
})
DropdownMenuItem.displayName = "DropdownMenuItem"
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
checked?: boolean
inset?: boolean
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
const context = React.useContext(DropdownMenuContext)
return (
<View
{...props}
ref={ref}
data-slot="dropdown-menu-checkbox-item"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-7",
className
)}
onClick={(e) => {
if (disabled) return
onClick?.(e)
if (closeOnSelect) context?.onOpenChange?.(false)
}}
>
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
{checked && <Check size={16} color="inherit" />}
</View>
{children}
</View>
)
})
DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value: string
checked?: boolean
inset?: boolean
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, children, value, checked: checkedProp, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
const context = React.useContext(DropdownMenuContext)
const group = React.useContext(DropdownMenuRadioGroupContext)
const checked = checkedProp !== undefined ? checkedProp : group?.value === value
return (
<View
{...props}
ref={ref}
data-slot="dropdown-menu-radio-item"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-7",
className
)}
onClick={(e) => {
if (disabled) return
group?.onValueChange?.(value)
onClick?.(e)
if (closeOnSelect) context?.onOpenChange?.(false)
}}
>
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
{checked && <Check size={16} color="inherit" />}
</View>
{children}
</View>
)
})
DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem"
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<View
ref={ref}
data-slot="dropdown-menu-label"
data-inset={inset ? "" : undefined}
className={cn("px-2 py-1 text-xs font-medium text-muted-foreground", inset && "pl-7", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = "DropdownMenuLabel"
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
const DropdownMenuShortcut = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => {
return (
<View
data-slot="dropdown-menu-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
disabled?: boolean
}
>(({ className, inset, disabled, children, onClick, ...props }, ref) => {
const subContext = React.useContext(DropdownMenuSubContext)
return (
<View
{...props}
ref={ref}
id={subContext?.triggerId}
data-slot="dropdown-menu-sub-trigger"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={subContext?.open ? "open" : "closed"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
e.stopPropagation()
if (disabled) return
subContext?.onOpenChange?.(!subContext.open)
onClick?.(e)
}}
>
{children}
<ChevronRight className="ml-auto opacity-50" size={16} color="inherit" />
</View>
)
})
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof View>,
DropdownMenuContentProps
>(({ className, align = "start", side = "right", sideOffset = 4, children, ...props }, ref) => {
const parent = React.useContext(DropdownMenuContext)
const subContext = React.useContext(DropdownMenuSubContext)
const contentId = React.useRef(`dropdown-menu-sub-content-${Math.random().toString(36).slice(2, 10)}`)
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
React.useEffect(() => {
if (!parent?.open || !subContext?.open) {
setPosition(null)
return
}
let cancelled = false
const compute = async () => {
if (!subContext?.triggerId) return
const [triggerRect, contentRect] = await Promise.all([
getRectById(subContext.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
setPosition(
computePosition({
triggerRect,
contentRect,
align,
side,
sideOffset,
})
)
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, parent?.open, side, sideOffset, subContext?.open, subContext?.triggerId])
if (!parent?.open || !subContext?.open) return null
const baseClassName =
"fixed z-50 min-w-[96px] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
const contentStyle = position
? ({ left: position.left, top: position.top } as React.CSSProperties)
: ({
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
return (
<Portal>
<View
{...props}
ref={ref}
id={contentId.current}
data-slot="dropdown-menu-sub-content"
data-state="open"
data-side={side}
className={cn(baseClassName, className)}
style={contentStyle}
onClick={(e) => e.stopPropagation()}
>
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
{children}
</ScrollView>
</View>
</Portal>
)
})
DropdownMenuSubContent.displayName = "DropdownMenuSubContent"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
@@ -0,0 +1,228 @@
import * as React from "react"
import { View, Text } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
return (
<View
data-slot="field-set"
className={cn(
"flex flex-col gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentPropsWithoutRef<typeof View> & { variant?: "legend" | "label" }) {
return (
<View
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
return (
<View
data-slot="field-group"
className={cn(
"flex w-full flex-col gap-3 data-[slot=checkbox-group]:gap-3",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"data-[invalid=true]:text-destructive flex w-full gap-1",
{
variants: {
orientation: {
vertical: ["flex-col [&>view]:w-full [&>label]:w-full"],
horizontal: [
"flex-row items-center",
],
responsive: ["flex-col [&>view]:w-full [&>label]:w-full"],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof fieldVariants>) {
return (
<View
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
return (
<View
data-slot="field-content"
className={cn(
"flex flex-1 flex-col gap-2 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"flex w-fit gap-2 leading-snug",
"[&>view]:p-1",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
return (
<View
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
return (
<View
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm font-normal leading-normal",
// "group-has-[[data-orientation=horizontal]]/field:text-balance", // text-balance not supported in Taro
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentPropsWithoutRef<typeof View> & {
children?: React.ReactNode
}) {
return (
<View
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<View
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</View>
)}
</View>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentPropsWithoutRef<typeof View> & {
errors?: Array<{ message?: string } | undefined>
}) {
// Memoize content if needed, or just render.
let content = children
if (!content && errors) {
if (errors.length === 1 && errors[0]?.message) {
content = <Text>{errors[0].message}</Text>
} else if (errors.length > 0) {
content = (
<View className="ml-4 flex flex-col gap-1">
{errors.map((error, index) =>
error?.message && <Text key={index} className="text-xs">{`\u2022 ${error.message}`}</Text>
)}
</View>
)
}
}
if (!content) {
return null
}
return (
<View
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</View>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
@@ -0,0 +1,282 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
import { isH5 } from "@/lib/platform"
import { getRectById, getViewport } from "@/lib/measure"
import { Portal } from "@/components/ui/portal"
type HoverCardProps = {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const HoverCardContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
triggerId: string
setHoverPart?: (part: "trigger" | "content", hovering: boolean) => void
} | null>(null)
const HoverCard = ({
open: openProp,
defaultOpen = false,
onOpenChange,
children,
}: HoverCardProps) => {
const baseIdRef = React.useRef(
`hover-card-${Math.random().toString(36).slice(2, 10)}`
)
const [openState, setOpenState] = React.useState(defaultOpen)
const open = openProp !== undefined ? openProp : openState
const hoverRef = React.useRef({ trigger: false, content: false })
const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
const setHoverPart = React.useCallback(
(part: "trigger" | "content", hovering: boolean) => {
if (!isH5()) return
hoverRef.current[part] = hovering
if (hovering) {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
handleOpenChange(true)
return
}
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
closeTimerRef.current = setTimeout(() => {
if (!hoverRef.current.trigger && !hoverRef.current.content) {
handleOpenChange(false)
}
}, 80)
},
[handleOpenChange]
)
React.useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
}
}, [])
return (
<HoverCardContext.Provider
value={{
open,
onOpenChange: handleOpenChange,
triggerId: baseIdRef.current,
setHoverPart,
}}
>
{children}
</HoverCardContext.Provider>
)
}
const HoverCardTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
onMouseEnter?: (e: React.MouseEvent) => void
onMouseLeave?: (e: React.MouseEvent) => void
}
>(({ className, children, onClick, onMouseEnter, onMouseLeave, ...props }, ref) => {
const context = React.useContext(HoverCardContext)
return (
<View
{...props}
ref={ref}
id={context?.triggerId}
className={className}
onClick={(e) => {
onClick?.(e)
e.stopPropagation()
context?.onOpenChange?.(!context.open)
}}
{...(isH5()
? ({
onMouseEnter: (e: React.MouseEvent) => {
onMouseEnter?.(e)
context?.setHoverPart?.("trigger", true)
},
onMouseLeave: (e: React.MouseEvent) => {
onMouseLeave?.(e)
context?.setHoverPart?.("trigger", false)
},
} as React.ComponentPropsWithoutRef<typeof View>)
: {})}
>
{children}
</View>
)
})
HoverCardTrigger.displayName = "HoverCardTrigger"
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
align?: "start" | "center" | "end"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
onMouseEnter?: (e: React.MouseEvent) => void
onMouseLeave?: (e: React.MouseEvent) => void
}
>(
(
{
className,
align = "center",
side = "bottom",
sideOffset = 4,
onMouseEnter,
onMouseLeave,
...props
},
ref
) => {
const context = React.useContext(HoverCardContext)
const contentId = React.useRef(
`hover-card-content-${Math.random().toString(36).slice(2, 10)}`
)
const [position, setPosition] = React.useState<
| {
left: number
top: number
}
| null
>(null)
React.useEffect(() => {
if (!context?.open) {
setPosition(null)
return
}
let cancelled = false
const compute = async () => {
const [triggerRect, contentRect] = await Promise.all([
getRectById(context.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
const vw = getViewport().width
const vh = getViewport().height
const margin = 8
const crossStart = (side === "left" || side === "right")
? triggerRect.top
: triggerRect.left
const crossSize = (side === "left" || side === "right")
? triggerRect.height
: triggerRect.width
const contentCrossSize = (side === "left" || side === "right")
? contentRect.height
: contentRect.width
const cross = (() => {
if (align === "start") return crossStart
if (align === "end") return crossStart + crossSize - contentCrossSize
return crossStart + crossSize / 2 - contentCrossSize / 2
})()
let left = 0
let top = 0
if (side === "bottom" || side === "top") {
left = cross
top =
side === "bottom"
? triggerRect.top + triggerRect.height + sideOffset
: triggerRect.top - contentRect.height - sideOffset
} else {
top = cross
left =
side === "right"
? triggerRect.left + triggerRect.width + sideOffset
: triggerRect.left - contentRect.width - sideOffset
}
left = Math.min(Math.max(left, margin), vw - contentRect.width - margin)
top = Math.min(Math.max(top, margin), vh - contentRect.height - margin)
setPosition({ left, top })
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, context?.open, context?.triggerId, side, sideOffset])
if (!context?.open) return null
const baseClassName =
"fixed z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
const contentStyle = position
? ({ left: position.left, top: position.top } as React.CSSProperties)
: ({
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
const overlay =
!isH5() ? (
<View
className="fixed inset-0 z-50 bg-transparent"
onClick={() => context.onOpenChange?.(false)}
/>
) : null
return (
<Portal>
{overlay}
<View
{...props}
ref={ref}
id={contentId.current}
className={cn(baseClassName, className)}
style={contentStyle}
{...(isH5()
? ({
onMouseEnter: (e: React.MouseEvent) => {
onMouseEnter?.(e)
context?.setHoverPart?.("content", true)
},
onMouseLeave: (e: React.MouseEvent) => {
onMouseLeave?.(e)
context?.setHoverPart?.("content", false)
},
} as React.ComponentPropsWithoutRef<typeof View>)
: {})}
/>
</Portal>
)
}
)
HoverCardContent.displayName = "HoverCardContent"
export { HoverCard, HoverCardTrigger, HoverCardContent }
@@ -0,0 +1,197 @@
import * as React from "react"
import { View, Text, Input, Textarea } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface InputGroupContextValue {
isFocused: boolean
setIsFocused: (value: boolean) => void
disabled?: boolean
}
const InputGroupContext = React.createContext<InputGroupContextValue>({
isFocused: false,
setIsFocused: () => {},
disabled: false,
})
function InputGroup({ className, disabled, ...props }: React.ComponentPropsWithoutRef<typeof View> & { disabled?: boolean }) {
const [isFocused, setIsFocused] = React.useState(false)
return (
<InputGroupContext.Provider value={{ isFocused, setIsFocused, disabled }}>
<View
data-slot="input-group"
className={cn(
"border-input dark:bg-input dark:bg-opacity-30 shadow-xs relative flex w-full min-h-9 flex-wrap items-center rounded-md border outline-none transition-[color,box-shadow]",
isFocused && "ring-2 ring-ring ring-offset-2 ring-offset-background",
className
)}
{...props}
/>
</InputGroupContext.Provider>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1 text-sm font-medium [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3",
"inline-end":
"order-last pr-3",
"block-start":
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3",
"block-end":
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof inputGroupAddonVariants>) {
const { disabled } = React.useContext(InputGroupContext)
return (
<View
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), disabled && "opacity-50", className)}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 [&>svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-2 rounded-md px-2",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0",
"icon-sm": "size-8 p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentPropsWithoutRef<typeof Text>) {
return (
<Text
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
onFocus,
onBlur,
autoFocus,
focus,
...props
}: React.ComponentPropsWithoutRef<typeof Input> & { autoFocus?: boolean }) {
const { setIsFocused } = React.useContext(InputGroupContext)
return (
<View className="flex h-full flex-1 items-center px-2 py-2">
<Input
data-slot="input-group-control"
className={cn(
"flex-1 bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
placeholderClass="text-muted-foreground"
onFocus={(e) => {
setIsFocused(true)
onFocus?.(e)
}}
onBlur={(e) => {
setIsFocused(false)
onBlur?.(e)
}}
focus={autoFocus || focus}
{...props}
/>
</View>
)
}
function InputGroupTextarea({
className,
onFocus,
onBlur,
autoFocus,
focus,
...props
}: React.ComponentPropsWithoutRef<typeof Textarea> & { autoFocus?: boolean }) {
const { setIsFocused } = React.useContext(InputGroupContext)
return (
<View className="flex h-full flex-1 min-w-20 m-2">
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 w-full h-full bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
placeholderClass="text-muted-foreground"
onFocus={(e) => {
setIsFocused(true)
onFocus?.(e)
}}
onBlur={(e) => {
setIsFocused(false)
onBlur?.(e)
}}
focus={autoFocus || focus}
{...props}
/>
</View>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
@@ -0,0 +1,137 @@
import * as React from "react"
import { View, Input, type BaseEventOrig } from "@tarojs/components"
import { type InputProps } from "@tarojs/components/types/Input"
import { Dot } from "lucide-react-taro"
import { cn } from "@/lib/utils"
const InputOTPContext = React.createContext<{
value: string
maxLength: number
isFocused: boolean
} | null>(null)
const InputOTP = React.forwardRef<
any,
{
value?: string
defaultValue?: string
onChange?: (value: string) => void
maxLength: number
containerClassName?: string
className?: string
disabled?: boolean
autoFocus?: boolean
children: React.ReactNode
}
>(({ value: valueProp, defaultValue, onChange, maxLength, containerClassName, className, disabled, autoFocus, children }, ref) => {
const [valueState, setValueState] = React.useState(defaultValue || "")
const [isFocused, setIsFocused] = React.useState(false)
const value = valueProp !== undefined ? valueProp : valueState
const handleChange = (e: BaseEventOrig<InputProps.inputValueEventDetail>) => {
const newValue = e.detail.value
if (newValue.length <= maxLength) {
if (valueProp === undefined) {
setValueState(newValue)
}
onChange?.(newValue)
}
}
return (
<InputOTPContext.Provider value={{ value, maxLength, isFocused }}>
<View
className={cn(
"relative flex items-center gap-2",
disabled && "opacity-50",
containerClassName
)}
>
<Input
className="z-10 taro-otp-hidden-input"
style={{
position: "absolute",
left: 0,
top: 0,
width: "100%",
height: "100%",
opacity: 0,
zIndex: 10,
backgroundColor: "transparent",
borderWidth: 0,
padding: 0,
margin: 0,
outline: "none",
color: "transparent",
caretColor: "transparent",
}}
value={value}
onInput={handleChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
maxlength={maxLength}
type="number"
disabled={disabled}
focus={autoFocus}
ref={ref}
/>
<View className={cn("flex items-center gap-2", className)}>
{children}
</View>
</View>
</InputOTPContext.Provider>
)
})
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { index: number }
>(({ index, className, ...props }, ref) => {
const context = React.useContext(InputOTPContext)
if (!context) return null
const char = context.value[index]
const isActive = context.isFocused && context.value.length === index
const hasFakeCaret = isActive
return (
<View
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
<View>{char}</View>
{hasFakeCaret && (
<View className="pointer-events-none absolute inset-0 flex items-center justify-center">
<View className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</View>
)}
</View>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ ...props }, ref) => (
<View ref={ref} {...props}>
<Dot size={24} color="inherit" />
</View>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
@@ -0,0 +1,56 @@
import * as React from "react"
import { Input as TaroInput, View } from "@tarojs/components"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.ComponentPropsWithoutRef<typeof TaroInput> {
className?: string
type?: React.ComponentProps<typeof TaroInput>['type']
autoFocus?: boolean
}
const Input = React.forwardRef<React.ElementRef<typeof TaroInput>, InputProps>(
({ className, type, autoFocus, focus, onFocus, onBlur, ...props }, ref) => {
const [isFocused, setIsFocused] = React.useState(false)
const disabled = !!(props as any).disabled
React.useEffect(() => {
if (autoFocus || focus) setIsFocused(true)
}, [autoFocus, focus])
return (
<View
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:border-ring focus-within:ring-4 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
isFocused &&
"border-ring ring-4 ring-ring ring-offset-2 ring-offset-background",
className
)}
onTouchStart={() => {
if (disabled) return
setIsFocused(true)
}}
>
<TaroInput
type={type}
className="w-full flex-1 bg-transparent text-sm text-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 selection:bg-selection selection:text-selection-foreground"
placeholderClass="text-muted-foreground"
ref={ref}
focus={autoFocus || focus}
onFocus={(e) => {
setIsFocused(true)
onFocus?.(e)
}}
onBlur={(e) => {
setIsFocused(false)
onBlur?.(e)
}}
{...props}
/>
</View>
)
}
)
Input.displayName = "Input"
export { Input }
@@ -0,0 +1,24 @@
import * as React from "react"
import { Label as TaroLabel } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none"
)
const Label = React.forwardRef<
React.ElementRef<typeof TaroLabel>,
React.ComponentPropsWithoutRef<typeof TaroLabel> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<TaroLabel
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }
@@ -0,0 +1,595 @@
import * as React from "react"
import { View, ScrollView } from "@tarojs/components"
import { Check, ChevronRight } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { isH5 } from "@/lib/platform"
import { computePosition, getRectById } from "@/lib/measure"
import { Portal } from "@/components/ui/portal"
const MenubarContext = React.createContext<{
openMenu?: string
setOpenMenu?: (id: string | undefined) => void
} | null>(null)
const Menubar = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const [openMenu, setOpenMenu] = React.useState<string | undefined>()
return (
<MenubarContext.Provider value={{ openMenu, setOpenMenu }}>
<View
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border border-border bg-background p-1",
className
)}
{...props}
/>
</MenubarContext.Provider>
)
})
Menubar.displayName = "Menubar"
const MenubarMenuContext = React.createContext<{
id: string
open: boolean
onOpenChange: (open: boolean) => void
triggerId: string
} | null>(null)
let menubarMenuIdCounter = 0
const MenubarMenu = ({ children }: { children: React.ReactNode }) => {
const id = React.useMemo(() => `menubar-menu-${menubarMenuIdCounter++}`, [])
const triggerId = React.useMemo(() => `${id}-trigger`, [id])
const context = React.useContext(MenubarContext)
const open = context?.openMenu === id
const onOpenChange = (isOpen: boolean) => {
if (isOpen) {
context?.setOpenMenu?.(id)
} else {
context?.setOpenMenu?.(undefined)
}
}
return (
<MenubarMenuContext.Provider value={{ id, open, onOpenChange, triggerId }}>
{children}
</MenubarMenuContext.Provider>
)
}
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => {
const context = React.useContext(MenubarMenuContext)
return (
<View
ref={ref}
id={context?.triggerId}
data-slot="menubar-trigger"
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-2 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
onClick={() => context?.onOpenChange(!context.open)}
{...props}
/>
)
})
MenubarTrigger.displayName = "MenubarTrigger"
const MenubarPortal = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>
}
const MenubarContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
align?: "start" | "center" | "end"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}
>(({ className, align = "start", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
const context = React.useContext(MenubarMenuContext)
const contentId = React.useRef(`menubar-content-${Math.random().toString(36).slice(2, 10)}`)
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
React.useEffect(() => {
if (!context?.open) {
setPosition(null)
return
}
let cancelled = false
const compute = async () => {
if (!context?.triggerId) return
const [triggerRect, contentRect] = await Promise.all([
getRectById(context.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
setPosition(
computePosition({
triggerRect,
contentRect,
align,
side,
sideOffset,
})
)
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, context?.open, context?.triggerId, side, sideOffset])
React.useEffect(() => {
if (!context?.open) return
if (!isH5() || typeof document === "undefined") return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
context?.onOpenChange(false)
}
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [context?.open])
if (!context?.open) return null
const baseClassName =
"fixed z-50 min-w-32 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
const contentStyle = position
? ({ left: position.left, top: position.top } as React.CSSProperties)
: ({
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
return (
<Portal>
<View
className="fixed inset-0 z-50 bg-transparent"
onClick={() => context.onOpenChange(false)}
/>
<View
ref={ref}
id={contentId.current}
data-slot="menubar-content"
data-state="open"
data-side={side}
className={cn(
baseClassName,
className
)}
style={contentStyle}
onClick={(e) => e.stopPropagation()}
{...props}
>
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
{children}
</ScrollView>
</View>
</Portal>
)
})
MenubarContent.displayName = "MenubarContent"
const MenubarItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
variant?: "default" | "destructive"
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, inset, variant = "default", disabled, closeOnSelect = true, children, onClick, ...props }, ref) => {
const context = React.useContext(MenubarMenuContext)
return (
<View
ref={ref}
data-slot="menubar-item"
data-inset={inset ? "" : undefined}
data-variant={variant}
data-disabled={disabled ? "" : undefined}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive data-[variant=destructive]:focus:bg-opacity-10 data-[variant=destructive]:focus:text-destructive",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
if (disabled) return
onClick?.(e)
if (closeOnSelect) context?.onOpenChange(false)
}}
{...props}
>
{children}
</View>
)
})
MenubarItem.displayName = "MenubarItem"
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
checked?: boolean
inset?: boolean
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
const context = React.useContext(MenubarMenuContext)
return (
<View
ref={ref}
data-slot="menubar-checkbox-item"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
if (disabled) return
onClick?.(e)
if (closeOnSelect) context?.onOpenChange(false)
}}
{...props}
>
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
{checked && <Check size={16} color="inherit" />}
</View>
{children}
</View>
)
})
MenubarCheckboxItem.displayName = "MenubarCheckboxItem"
const MenubarRadioGroupContext = React.createContext<{
value?: string
onValueChange?: (value: string) => void
} | null>(null)
interface MenubarRadioGroupProps extends React.ComponentPropsWithoutRef<typeof View> {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}
const MenubarRadioGroup = React.forwardRef<
React.ElementRef<typeof View>,
MenubarRadioGroupProps
>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (next: string) => {
if (valueProp === undefined) {
setValueState(next)
}
onValueChange?.(next)
}
return (
<MenubarRadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
<View ref={ref} {...props} />
</MenubarRadioGroupContext.Provider>
)
})
MenubarRadioGroup.displayName = "MenubarRadioGroup"
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value: string
checked?: boolean
inset?: boolean
disabled?: boolean
closeOnSelect?: boolean
}
>(({ className, children, value, checked: checkedProp, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
const context = React.useContext(MenubarMenuContext)
const group = React.useContext(MenubarRadioGroupContext)
const checked = checkedProp !== undefined ? checkedProp : group?.value === value
return (
<View
ref={ref}
data-slot="menubar-radio-item"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
if (disabled) return
group?.onValueChange?.(value)
onClick?.(e)
if (closeOnSelect) context?.onOpenChange(false)
}}
{...props}
>
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
{checked && <Check size={16} color="inherit" />}
</View>
{children}
</View>
)
})
MenubarRadioItem.displayName = "MenubarRadioItem"
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<View
ref={ref}
data-slot="menubar-label"
data-inset={inset ? "" : undefined}
className={cn(
"px-2 py-1 text-xs font-medium text-muted-foreground",
inset && "pl-7",
className
)}
{...props}
/>
))
MenubarLabel.displayName = "MenubarLabel"
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
MenubarSeparator.displayName = "MenubarSeparator"
const MenubarShortcut = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => {
return (
<View
data-slot="menubar-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
)
}
MenubarShortcut.displayName = "MenubarShortcut"
const MenubarGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} data-slot="menubar-group" className={className} {...props} />
))
MenubarGroup.displayName = "MenubarGroup"
const MenubarSubContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
triggerId: string
} | null>(null)
interface MenubarSubProps {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const MenubarSub = ({ open: openProp, defaultOpen, onOpenChange, children }: MenubarSubProps) => {
const parent = React.useContext(MenubarMenuContext)
const baseIdRef = React.useRef(`menubar-sub-${Math.random().toString(36).slice(2, 10)}`)
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
React.useEffect(() => {
if (parent?.open === false && open) {
handleOpenChange(false)
}
}, [open, parent?.open])
return (
<MenubarSubContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}>
{children}
</MenubarSubContext.Provider>
)
}
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { inset?: boolean; disabled?: boolean }
>(({ className, inset, disabled, children, onClick, ...props }, ref) => {
const subContext = React.useContext(MenubarSubContext)
return (
<View
{...props}
ref={ref}
id={subContext?.triggerId}
data-slot="menubar-sub-trigger"
data-inset={inset ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-state={subContext?.open ? "open" : "closed"}
className={cn(
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-7",
disabled && "pointer-events-none opacity-50",
className
)}
onClick={(e) => {
e.stopPropagation()
if (disabled) return
subContext?.onOpenChange?.(!subContext.open)
onClick?.(e)
}}
>
{children}
<ChevronRight className="ml-auto opacity-50" size={16} color="inherit" />
</View>
)
})
MenubarSubTrigger.displayName = "MenubarSubTrigger"
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
align?: "start" | "center" | "end"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}
>(({ className, align = "start", side = "right", sideOffset = 4, children, ...props }, ref) => {
const parent = React.useContext(MenubarMenuContext)
const subContext = React.useContext(MenubarSubContext)
const contentId = React.useRef(`menubar-sub-content-${Math.random().toString(36).slice(2, 10)}`)
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
React.useEffect(() => {
if (!parent?.open || !subContext?.open) {
setPosition(null)
return
}
let cancelled = false
const compute = async () => {
if (!subContext?.triggerId) return
const [triggerRect, contentRect] = await Promise.all([
getRectById(subContext.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
setPosition(
computePosition({
triggerRect,
contentRect,
align,
side,
sideOffset,
})
)
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, parent?.open, side, sideOffset, subContext?.open, subContext?.triggerId])
if (!parent?.open || !subContext?.open) return null
const baseClassName =
"fixed z-50 min-w-[96px] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
const contentStyle = position
? ({ left: position.left, top: position.top } as React.CSSProperties)
: ({
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
return (
<Portal>
<View
{...props}
ref={ref}
id={contentId.current}
data-slot="menubar-sub-content"
data-state="open"
data-side={side}
className={cn(baseClassName, className)}
style={contentStyle}
onClick={(e) => e.stopPropagation()}
>
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
{children}
</ScrollView>
</View>
</Portal>
)
})
MenubarSubContent.displayName = "MenubarSubContent"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}
@@ -0,0 +1,266 @@
import * as React from "react"
import { View, ScrollView } from "@tarojs/components"
import { ChevronDown } from "lucide-react-taro"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { isH5 } from "@/lib/platform"
import { computePosition, getRectById, getViewport } from "@/lib/measure"
import { Portal } from "@/components/ui/portal"
const NavigationMenuContext = React.createContext<{
value?: string
onValueChange?: (value: string | undefined) => void
} | null>(null)
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value?: string
onValueChange?: (value: string | undefined) => void
}
>(({ className, children, value: valueProp, onValueChange, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | undefined>()
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (newValue: string | undefined) => {
if (valueProp === undefined) {
setValueState(newValue)
}
onValueChange?.(newValue)
}
return (
<NavigationMenuContext.Provider value={{ value, onValueChange: handleValueChange }}>
<View
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
</View>
</NavigationMenuContext.Provider>
)
})
NavigationMenu.displayName = "NavigationMenu"
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = "NavigationMenuList"
const NavigationMenuItemContext = React.createContext<{ value: string; triggerId: string } | null>(null)
const NavigationMenuItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { value?: string }
>(({ children, value: valueProp, ...props }, ref) => {
const id = React.useId()
const value = valueProp || id
const triggerIdRef = React.useRef(`navigation-menu-trigger-${Math.random().toString(36).slice(2, 10)}`)
return (
<NavigationMenuItemContext.Provider value={{ value, triggerId: triggerIdRef.current }}>
<View ref={ref} {...props}>
{children}
</View>
</NavigationMenuItemContext.Provider>
)
})
NavigationMenuItem.displayName = "NavigationMenuItem"
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:bg-opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, children, ...props }, ref) => {
const context = React.useContext(NavigationMenuContext)
const item = React.useContext(NavigationMenuItemContext)
const isOpen = context?.value === item?.value
return (
<View
ref={ref}
id={item?.triggerId}
data-slot="navigation-menu-trigger"
data-state={isOpen ? "open" : "closed"}
className={cn(navigationMenuTriggerStyle(), "group", className)}
onClick={() => context?.onValueChange?.(isOpen ? undefined : item?.value)}
{...props}
>
{children}{" "}
<ChevronDown
className={cn(
"relative top-[1px] ml-1 h-3 w-3 transition duration-200",
isOpen && "rotate-180"
)}
size={12}
color="inherit"
aria-hidden="true"
/>
</View>
)
})
NavigationMenuTrigger.displayName = "NavigationMenuTrigger"
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
align?: "start" | "center" | "end"
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}
>(({ className, align = "start", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
const context = React.useContext(NavigationMenuContext)
const item = React.useContext(NavigationMenuItemContext)
const isOpen = context?.value === item?.value
const contentId = React.useRef(`navigation-menu-content-${Math.random().toString(36).slice(2, 10)}`)
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
const [contentWidth, setContentWidth] = React.useState<number | null>(null)
React.useEffect(() => {
if (!isOpen) {
setPosition(null)
setContentWidth(null)
return
}
let cancelled = false
const compute = async () => {
const triggerId = item?.triggerId
if (!triggerId) return
const [triggerRect, contentRect] = await Promise.all([
getRectById(triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
setContentWidth(contentRect.width)
setPosition(
computePosition({
triggerRect,
contentRect,
align,
side,
sideOffset,
})
)
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, isOpen, item?.triggerId, side, sideOffset])
React.useEffect(() => {
if (!isOpen) return
if (!isH5() || typeof document === "undefined") return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
context?.onValueChange?.(undefined)
}
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [context, isOpen])
if (!isOpen) return null
const baseClassName =
"fixed z-50 min-w-32 w-max max-w-[95vw] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
const vw = getViewport().width
const isMobileLayout = vw < 640
const shouldFullWidth = isMobileLayout && contentWidth !== null && contentWidth > vw - 16
const contentStyle = position
? (shouldFullWidth
? ({ left: 8, right: 8, top: position.top } as React.CSSProperties)
: ({ left: position.left, top: position.top } as React.CSSProperties))
: ({
left: shouldFullWidth ? 8 : 0,
right: shouldFullWidth ? 8 : undefined,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
return (
<Portal>
<View
className="fixed inset-0 z-50 bg-transparent"
onClick={() => context?.onValueChange?.(undefined)}
/>
<View
ref={ref}
id={contentId.current}
data-slot="navigation-menu-content"
data-state="open"
data-side={side}
className={cn(
baseClassName,
className
)}
style={contentStyle}
onClick={(e) => e.stopPropagation()}
{...props}
>
<ScrollView scrollY className="max-h-[70vh] overflow-x-hidden overflow-y-auto">
{children}
</ScrollView>
</View>
</Portal>
)
})
NavigationMenuContent.displayName = "NavigationMenuContent"
const NavigationMenuLink = ({ children, className, ...props }: any) => (
<View className={cn("block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground", className)} {...props}>
{children}
</View>
)
const NavigationMenuViewport = () => null
const NavigationMenuIndicator = () => null
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
@@ -0,0 +1,118 @@
import * as React from "react"
import { View, Text } from "@tarojs/components"
import { ChevronLeft, ChevronRight, Ellipsis } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { ButtonProps, buttonVariants } from "@/components/ui/button"
const Pagination = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => (
<View
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentPropsWithoutRef<typeof View>
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<View
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-3", className)}
{...props}
>
<ChevronLeft size={16} color="inherit" />
<Text></Text>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-3", className)}
{...props}
>
<Text></Text>
<ChevronRight size={16} color="inherit" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<Ellipsis size={16} color="inherit" />
<View className="sr-only">More pages</View>
</View>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
@@ -0,0 +1,291 @@
import * as React from "react"
import { View } from "@tarojs/components"
import Taro from "@tarojs/taro"
import { cn } from "@/lib/utils"
import { Portal } from "@/components/ui/portal"
import { useKeyboardOffset } from "@/lib/hooks/use-keyboard-offset"
import { isH5 } from "@/lib/platform"
const PopoverContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
triggerId: string
} | null>(null)
interface PopoverProps {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
const Popover = ({ open: openProp, defaultOpen, onOpenChange, children }: PopoverProps) => {
const baseIdRef = React.useRef(
`popover-${Math.random().toString(36).slice(2, 10)}`
)
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
React.useEffect(() => {
if (!isH5()) return
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return
e.stopPropagation()
handleOpenChange(false)
}
window.addEventListener("keydown", onKeyDown, true)
return () => {
window.removeEventListener("keydown", onKeyDown, true)
}
}, [open])
return (
<PopoverContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}>
{children}
</PopoverContext.Provider>
)
}
const PopoverTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
>(({ className, children, asChild, ...props }, ref) => {
const context = React.useContext(PopoverContext)
return (
<View
ref={ref}
id={context?.triggerId}
className={cn("w-fit", className)}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(!context.open)
}}
{...props}
>
{children}
</View>
)
})
PopoverTrigger.displayName = "PopoverTrigger"
interface PopoverContentProps extends React.ComponentPropsWithoutRef<typeof View> {
align?: "center" | "start" | "end"
side?: "top" | "bottom" | "left" | "right"
position?: "top" | "bottom" | "left" | "right"
sideOffset?: number
}
const PopoverContent = React.forwardRef<
React.ElementRef<typeof View>,
PopoverContentProps
>(({ className, align = "center", side, position: positionProp, sideOffset = 4, style, ...props }, ref) => {
const context = React.useContext(PopoverContext)
const offset = useKeyboardOffset()
const resolvedSide = positionProp ?? side ?? "bottom"
const contentId = React.useRef(
`popover-content-${Math.random().toString(36).slice(2, 10)}`
)
const [contentPosition, setContentPosition] = React.useState<
| {
left: number
top: number
}
| null
>(null)
React.useEffect(() => {
if (!context?.open) {
setContentPosition(null)
return
}
let cancelled = false
const getViewport = () => {
if (isH5() && typeof window !== "undefined") {
return { width: window.innerWidth, height: window.innerHeight }
}
try {
const info = Taro.getSystemInfoSync()
return { width: info.windowWidth, height: info.windowHeight }
} catch {
return { width: 375, height: 667 }
}
}
const getRectH5 = (id: string) => {
if (!isH5() || typeof document === "undefined") return null
const el = document.getElementById(id)
if (!el) return null
const r = el.getBoundingClientRect()
return { left: r.left, top: r.top, width: r.width, height: r.height }
}
const getRect = (id: string) => {
const h5Rect = getRectH5(id)
if (h5Rect) return Promise.resolve(h5Rect)
return new Promise<any>((resolve) => {
const query = Taro.createSelectorQuery()
query
.select(`#${id}`)
.boundingClientRect((res) => {
const rect = Array.isArray(res) ? res[0] : res
resolve(rect || null)
})
.exec()
})
}
const compute = async () => {
const [triggerRect, contentRect] = await Promise.all([
getRect(context.triggerId),
getRect(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
const viewport = getViewport()
const vw = viewport.width
const vh = Math.max(0, viewport.height - (isH5() ? 0 : offset || 0))
const margin = 8
const crossStart =
resolvedSide === "left" || resolvedSide === "right" ? triggerRect.top : triggerRect.left
const crossSize =
resolvedSide === "left" || resolvedSide === "right" ? triggerRect.height : triggerRect.width
const contentCrossSize =
resolvedSide === "left" || resolvedSide === "right" ? contentRect.height : contentRect.width
const cross = (() => {
if (align === "start") return crossStart
if (align === "end") return crossStart + crossSize - contentCrossSize
return crossStart + crossSize / 2 - contentCrossSize / 2
})()
let left = 0
let top = 0
if (resolvedSide === "bottom" || resolvedSide === "top") {
left = cross
top =
resolvedSide === "bottom"
? triggerRect.top + triggerRect.height + sideOffset
: triggerRect.top - contentRect.height - sideOffset
} else {
top = cross
left =
resolvedSide === "right"
? triggerRect.left + triggerRect.width + sideOffset
: triggerRect.left - contentRect.width - sideOffset
}
left = Math.min(Math.max(left, margin), vw - contentRect.width - margin)
top = Math.min(Math.max(top, margin), vh - contentRect.height - margin)
setContentPosition({ left, top })
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [align, context?.open, context?.triggerId, offset, resolvedSide, sideOffset])
if (!context?.open) return null
const baseClassName =
"fixed z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
const contentStyle = contentPosition
? ({
...(style as object),
left: contentPosition.left,
top: contentPosition.top,
} as React.CSSProperties)
: ({
...(style as object),
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
return (
<Portal>
<View
className="fixed inset-0 z-50 bg-transparent"
onClick={() => context.onOpenChange?.(false)}
/>
<View
ref={ref}
className={cn(
baseClassName,
className
)}
id={contentId.current}
style={contentStyle}
{...props}
/>
</Portal>
)
})
PopoverContent.displayName = "PopoverContent"
const PopoverHeader = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} className={cn("grid gap-1.5", className)} {...props} />
))
PopoverHeader.displayName = "PopoverHeader"
const PopoverTitle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} className={cn("font-medium leading-none", className)} {...props} />
))
PopoverTitle.displayName = "PopoverTitle"
const PopoverDescription = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
PopoverDescription.displayName = "PopoverDescription"
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}
@@ -0,0 +1,19 @@
import * as React from "react"
import * as TaroComponents from "@tarojs/components"
import { createPortal } from "react-dom"
import { isH5 } from "@/lib/platform"
const RootPortal = (TaroComponents as any).RootPortal
const Portal = ({ children }: { children: React.ReactNode }) => {
if (isH5()) {
if (typeof document === "undefined") return <>{children}</>
return createPortal(children, document.body)
}
if (!RootPortal) {
return <>{children}</>
}
return <RootPortal>{children}</RootPortal>
}
export { Portal }
@@ -0,0 +1,28 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value?: number | null
}
>(({ className, value, ...props }, ref) => (
<View
ref={ref}
className={cn(
"relative h-1 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<View
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</View>
))
Progress.displayName = "Progress"
export { Progress }
@@ -0,0 +1,64 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
const RadioGroupContext = React.createContext<{
value?: string
onValueChange?: (value: string) => void
} | null>(null)
const RadioGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value?: string
onValueChange?: (value: string) => void
defaultValue?: string
}
>(({ className, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (newValue: string) => {
if (valueProp === undefined) {
setValueState(newValue)
}
onValueChange?.(newValue)
}
return (
<RadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
<View
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
</RadioGroupContext.Provider>
)
})
RadioGroup.displayName = "RadioGroup"
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value: string
}
>(({ className, value, ...props }, ref) => {
const context = React.useContext(RadioGroupContext)
const checked = context?.value === value
return (
<View
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
checked && "border-10",
className
)}
onClick={() => context?.onValueChange?.(value)}
{...props}
/>
)
})
RadioGroupItem.displayName = "RadioGroupItem"
export { RadioGroup, RadioGroupItem }
@@ -0,0 +1,346 @@
import * as React from "react"
import { View } from "@tarojs/components"
import Taro from "@tarojs/taro"
import { GripVertical } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { isH5 } from "@/lib/platform"
type Direction = "horizontal" | "vertical"
function getPoint(e: any) {
const touch = e?.touches?.[0] || e?.changedTouches?.[0]
return touch || e
}
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
const ResizablePanelGroup = ({
className,
children,
direction = "horizontal",
...props
}: React.ComponentPropsWithoutRef<typeof View> & {
direction?: Direction
}) => {
const idRef = React.useRef(`resizable-${Math.random().toString(36).slice(2, 11)}`)
const groupRef = React.useRef<any>(null)
const sizesRef = React.useRef<number[] | null>(null)
const [sizes, setSizes] = React.useState<number[] | null>(null)
const rectSizeRef = React.useRef<{ width: number; height: number } | null>(null)
const dragRef = React.useRef<{
axis: "x" | "y"
input: "mouse" | "touch"
leftIndex: number
startPos: number
startSizes: number[]
containerSize: number
} | null>(null)
React.useEffect(() => {
sizesRef.current = sizes
}, [sizes])
const isPanelElement = React.useCallback((child: any) => {
return child?.type?.displayName === "ResizablePanel"
}, [])
const isHandleElement = React.useCallback((child: any) => {
return child?.type?.displayName === "ResizableHandle"
}, [])
const panels = React.useMemo(() => {
const out: React.ReactElement[] = []
React.Children.forEach(children as any, (child) => {
if (!React.isValidElement(child)) return
if (isPanelElement(child)) out.push(child as any)
})
return out
}, [children, isPanelElement])
const measure = React.useCallback(() => {
const el = groupRef.current
if (isH5() && typeof el?.getBoundingClientRect === "function") {
const rect = el.getBoundingClientRect()
const width = rect?.width
const height = rect?.height
if (typeof width === "number" && typeof height === "number") {
rectSizeRef.current = { width, height }
return Promise.resolve(rectSizeRef.current)
}
}
return new Promise<{ width: number; height: number } | null>((resolve) => {
const query = Taro.createSelectorQuery()
query
.select(`#${idRef.current}`)
.boundingClientRect((res) => {
const r = Array.isArray(res) ? res[0] : res
const width = r?.width
const height = r?.height
if (typeof width === "number" && typeof height === "number") {
rectSizeRef.current = { width, height }
resolve(rectSizeRef.current)
return
}
resolve(null)
})
.exec()
})
}, [])
React.useEffect(() => {
const defaults = panels.map((p) => {
const v = (p.props as any)?.defaultSize
return typeof v === "number" && Number.isFinite(v) ? v : null
})
const hasAnyDefault = defaults.some((v) => v != null)
const next =
hasAnyDefault
? defaults.map((v) => (v == null ? 0 : v))
: panels.map(() => 100 / Math.max(1, panels.length))
const total = next.reduce((a, b) => a + b, 0) || 1
const normalized = next.map((v) => (v / total) * 100)
setSizes((prev) => {
if (prev && prev.length === normalized.length) return prev
return normalized
})
}, [panels])
React.useEffect(() => {
void measure()
if (!isH5() || typeof window === "undefined") return
const onResize = () => void measure()
window.addEventListener("resize", onResize)
return () => window.removeEventListener("resize", onResize)
}, [measure])
const applyMove = React.useCallback(
(e: any) => {
const drag = dragRef.current
const currentSizes = sizesRef.current
if (!drag || !currentSizes) return
const p = getPoint(e)
const pos = drag.axis === "x" ? p?.pageX : p?.pageY
if (typeof pos !== "number") return
if (!drag.containerSize) return
const deltaPercent = ((pos - drag.startPos) / drag.containerSize) * 100
const left = drag.leftIndex
const right = left + 1
const total = drag.startSizes[left] + drag.startSizes[right]
const min = 10
const nextLeft = clamp(drag.startSizes[left] + deltaPercent, min, total - min)
const next = drag.startSizes.slice()
next[left] = nextLeft
next[right] = total - nextLeft
setSizes(next)
},
[]
)
const endDrag = React.useCallback(() => {
dragRef.current = null
if (!isH5() || typeof document === "undefined") return
document.removeEventListener("mousemove", onDocumentMouseMove as any)
document.removeEventListener("mouseup", onDocumentMouseUp as any)
document.removeEventListener("touchmove", onDocumentTouchMove as any, { passive: false } as any)
document.removeEventListener("touchend", onDocumentTouchEnd as any)
document.removeEventListener("touchcancel", onDocumentTouchEnd as any)
}, [])
const onDocumentMouseMove = React.useCallback((e: any) => applyMove(e), [applyMove])
const onDocumentMouseUp = React.useCallback(() => endDrag(), [endDrag])
const onDocumentTouchMove = React.useCallback(
(e: any) => {
const drag = dragRef.current
if (!drag || drag.input !== "touch") return
e?.preventDefault?.()
applyMove(e)
},
[applyMove]
)
const onDocumentTouchEnd = React.useCallback(() => endDrag(), [endDrag])
React.useEffect(() => {
return () => endDrag()
}, [endDrag])
const startDrag = React.useCallback(
async (leftIndex: number, e: any) => {
const currentSizes = sizesRef.current
if (!currentSizes) return
if (leftIndex < 0 || leftIndex >= currentSizes.length - 1) return
const axis = direction === "horizontal" ? "x" : "y"
const p = getPoint(e)
const pos = axis === "x" ? p?.pageX : p?.pageY
if (typeof pos !== "number") return
const input: "mouse" | "touch" = (e as any)?.touches?.length ? "touch" : "mouse"
const rect = rectSizeRef.current || (await measure())
const containerSize = axis === "x" ? rect?.width : rect?.height
if (!containerSize) return
dragRef.current = {
axis,
input,
leftIndex,
startPos: pos,
startSizes: currentSizes.slice(),
containerSize,
}
e?.preventDefault?.()
if (isH5() && typeof document !== "undefined") {
if (input === "mouse") {
document.addEventListener("mousemove", onDocumentMouseMove as any)
document.addEventListener("mouseup", onDocumentMouseUp as any)
} else {
document.addEventListener("touchmove", onDocumentTouchMove as any, { passive: false } as any)
document.addEventListener("touchend", onDocumentTouchEnd as any)
document.addEventListener("touchcancel", onDocumentTouchEnd as any)
}
}
},
[
direction,
measure,
onDocumentMouseMove,
onDocumentMouseUp,
onDocumentTouchEnd,
onDocumentTouchMove,
]
)
const onGroupTouchMove = React.useCallback(
(e: any) => {
const drag = dragRef.current
if (!drag || drag.input !== "touch") return
e?.preventDefault?.()
applyMove(e)
},
[applyMove]
)
const onGroupTouchEnd = React.useCallback(() => endDrag(), [endDrag])
let panelIndex = 0
let handleIndex = 0
return (
<View
id={idRef.current}
ref={groupRef}
className={cn(
"flex h-full w-full items-stretch overflow-hidden",
direction === "vertical" ? "flex-col" : "flex-row",
className
)}
{...props}
onTouchMove={onGroupTouchMove}
onTouchEnd={onGroupTouchEnd}
onTouchCancel={onGroupTouchEnd as any}
>
{React.Children.map(children as any, (child) => {
if (!React.isValidElement(child)) return child
if (isPanelElement(child)) {
const size = sizes?.[panelIndex]
const cloned = React.cloneElement(child as any, {
__size: typeof size === "number" ? size : undefined,
__direction: direction,
})
panelIndex += 1
return cloned
}
if (isHandleElement(child)) {
const leftIndex = panelIndex - 1
const cursorClass =
direction === "horizontal" ? "cursor-col-resize" : "cursor-row-resize"
const cloned = React.cloneElement(child as any, {
__direction: direction,
className: cn(cursorClass, (child.props as any)?.className),
onTouchStart: (e: any) => void startDrag(leftIndex, e),
// @ts-ignore
onMouseDown: (e: any) => void startDrag(leftIndex, e),
"data-handle-index": handleIndex,
})
handleIndex += 1
return cloned
}
return child
})}
</View>
)
}
const ResizablePanel = ({
className,
children,
defaultSize,
__size,
__direction,
...props
}: React.ComponentPropsWithoutRef<typeof View> & {
defaultSize?: number
__size?: number
__direction?: Direction
}) => (
<View
{...props}
className={cn("flex min-h-0 min-w-0 flex-col overflow-hidden", className)}
style={{
flexBasis: 0,
flexGrow: __size ?? defaultSize ?? 1,
flexShrink: 1,
minWidth: 0,
minHeight: 0,
...(props as any)?.style,
}}
>
{children}
</View>
)
const ResizableHandle = ({
withHandle,
className,
__direction,
...props
}: React.ComponentPropsWithoutRef<typeof View> & {
withHandle?: boolean
__direction?: Direction
}) => (
<View
{...props}
className={cn(
"relative flex shrink-0 items-center justify-center bg-transparent",
__direction === "vertical" ? "h-3 self-stretch" : "w-3 self-stretch",
className
)}
>
<View
className={cn(
"absolute bg-border",
__direction === "vertical"
? "inset-x-0 top-1/2 -translate-y-1/2"
: "inset-y-0 left-1/2 -translate-x-1/2"
)}
style={__direction === "vertical" ? { height: "1PX" } : { width: "1PX" }}
/>
{withHandle && (
<View className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-3 w-3" size={12} color="inherit" />
</View>
)}
</View>
)
;(ResizablePanel as any).displayName = "ResizablePanel"
;(ResizableHandle as any).displayName = "ResizableHandle"
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -0,0 +1,34 @@
import * as React from "react"
import { ScrollView } from "@tarojs/components"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollView>,
React.ComponentPropsWithoutRef<typeof ScrollView> & {
orientation?: "vertical" | "horizontal" | "both"
}
>(({ className, children, orientation = "vertical", ...props }, ref) => {
const scrollX = orientation === "horizontal" || orientation === "both"
const scrollY = orientation === "vertical" || orientation === "both"
return (
<ScrollView
ref={ref}
className={cn("relative", className)}
scrollY={scrollY}
scrollX={scrollX}
style={{
overflowX: scrollX ? 'auto' : 'hidden',
overflowY: scrollY ? 'auto' : 'hidden',
}}
{...props}
>
{children}
</ScrollView>
)
})
ScrollArea.displayName = "ScrollArea"
const ScrollBar = () => null // Taro ScrollView handles scrollbars natively
export { ScrollArea, ScrollBar }
@@ -0,0 +1,438 @@
import * as React from "react"
import { View, ScrollView } from "@tarojs/components"
import { Check, ChevronDown, ChevronUp } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { getRectById, getViewport } from "@/lib/measure"
import { Portal } from "@/components/ui/portal"
type SelectContextValue = {
value?: string
onValueChange?: (value: string) => void
open?: boolean
onOpenChange?: (open: boolean) => void
triggerId: string
selectedLabel?: string
setSelectedLabel?: (label?: string) => void
}
const SelectContext = React.createContext<SelectContextValue | null>(null)
interface SelectProps {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
children?: React.ReactNode
}
const Select: React.FC<SelectProps> = ({
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen,
onOpenChange,
children,
}) => {
const baseIdRef = React.useRef(
`select-${Math.random().toString(36).slice(2, 10)}`
)
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const [valueState, setValueState] = React.useState(defaultValue || "")
const value = valueProp !== undefined ? valueProp : valueState
const [selectedLabel, setSelectedLabel] = React.useState<string | undefined>(
undefined
)
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
const handleValueChange = (newValue: string) => {
if (valueProp === undefined) {
setValueState(newValue)
}
onValueChange?.(newValue)
handleOpenChange(false)
}
return (
<SelectContext.Provider
value={{
value,
onValueChange: handleValueChange,
open,
onOpenChange: handleOpenChange,
triggerId: baseIdRef.current,
selectedLabel,
setSelectedLabel,
}}
>
{children}
</SelectContext.Provider>
)
}
const SelectGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} className={cn("scroll-my-1 p-1", className)} {...props} />
))
SelectGroup.displayName = "SelectGroup"
const SelectValue = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { placeholder?: string }
>(({ className, placeholder, children, ...props }, ref) => {
const context = React.useContext(SelectContext)
const hasValue = !!context?.value
const displayValue = children
? children
: context?.selectedLabel || context?.value || placeholder
return (
<View
ref={ref}
className={cn(
"flex flex-1 text-left",
!hasValue && !children && "text-muted-foreground",
className
)}
{...props}
>
{displayValue}
</View>
)
})
SelectValue.displayName = "SelectValue"
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
size?: "sm" | "default"
disabled?: boolean
}
>(({ className, size = "default", disabled, children, onClick, ...props }, ref) => {
const context = React.useContext(SelectContext)
return (
<View
ref={ref}
{...props}
id={context?.triggerId}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-lg border border-input bg-transparent pr-2 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus:border-ring focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
size === "default" && "h-8 py-2",
size === "sm" && "h-7 py-1 rounded-[10px]",
context?.open &&
"border-ring ring-2 ring-ring ring-offset-2 ring-offset-background",
className
)}
hoverClass={
disabled
? undefined
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
}
onClick={(e) => {
if (disabled) return
onClick?.(e)
e.stopPropagation()
context?.onOpenChange?.(!context.open)
}}
>
{children}
<ChevronDown className="text-muted-foreground" size={16} color="inherit" />
</View>
)
})
SelectTrigger.displayName = "SelectTrigger"
const SelectScrollUpButton = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => (
<View className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronUp size={16} color="inherit" />
</View>
)
SelectScrollUpButton.displayName = "SelectScrollUpButton"
const SelectScrollDownButton = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => (
<View className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronDown size={16} color="inherit" />
</View>
)
SelectScrollDownButton.displayName = "SelectScrollDownButton"
const SelectContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> &
Pick<
{
align?: "start" | "center" | "end"
alignOffset?: number
side?: "top" | "bottom" | "left" | "right"
sideOffset?: number
alignItemWithTrigger?: boolean
},
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>
>(
(
{
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
onClick,
...props
},
ref
) => {
const context = React.useContext(SelectContext)
const contentId = React.useRef(
`select-content-${Math.random().toString(36).slice(2, 10)}`
)
const [position, setPosition] = React.useState<{
left: number
top: number
} | null>(null)
const [anchorWidth, setAnchorWidth] = React.useState<number | null>(null)
React.useEffect(() => {
if (!context?.open) {
setPosition(null)
setAnchorWidth(null)
return
}
let cancelled = false
const compute = async () => {
if (!context?.triggerId) return
const [triggerRect, contentRect] = await Promise.all([
getRectById(context.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
const { width: vw, height: vh } = getViewport()
const margin = 8
const contentMainSize =
alignItemWithTrigger && (side === "bottom" || side === "top")
? triggerRect.width
: side === "left" || side === "right"
? contentRect.height
: contentRect.width
const crossStart =
side === "left" || side === "right" ? triggerRect.top : triggerRect.left
const crossSize =
side === "left" || side === "right" ? triggerRect.height : triggerRect.width
const contentCrossSize = (() => {
if (side === "left" || side === "right") return contentRect.height
return alignItemWithTrigger ? triggerRect.width : contentRect.width
})()
const cross = (() => {
if (align === "start") return crossStart
if (align === "end") return crossStart + crossSize - contentCrossSize
return crossStart + crossSize / 2 - contentCrossSize / 2
})()
let left = 0
let top = 0
if (side === "bottom" || side === "top") {
left = cross + alignOffset
top =
side === "bottom"
? triggerRect.top + triggerRect.height + sideOffset
: triggerRect.top - contentRect.height - sideOffset
} else {
top = cross + alignOffset
left =
side === "right"
? triggerRect.left + triggerRect.width + sideOffset
: triggerRect.left - contentRect.width - sideOffset
}
const clampWidth =
side === "bottom" || side === "top" ? contentMainSize : contentRect.width
const clampHeight =
side === "left" || side === "right" ? contentMainSize : contentRect.height
left = Math.min(Math.max(left, margin), vw - clampWidth - margin)
top = Math.min(Math.max(top, margin), vh - clampHeight - margin)
setAnchorWidth(triggerRect.width)
setPosition({ left, top })
}
const raf = (() => {
if (typeof requestAnimationFrame !== "undefined") {
return requestAnimationFrame(() => compute())
}
return setTimeout(() => compute(), 0) as unknown as number
})()
return () => {
cancelled = true
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(raf)
} else {
clearTimeout(raf)
}
}
}, [
align,
alignOffset,
alignItemWithTrigger,
context?.open,
context?.triggerId,
side,
sideOffset,
])
if (!context?.open) return null
const contentStyle: React.CSSProperties = position
? {
left: position.left,
top: position.top,
width: alignItemWithTrigger && anchorWidth ? anchorWidth : undefined,
}
: {
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
}
return (
<Portal>
<View
className="fixed inset-0 z-50 bg-transparent"
onClick={() => context.onOpenChange?.(false)}
/>
<View
ref={ref}
id={contentId.current}
className={cn(
"fixed z-50 min-w-36 overflow-x-hidden overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
className
)}
style={contentStyle}
onClick={(e) => {
onClick?.(e)
e.stopPropagation()
}}
{...props}
>
<SelectScrollUpButton className="hidden" />
<ScrollView scrollY className="max-h-[50vh]">
{children}
</ScrollView>
<SelectScrollDownButton className="hidden" />
</View>
</Portal>
)
})
SelectContent.displayName = "SelectContent"
const SelectLabel = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("px-2 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
))
SelectLabel.displayName = "SelectLabel"
const SelectItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { value: string; disabled?: boolean }
>(({ className, children, value, disabled, onClick, ...props }, ref) => {
const context = React.useContext(SelectContext)
const isSelected = context?.value === value
const labelText = React.useMemo(() => {
if (typeof children === "string") return children
if (Array.isArray(children) && children.every((c) => typeof c === "string")) {
return children.join("")
}
return undefined
}, [children])
React.useEffect(() => {
if (isSelected && labelText) {
context?.setSelectedLabel?.(labelText)
}
}, [context, isSelected, labelText])
return (
<View
ref={ref}
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-2 text-sm outline-none select-none transition-colors focus:bg-accent focus:text-accent-foreground",
disabled && "opacity-50 pointer-events-none",
className
)}
onClick={(e) => {
onClick?.(e)
if (disabled) return
e.stopPropagation()
context?.setSelectedLabel?.(labelText)
context?.onValueChange?.(value)
}}
{...props}
>
<View className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</View>
{isSelected ? (
<View className="pointer-events-none absolute right-2 flex h-4 w-4 items-center justify-center">
<Check size={16} color="inherit" />
</View>
) : null}
</View>
)
})
SelectItem.displayName = "SelectItem"
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
SelectSeparator.displayName = "SelectSeparator"
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
@@ -0,0 +1,30 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
orientation?: "horizontal" | "vertical"
decorative?: boolean
}
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<View
ref={ref}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = "Separator"
export { Separator }
@@ -0,0 +1,262 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { Portal } from "@/components/ui/portal"
const SheetContext = React.createContext<{
open?: boolean
onOpenChange?: (open: boolean) => void
} | null>(null)
const usePresence = (open: boolean | undefined, durationMs: number) => {
const [present, setPresent] = React.useState(!!open)
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
React.useEffect(() => {
if (open) {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = null
setPresent(true)
return
}
timeoutRef.current = setTimeout(() => setPresent(false), durationMs)
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}, [open, durationMs])
return present
}
interface SheetProps {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
modal?: boolean
}
const Sheet = ({ children, open: openProp, defaultOpen, onOpenChange }: SheetProps) => {
const [openState, setOpenState] = React.useState(defaultOpen || false)
const open = openProp !== undefined ? openProp : openState
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
return (
<SheetContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
{children}
</SheetContext.Provider>
)
}
const SheetTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
>(({ className, children, asChild, ...props }, ref) => {
const context = React.useContext(SheetContext)
return (
<View
ref={ref}
className={cn("w-fit", className)}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(true)
}}
{...props}
>
{children}
</View>
)
})
SheetTrigger.displayName = "SheetTrigger"
const SheetClose = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
>(({ className, children, asChild, ...props }, ref) => {
const context = React.useContext(SheetContext)
return (
<View
ref={ref}
className={className}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(false)
}}
{...props}
>
{children}
</View>
)
})
SheetClose.displayName = "SheetClose"
const SheetPortal = ({ children }: { children: React.ReactNode }) => {
const context = React.useContext(SheetContext)
const present = usePresence(context?.open, 300)
if (!present) return null
return <Portal>{children}</Portal>
}
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, onClick, ...props }, ref) => {
const context = React.useContext(SheetContext)
const state = context?.open ? "open" : "closed"
return (
<View
data-state={state}
className={cn(
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
onClick={(e) => {
e.stopPropagation()
onClick?.(e)
context?.onOpenChange?.(false)
}}
/>
)
})
SheetOverlay.displayName = "SheetOverlay"
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof View>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof View>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => {
const context = React.useContext(SheetContext)
const state = context?.open ? "open" : "closed"
return (
<SheetPortal>
<View
className="fixed inset-0 z-50"
onClick={() => context?.onOpenChange?.(false)}
>
<SheetOverlay />
<View
ref={ref}
className={cn(sheetVariants({ side }), "sheet-content", className)}
data-state={state}
data-side={side}
onClick={(e) => e.stopPropagation()}
{...props}
>
{children}
<View
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
data-state={state}
onClick={(e) => {
e.stopPropagation()
context?.onOpenChange?.(false)
}}
>
<X size={16} color="inherit" />
<View className="sr-only">Close</View>
</View>
</View>
</View>
</SheetPortal>
)
})
SheetContent.displayName = "SheetContent"
const SheetHeader = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) => (
<View
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = "SheetTitle"
const SheetDescription = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = "SheetDescription"
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,17 @@
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.ComponentPropsWithoutRef<typeof View>) {
return (
<View
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,203 @@
import * as React from "react"
import { View } from "@tarojs/components"
import Taro from "@tarojs/taro"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof View>,
Omit<React.ComponentPropsWithoutRef<typeof View>, "value" | "onChange"> & {
value?: number[]
defaultValue?: number[]
min?: number
max?: number
step?: number
onValueChange?: (value: number[]) => void
disabled?: boolean
orientation?: "horizontal" | "vertical"
trackClassName?: string
rangeClassName?: string
thumbClassName?: string
}
>(({ className, trackClassName, rangeClassName, thumbClassName, value: valueProp, defaultValue, min = 0, max = 100, step = 1, onValueChange, disabled, orientation = "horizontal", ...props }, ref) => {
const [localValue, setLocalValue] = React.useState<number[]>(
valueProp || defaultValue || [min]
)
const [isDragging, setIsDragging] = React.useState(false)
const [rect, setRect] = React.useState<{ left: number; top: number; width: number; height: number } | null>(null)
const rectRef = React.useRef<{ left: number; top: number; width: number; height: number } | null>(null)
const idRef = React.useRef(`slider-${Math.random().toString(36).substr(2, 9)}`)
const value = valueProp !== undefined ? valueProp : localValue
const currentValue = value[0] ?? min
React.useEffect(() => {
rectRef.current = rect
}, [rect])
React.useEffect(() => {
// Delay measurement to ensure the component is mounted and layout is ready
const timer = setTimeout(() => {
const query = Taro.createSelectorQuery()
query
.select(`#${idRef.current}`)
.boundingClientRect((res) => {
const measuredRect = Array.isArray(res) ? res[0] : res
if (measuredRect) {
setRect({ left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height })
rectRef.current = { left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height }
}
})
.exec()
}, 100)
return () => clearTimeout(timer)
}, [])
const updateValue = (pageX: number, pageY: number, passedRect?: { left: number; top: number; width: number; height: number } | null) => {
const currentRect = passedRect || rectRef.current
if (!currentRect || disabled) return
let percentage = 0
if (orientation === "horizontal") {
const { left, width } = currentRect
percentage = Math.min(Math.max((pageX - left) / width, 0), 1)
} else {
const { top, height } = currentRect
percentage = Math.min(Math.max(1 - (pageY - top) / height, 0), 1)
}
const rawValue = min + percentage * (max - min)
const steppedValue = Math.round((rawValue - min) / step) * step + min
const newValue = Math.min(Math.max(steppedValue, min), max)
if (newValue !== currentValue) {
const nextValue = [newValue]
if (valueProp === undefined) {
setLocalValue(nextValue)
}
onValueChange?.(nextValue)
}
}
const handleTouchStart = (e: any) => {
if (disabled) return
setIsDragging(true)
// Try to update rect on touch start in case of layout changes
const query = Taro.createSelectorQuery()
query
.select(`#${idRef.current}`)
.boundingClientRect((res) => {
const measuredRect = Array.isArray(res) ? res[0] : res
if (measuredRect) {
setRect({ left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height })
rectRef.current = { left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height }
// If we have a touch event, update value immediately after getting fresh rect
const touch = e.touches[0] || e.changedTouches[0]
if (touch) {
updateValue(touch.pageX, touch.pageY, rectRef.current)
}
}
})
.exec()
}
const handleMouseDown = (e: React.MouseEvent) => {
if (disabled) return
setIsDragging(true)
const query = Taro.createSelectorQuery()
query
.select(`#${idRef.current}`)
.boundingClientRect((res) => {
const measuredRect = Array.isArray(res) ? res[0] : res
if (measuredRect) {
setRect({ left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height })
rectRef.current = { left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height }
updateValue(e.pageX, e.pageY, rectRef.current)
}
})
.exec()
const onMouseMove = (moveEvent: MouseEvent) => {
updateValue(moveEvent.pageX, moveEvent.pageY)
}
const onMouseUp = (upEvent: MouseEvent) => {
setIsDragging(false)
updateValue(upEvent.pageX, upEvent.pageY)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
if (typeof document !== 'undefined') {
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
}
const handleTouchMove = (e: any) => {
if (disabled) return
const touch = e.touches[0] || e.changedTouches[0]
if (touch) {
updateValue(touch.pageX, touch.pageY)
}
}
const handleTouchEnd = (e: any) => {
if (disabled) return
setIsDragging(false)
const touch = e.touches[0] || e.changedTouches[0]
if (touch) {
updateValue(touch.pageX, touch.pageY)
}
}
const percentage = ((currentValue - min) / (max - min)) * 100
return (
<View
ref={ref}
id={idRef.current}
className={cn(
"relative flex touch-none select-none items-center",
orientation === "horizontal" ? "w-full py-4" : "h-full flex-col px-4",
className
)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
// @ts-ignore
onMouseDown={handleMouseDown}
{...props}
>
<View
className={cn(
"relative grow overflow-hidden rounded-full bg-secondary",
orientation === "horizontal" ? "h-1 w-full" : "w-1 h-full",
trackClassName
)}
>
<View
className={cn("absolute bg-primary", orientation === "horizontal" ? "h-full" : "w-full bottom-0", rangeClassName)}
style={orientation === "horizontal" ? { width: `${percentage}%` } : { height: `${percentage}%` }}
/>
</View>
<View
className={cn(
"absolute block h-3 w-3 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors disabled:pointer-events-none disabled:opacity-50",
isDragging && "ring-4 ring-primary ring-opacity-30",
disabled && "opacity-50",
thumbClassName
)}
style={
orientation === "horizontal"
? { left: `${percentage}%`, transform: 'translateX(-50%)' }
: { bottom: `${percentage}%`, transform: 'translateY(50%)' }
}
/>
</View>
)
})
Slider.displayName = "Slider"
export { Slider }
@@ -0,0 +1 @@
export * from "./toast"
@@ -0,0 +1,55 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
checked?: boolean
defaultChecked?: boolean
onCheckedChange?: (checked: boolean) => void
disabled?: boolean
}
>(({ className, checked, defaultChecked, onCheckedChange, disabled, ...props }, ref) => {
const [localChecked, setLocalChecked] = React.useState(defaultChecked || false)
const isControlled = checked !== undefined
const currentChecked = isControlled ? checked : localChecked
return (
<View
className={cn(
"inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background [-webkit-tap-highlight-color:transparent]",
disabled && "cursor-not-allowed opacity-50",
currentChecked ? "bg-primary" : "bg-input",
className
)}
data-state={currentChecked ? "checked" : "unchecked"}
hoverClass={
disabled
? undefined
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
}
{...props}
ref={ref}
onClick={(e) => {
if (disabled) return
e.stopPropagation()
const newChecked = !currentChecked
if (!isControlled) {
setLocalChecked(newChecked)
}
onCheckedChange?.(newChecked)
}}
>
<View
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
currentChecked ? "translate-x-5" : "translate-x-0"
)}
/>
</View>
)
})
Switch.displayName = "Switch"
export { Switch }
@@ -0,0 +1,142 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View className="relative w-full overflow-auto">
<View
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</View>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View ref={ref} className={cn("[&>view]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("[&>view:last-child]:border-b-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"border-t bg-muted bg-opacity-50 font-medium [&>view:last-child]:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted hover:bg-opacity-50 data-[state=selected]:bg-muted flex flex-row",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground flex flex-1 basis-0 min-w-0 items-center justify-start",
typeof className === "string" && /(^|\s)text-right(\s|$)/.test(className)
? "justify-end"
: null,
typeof className === "string" && /(^|\s)w-/.test(className)
? "flex-none basis-auto"
: null,
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & { colSpan?: number }
>(({ className, colSpan, style, ...props }, ref) => (
<View
ref={ref}
style={
colSpan != null &&
typeof style === "object" &&
style != null &&
!Array.isArray(style) &&
!(typeof className === "string" && /(^|\s)w-/.test(className))
? { ...(style as React.CSSProperties), flexGrow: colSpan }
: style
}
className={cn(
"p-4 align-middle flex flex-1 basis-0 min-w-0 items-center justify-start",
typeof className === "string" && /(^|\s)text-right(\s|$)/.test(className)
? "justify-end"
: null,
typeof className === "string" && /(^|\s)w-/.test(className)
? "flex-none basis-auto"
: null,
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,114 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
const TabsContext = React.createContext<{
value?: string
onValueChange?: (value: string) => void
} | null>(null)
const Tabs = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}
>(({ className, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (newValue: string) => {
if (valueProp === undefined) {
setValueState(newValue)
}
onValueChange?.(newValue)
}
return (
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
<View
ref={ref}
className={cn(className)}
{...props}
/>
</TabsContext.Provider>
)
})
Tabs.displayName = "Tabs"
const TabsList = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
>(({ className, ...props }, ref) => (
<View
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = "TabsList"
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value: string
disabled?: boolean
}
>(({ className, value, onClick, disabled, ...props }, ref) => {
const context = React.useContext(TabsContext)
const isActive = context?.value === value
const handleClick = (e: any) => {
if (disabled) return
context?.onValueChange?.(value)
onClick?.(e)
}
return (
<View
ref={ref}
onClick={handleClick}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
isActive && "bg-background text-foreground shadow-sm",
disabled && "opacity-50 pointer-events-none",
className
)}
hoverClass={
disabled
? undefined
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
}
{...props}
/>
)
})
TabsTrigger.displayName = "TabsTrigger"
const TabsContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
value: string
}
>(({ className, value, ...props }, ref) => {
const context = React.useContext(TabsContext)
if (context?.value !== value) return null
return (
<View
ref={ref}
className={cn(
"mt-2 ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
className
)}
{...props}
/>
)
})
TabsContent.displayName = "TabsContent"
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,54 @@
import * as React from "react"
import { Textarea as TaroTextarea, View } from "@tarojs/components"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.ComponentPropsWithoutRef<typeof TaroTextarea> {
className?: string
autoFocus?: boolean
}
const Textarea = React.forwardRef<
React.ElementRef<typeof TaroTextarea>,
TextareaProps
>(({ className, autoFocus, focus, onFocus, onBlur, ...props }, ref) => {
const [isFocused, setIsFocused] = React.useState(false)
const disabled = !!(props as any).disabled
React.useEffect(() => {
if (autoFocus || focus) setIsFocused(true)
}, [autoFocus, focus])
return (
<View
className={cn(
"flex h-20 w-full rounded-md border border-input bg-background px-3 py-2 ring-offset-background focus-within:border-ring focus-within:ring-4 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
isFocused && "border-ring ring-4 ring-ring ring-offset-2 ring-offset-background",
className
)}
onTouchStart={() => {
if (disabled) return
setIsFocused(true)
}}
>
<TaroTextarea
className="flex-1 w-full h-full bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm selection:bg-selection selection:text-selection-foreground"
placeholderClass="text-muted-foreground"
ref={ref}
focus={autoFocus || focus}
onFocus={(e) => {
setIsFocused(true)
onFocus?.(e)
}}
onBlur={(e) => {
setIsFocused(false)
onBlur?.(e)
}}
{...props}
/>
</View>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
@@ -0,0 +1,517 @@
import * as React from "react"
import { Text, View } from "@tarojs/components"
import Taro from "@tarojs/taro"
import {
Check,
Info,
Loader,
TriangleAlert,
X,
CircleAlert
} from "lucide-react-taro"
import { cn } from "@/lib/utils"
import { Portal } from "@/components/ui/portal"
export type ToastPosition =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right"
| "top-center"
| "bottom-center"
export type ToastType = "success" | "info" | "warning" | "error" | "loading" | "default"
export interface ToastAction {
label: string
onClick: () => void
}
export interface ToastData {
id?: string | number
title?: React.ReactNode
description?: React.ReactNode
type?: ToastType
duration?: number
position?: ToastPosition
invert?: boolean
dismissible?: boolean
descriptionClassName?: string
action?: ToastAction
cancel?: ToastAction
onDismiss?: (toast: Toast) => void
onAutoClose?: (toast: Toast) => void
richColors?: boolean
closeButton?: boolean
style?: React.CSSProperties
className?: string
jsx?: React.ReactNode | ((id: string | number) => React.ReactNode)
}
export interface Toast extends ToastData {
id: string | number
}
const listeners: Array<(toasts: Toast[]) => void> = []
let toasts: Toast[] = []
const notify = () => {
listeners.forEach((l) => l([...toasts]))
}
const createToast = (title: React.ReactNode, data: ToastData = {}) => {
const id = data.id || Date.now().toString() + Math.random().toString(36).substring(2, 9)
const existingToast = toasts.find((t) => t.id === id)
if (existingToast) {
toasts = toasts.map((t) =>
t.id === id ? { ...t, ...data, title: title || t.title } : t
)
} else {
const newToast: Toast = {
...data,
id,
title,
type: data.type || "default",
dismissible: data.dismissible ?? true,
}
toasts = [...toasts, newToast]
}
notify()
return id
}
const dismiss = (id?: string | number) => {
if (!id) {
toasts = []
} else {
toasts = toasts.filter((t) => t.id !== id)
}
notify()
}
type ToastFunction = (title: string | React.ReactNode, data?: ToastData) => string | number
const toastFn: ToastFunction = (title, data) => createToast(title, data)
const toast = Object.assign(toastFn, {
success: (title: string | React.ReactNode, data?: ToastData) =>
createToast(title, { ...data, type: "success" }),
error: (title: string | React.ReactNode, data?: ToastData) =>
createToast(title, { ...data, type: "error" }),
warning: (title: string | React.ReactNode, data?: ToastData) =>
createToast(title, { ...data, type: "warning" }),
info: (title: string | React.ReactNode, data?: ToastData) =>
createToast(title, { ...data, type: "info" }),
loading: (title: string | React.ReactNode, data?: ToastData) =>
createToast(title, { ...data, type: "loading" }),
message: (title: string | React.ReactNode, data?: ToastData) =>
createToast(title, { ...data, type: "default" }),
custom: (jsx: (id: string | number) => React.ReactNode, data?: ToastData) => {
const id = data?.id || Date.now().toString()
return createToast(null, { ...data, id, jsx })
},
dismiss,
promise: <T,>(
promise: Promise<T> | (() => Promise<T>),
data: {
loading?: string | React.ReactNode
success?: string | React.ReactNode | ((data: T) => React.ReactNode)
error?: string | React.ReactNode | ((error: unknown) => React.ReactNode)
finally?: () => void
} & ToastData
) => {
const id = toast.loading(data.loading, { ...data })
const p = typeof promise === "function" ? promise() : promise
p.then((res) => {
const successMessage = typeof data.success === "function" ? data.success(res) : data.success
toast.success(successMessage, { id, ...data })
})
.catch((err) => {
const errorMessage = typeof data.error === "function" ? data.error(err) : data.error
toast.error(errorMessage, { id, ...data })
})
.finally(() => {
data.finally?.()
})
return id
}
})
interface ToasterProps {
position?: ToastPosition
richColors?: boolean
expand?: boolean
closeButton?: boolean
offset?: number
dir?: "rtl" | "ltr" | "auto"
visibleToasts?: number
duration?: number
gap?: number
theme?: "light" | "dark" | "system"
className?: string
style?: React.CSSProperties
toastOptions?: Omit<ToastData, "id">
}
const Toaster = ({
position = "bottom-right",
richColors = false,
expand = false,
closeButton = false,
visibleToasts = 3,
duration = 4000,
gap = 14,
className,
style,
toastOptions
}: ToasterProps) => {
const [activeToasts, setActiveToasts] = React.useState<Toast[]>([])
const [closingIds, setClosingIds] = React.useState<Set<string | number>>(() => new Set())
const [frontHeight, setFrontHeight] = React.useState<number | null>(null)
const frontIdRef = React.useRef(`toaster-front-${Math.random().toString(36).slice(2, 9)}`)
React.useEffect(() => {
listeners.push(setActiveToasts)
return () => {
const index = listeners.indexOf(setActiveToasts)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [])
const getPositionStyle = (pos: ToastPosition) => {
switch (pos) {
case "top-left": return "top-0 left-0 right-0 justify-start"
case "top-right": return "top-0 left-0 right-0 justify-end"
case "bottom-left": return "bottom-0 left-0 right-0 justify-start"
case "bottom-right": return "bottom-0 left-0 right-0 justify-end"
case "top-center": return "top-0 left-0 right-0 justify-center"
case "bottom-center": return "bottom-0 left-0 right-0 justify-center"
default: return "bottom-0 left-0 right-0 justify-end"
}
}
const isTop = position.includes("top")
React.useEffect(() => {
if (expand) return
if (activeToasts.length <= visibleToasts) return
const overflow = activeToasts.slice(0, activeToasts.length - visibleToasts)
const nextToClose = overflow.find((t) => !closingIds.has(t.id))
if (!nextToClose) return
setClosingIds((prev) => {
if (prev.has(nextToClose.id)) return prev
const next = new Set(prev)
next.add(nextToClose.id)
return next
})
}, [activeToasts, closingIds, expand, visibleToasts])
React.useEffect(() => {
setClosingIds((prev) => {
if (prev.size === 0) return prev
const activeIds = new Set(activeToasts.map((t) => t.id))
const next = new Set<string | number>()
prev.forEach((id) => {
if (activeIds.has(id)) next.add(id)
})
return next.size === prev.size ? prev : next
})
}, [activeToasts])
const toastsToRender = React.useMemo(() => {
if (expand) return activeToasts
const keep = activeToasts.slice(-visibleToasts)
const overflowCount = Math.max(0, activeToasts.length - visibleToasts)
const overflowIds = new Set(activeToasts.slice(0, overflowCount).map((t) => t.id))
const closing = activeToasts.filter((t) => overflowIds.has(t.id) && closingIds.has(t.id))
return [...closing, ...keep]
}, [activeToasts, closingIds, expand, visibleToasts])
const listStyle = expand ? ({ gap } as React.CSSProperties) : undefined
React.useEffect(() => {
if (toastsToRender.length === 0) return
const timer = setTimeout(() => {
const query = Taro.createSelectorQuery()
query
.select(`#${frontIdRef.current}`)
.boundingClientRect((res) => {
const rect = Array.isArray(res) ? res[0] : res
if (rect?.height) setFrontHeight(rect.height)
})
.exec()
}, 50)
return () => clearTimeout(timer)
}, [toastsToRender.length])
return (
<Portal>
<View
className={cn(
"toaster fixed z-[2147483647] flex w-full pointer-events-none p-4",
getPositionStyle(position),
className
)}
data-position={position}
style={style}
>
<View
className={cn(
"toaster-list relative w-full flex",
isTop ? "flex-col-reverse" : "flex-col"
)}
style={listStyle}
>
{toastsToRender.map((t, index) => {
const isFront = index === toastsToRender.length - 1
const stackIndex = toastsToRender.length - 1 - index
return (
<ToastItem
key={t.id}
elementId={isFront ? frontIdRef.current : undefined}
item={t}
isExpanded={expand}
isFront={isFront}
stackIndex={stackIndex}
isTop={isTop}
gap={gap}
forceClose={!expand && closingIds.has(t.id)}
frontHeight={frontHeight}
duration={t.duration || duration}
richColors={t.richColors ?? richColors}
closeButton={t.closeButton ?? closeButton}
toastOptions={toastOptions}
/>
)
})}
</View>
</View>
</Portal>
)
}
const ToastItem = ({
elementId,
item,
isExpanded,
isFront,
stackIndex,
isTop,
gap,
forceClose,
frontHeight,
duration,
richColors,
closeButton,
toastOptions
}: {
elementId?: string
item: Toast
isExpanded: boolean
isFront: boolean
stackIndex: number
isTop: boolean
gap: number
forceClose: boolean
frontHeight: number | null
duration: number
richColors: boolean
closeButton: boolean
toastOptions?: Omit<ToastData, "id">
}) => {
const [isVisible, setIsVisible] = React.useState(false)
const [isRemoved, setIsRemoved] = React.useState(false)
const timeOutRef = React.useRef<NodeJS.Timeout>()
React.useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 16)
if (item.duration === Infinity) return
const d = item.duration || duration
timeOutRef.current = setTimeout(() => {
handleDismiss()
}, d)
return () => {
clearTimeout(timer)
clearTimeout(timeOutRef.current)
}
}, [item.duration, duration])
const handleDismiss = () => {
setIsVisible(false)
setTimeout(() => {
setIsRemoved(true)
item.onDismiss?.(item)
dismiss(item.id)
}, 400)
}
React.useEffect(() => {
if (!forceClose) return
const t = setTimeout(() => handleDismiss(), 16)
return () => clearTimeout(t)
}, [forceClose])
const TypeIcon = {
success: Check,
error: CircleAlert,
warning: TriangleAlert,
info: Info,
loading: Loader,
default: null
}[item.type || "default"]
const palette = React.useMemo(() => {
if (!richColors) return null
const type = item.type || "default"
if (type === "success") {
return { backgroundColor: "hsl(143, 85%, 96%)", borderColor: "hsl(145, 92%, 87%)", color: "hsl(140, 100%, 27%)" }
}
if (type === "info") {
return { backgroundColor: "hsl(208, 100%, 97%)", borderColor: "hsl(221, 91%, 93%)", color: "hsl(210, 92%, 45%)" }
}
if (type === "warning") {
return { backgroundColor: "hsl(49, 100%, 97%)", borderColor: "hsl(49, 91%, 84%)", color: "hsl(31, 92%, 45%)" }
}
if (type === "error") {
return { backgroundColor: "hsl(359, 100%, 97%)", borderColor: "hsl(359, 100%, 94%)", color: "hsl(360, 100%, 45%)" }
}
return null
}, [item.type, richColors])
const iconColor = palette?.color ?? "inherit"
const isCollapsedStack = !isExpanded && !isFront
const lift = isTop ? 1 : -1
const enterOffset = (frontHeight ?? 80) * 1.5
const stackTranslate = lift * gap * stackIndex
const stackScale = 1 - stackIndex * 0.05
const transform = isCollapsedStack
? `translateY(${stackTranslate}px) scale(${stackScale})`
: isVisible
? "translateY(0px)"
: `translateY(${(-lift * enterOffset).toFixed(0)}px)`
const motionStyle: React.CSSProperties = {
transform,
opacity: isVisible ? 1 : 0,
transition: "transform 400ms ease, opacity 400ms ease, height 400ms ease, box-shadow 200ms ease",
transformOrigin: isCollapsedStack ? (isTop ? "bottom" : "top") : "center"
}
if (isRemoved) return null
const isCustom = typeof item.jsx !== "undefined"
const baseClasses = cn(
"toast pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg",
!palette && "bg-background border-border text-foreground",
item.className,
toastOptions?.className
)
const finalStyle: React.CSSProperties = {
...(palette || {}),
...item.style,
...motionStyle,
position: isCollapsedStack ? "absolute" : "relative",
...(isCollapsedStack ? (isTop ? { bottom: 0 } : { top: 0 }) : (isTop ? { top: 0 } : { bottom: 0 })),
left: 0,
right: 0,
zIndex: isFront ? 50 : 40 - stackIndex,
pointerEvents: isCollapsedStack ? "none" : "auto",
...(isCollapsedStack && frontHeight ? { height: frontHeight } : {})
}
if (isCustom) {
const content = typeof item.jsx === "function" ? item.jsx(item.id) : item.jsx
return (
<View
className={cn(
"pointer-events-auto w-full"
)}
id={elementId}
style={finalStyle}
>
<View style={{ opacity: isCollapsedStack ? 0 : 1, transition: "opacity 400ms ease" }}>
{content}
</View>
</View>
)
}
return (
<View className={baseClasses} style={finalStyle} id={elementId}>
<View className="flex gap-3 items-center flex-1" style={{ opacity: isCollapsedStack ? 0 : 1, transition: "opacity 400ms ease" }}>
{TypeIcon && (
<TypeIcon className={cn("shrink-0", item.type === "loading" && "animate-spin")} color={iconColor} size={20} />
)}
<View className="flex flex-col gap-1 flex-1">
{item.title && <Text className="text-sm font-semibold leading-none" style={palette ? { color: palette.color } : undefined}>{item.title}</Text>}
{item.description && (
<Text className={cn("text-xs opacity-90 leading-normal", item.descriptionClassName)} style={palette ? { color: palette.color } : undefined}>
{item.description}
</Text>
)}
</View>
</View>
{(item.action || item.cancel) && (
<View className="flex flex-nowrap items-center gap-2 shrink-0" style={{ opacity: isCollapsedStack ? 0 : 1, transition: "opacity 400ms ease" }}>
{item.cancel && (
<View
className="text-xs font-medium opacity-70 active:opacity-100 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
item.cancel?.onClick()
handleDismiss()
}}
>
{item.cancel.label}
</View>
)}
{item.action && (
<View
className="text-xs font-medium active:opacity-80 px-3 py-2 rounded-md bg-primary text-primary-foreground shadow hover:bg-primary hover:bg-opacity-90 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
item.action?.onClick()
handleDismiss()
}}
>
{item.action.label}
</View>
)}
</View>
)}
{closeButton && (
<View
className="absolute right-2 top-2 rounded-md p-1 opacity-50 transition-opacity hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-2"
style={{ opacity: isCollapsedStack ? 0 : undefined, transition: "opacity 400ms ease" }}
onClick={(e) => {
e.stopPropagation()
handleDismiss()
}}
>
<X color={iconColor} size={16} />
</View>
)}
</View>
)
}
export { Toaster, toast }
@@ -0,0 +1,120 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
value?: string | string[]
onValueChange?: (value: string) => void
type: "single" | "multiple"
}
>({
size: "default",
variant: "default",
type: "single"
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> &
VariantProps<typeof toggleVariants> & {
type?: "single" | "multiple"
value?: string | string[]
defaultValue?: string | string[]
onValueChange?: (value: string | string[]) => void
}
>(({ className, variant, size, children, type = "single", value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
const [valueState, setValueState] = React.useState<string | string[]>(
defaultValue || (type === "multiple" ? [] : "")
)
const value = valueProp !== undefined ? valueProp : valueState
const handleValueChange = (itemValue: string) => {
let newValue: string | string[]
if (type === "multiple") {
const current = Array.isArray(value) ? value : []
if (current.includes(itemValue)) {
newValue = current.filter(v => v !== itemValue)
} else {
newValue = [...current, itemValue]
}
} else {
// In Radix ToggleGroup "single", clicking active item deselects it?
// Actually yes, unless `rovingFocus` logic etc.
// But usually it behaves like radio if required=true.
// Radix primitive has `rovingFocus` and `loop`.
// We'll implement simple toggle logic.
if (value === itemValue) {
newValue = "" // Deselect
} else {
newValue = itemValue
}
}
if (valueProp === undefined) {
setValueState(newValue)
}
onValueChange?.(newValue)
}
return (
<View
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, value, onValueChange: handleValueChange, type }}>
{children}
</ToggleGroupContext.Provider>
</View>
)
})
ToggleGroup.displayName = "ToggleGroup"
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> &
VariantProps<typeof toggleVariants> & {
value: string
disabled?: boolean
}
>(({ className, children, variant, size, value, disabled, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
const checked = context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(value)
: context.value === value
return (
<View
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
checked && "bg-accent text-accent-foreground",
disabled && "opacity-50 pointer-events-none"
)}
data-state={checked ? "on" : "off"}
data-disabled={disabled ? "" : undefined}
onClick={(e) => {
if (disabled) return
context.onValueChange?.(value)
props.onClick?.(e)
}}
{...props}
>
{children}
</View>
)
})
ToggleGroupItem.displayName = "ToggleGroupItem"
export { ToggleGroup, ToggleGroupItem }
@@ -0,0 +1,77 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3 min-w-10",
sm: "h-9 px-3 min-w-9",
lg: "h-11 px-5 min-w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> &
VariantProps<typeof toggleVariants> & {
pressed?: boolean
onPressedChange?: (pressed: boolean) => void
defaultPressed?: boolean
disabled?: boolean
}
>(({ className, variant, size, pressed: pressedProp, defaultPressed, onPressedChange, disabled, ...props }, ref) => {
const [pressedState, setPressedState] = React.useState(defaultPressed || false)
const pressed = pressedProp !== undefined ? pressedProp : pressedState
const handleClick = (e) => {
if (disabled) return
const newPressed = !pressed
if (pressedProp === undefined) {
setPressedState(newPressed)
}
onPressedChange?.(newPressed)
props.onClick?.(e)
}
const tabIndex = (props as { tabIndex?: number }).tabIndex ?? (disabled ? -1 : 0)
return (
<View
ref={ref}
className={cn(
toggleVariants({ variant, size, className }),
pressed && "bg-accent text-accent-foreground",
disabled && "opacity-50 pointer-events-none"
)}
data-state={pressed ? "on" : "off"}
data-disabled={disabled ? "" : undefined}
{...({ tabIndex } as { tabIndex?: number })}
hoverClass={
disabled
? undefined
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
}
onClick={handleClick}
{...props}
/>
)
})
Toggle.displayName = "Toggle"
export { Toggle, toggleVariants }
@@ -0,0 +1,455 @@
import * as React from "react"
import { View } from "@tarojs/components"
import { cn } from "@/lib/utils"
import { isH5 } from "@/lib/platform"
import { getRectById, getViewport } from "@/lib/measure"
import { Portal } from "@/components/ui/portal"
type TooltipProviderProps = {
children: React.ReactNode
}
type TooltipProps = {
children: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
type TooltipSide = "top" | "bottom" | "left" | "right"
type TooltipAlign = "start" | "center" | "end"
const TooltipContext = React.createContext<{
open: boolean
onOpenChange: (open: boolean) => void
triggerId: string
setHoverPart?: (part: "trigger" | "content", hovering: boolean) => void
} | null>(null)
const TooltipProvider = ({ children }: TooltipProviderProps) => <>{children}</>
const Tooltip = ({
children,
open: openProp,
defaultOpen = false,
onOpenChange,
}: TooltipProps) => {
const baseIdRef = React.useRef(
`tooltip-${Math.random().toString(36).slice(2, 10)}`
)
const [openState, setOpenState] = React.useState(defaultOpen)
const open = openProp !== undefined ? openProp : openState
const hoverRef = React.useRef({ trigger: false, content: false })
const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const handleOpenChange = (newOpen: boolean) => {
if (openProp === undefined) {
setOpenState(newOpen)
}
onOpenChange?.(newOpen)
}
const setHoverPart = React.useCallback(
(part: "trigger" | "content", hovering: boolean) => {
if (!isH5()) return
hoverRef.current[part] = hovering
if (hovering) {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
handleOpenChange(true)
return
}
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
closeTimerRef.current = setTimeout(() => {
if (!hoverRef.current.trigger && !hoverRef.current.content) {
handleOpenChange(false)
}
}, 80)
},
[handleOpenChange]
)
React.useEffect(() => {
return () => {
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
}
}, [])
return (
<TooltipContext.Provider
value={{
open,
onOpenChange: handleOpenChange,
triggerId: baseIdRef.current,
setHoverPart,
}}
>
{children}
</TooltipContext.Provider>
)
}
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
onMouseEnter?: (e: React.MouseEvent) => void
onMouseLeave?: (e: React.MouseEvent) => void
}
>(
(
{ className, children, onClick, onMouseEnter, onMouseLeave, ...props },
ref
) => {
const context = React.useContext(TooltipContext)
return (
<View
ref={ref}
id={context?.triggerId}
className={cn("inline-flex w-fit justify-self-start", className)}
onClick={(e) => {
onClick?.(e)
e.stopPropagation()
context?.onOpenChange(!context.open)
}}
{...(isH5()
? ({
onMouseEnter: (e: React.MouseEvent) => {
onMouseEnter?.(e)
context?.setHoverPart?.("trigger", true)
},
onMouseLeave: (e: React.MouseEvent) => {
onMouseLeave?.(e)
context?.setHoverPart?.("trigger", false)
},
} as React.ComponentPropsWithoutRef<typeof View>)
: {})}
{...props}
>
{children}
</View>
)
}
)
TooltipTrigger.displayName = "TooltipTrigger"
const TooltipContent = React.forwardRef<
React.ElementRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View> & {
align?: TooltipAlign
side?: TooltipSide
sideOffset?: number
avoidCollisions?: boolean
collisionPadding?: number
showArrow?: boolean
arrowSize?: number
onMouseEnter?: (e: React.MouseEvent) => void
onMouseLeave?: (e: React.MouseEvent) => void
}
>(
(
{
children,
className,
align = "center",
side = "top",
sideOffset = 4,
avoidCollisions = true,
collisionPadding = 8,
showArrow = true,
arrowSize = 8,
onMouseEnter,
onMouseLeave,
...props
},
ref
) => {
const context = React.useContext(TooltipContext)
const contentId = React.useRef(
`tooltip-content-${Math.random().toString(36).slice(2, 10)}`
)
const [layout, setLayout] = React.useState<
| {
left: number
top: number
side: TooltipSide
arrowLeft?: number
arrowTop?: number
}
| null
>(null)
React.useEffect(() => {
if (!context?.open) {
setLayout(null)
return
}
let cancelled = false
let rafId: number | null = null
const compute = async () => {
const [triggerRect, contentRect] = await Promise.all([
getRectById(context.triggerId),
getRectById(contentId.current),
])
if (cancelled) return
if (!triggerRect?.width || !contentRect?.width) return
const vw = getViewport().width
const vh = getViewport().height
const padding = Math.max(0, collisionPadding)
const computeForSide = (s: TooltipSide) => {
const isLR = s === "left" || s === "right"
const crossStart = isLR ? triggerRect.top : triggerRect.left
const crossSize = isLR ? triggerRect.height : triggerRect.width
const contentCrossSize = isLR ? contentRect.height : contentRect.width
const mainOffset = sideOffset + (showArrow ? arrowSize / 2 : 0)
const cross = (() => {
if (align === "start") return crossStart
if (align === "end") return crossStart + crossSize - contentCrossSize
return crossStart + crossSize / 2 - contentCrossSize / 2
})()
if (s === "bottom" || s === "top") {
const left = cross
const top =
s === "bottom"
? triggerRect.top + triggerRect.height + mainOffset
: triggerRect.top - contentRect.height - mainOffset
return { left, top }
}
const top = cross
const left =
s === "right"
? triggerRect.left + triggerRect.width + mainOffset
: triggerRect.left - contentRect.width - mainOffset
return { left, top }
}
const oppositeSide = (s: TooltipSide): TooltipSide => {
if (s === "top") return "bottom"
if (s === "bottom") return "top"
if (s === "left") return "right"
return "left"
}
const wouldOverflowMainAxis = (s: TooltipSide, left: number, top: number) => {
if (s === "top") return top < padding
if (s === "bottom") return top + contentRect.height > vh - padding
if (s === "left") return left < padding
return left + contentRect.width > vw - padding
}
let resolvedSide: TooltipSide = side
let { left, top } = computeForSide(resolvedSide)
if (avoidCollisions && wouldOverflowMainAxis(resolvedSide, left, top)) {
const flipped = oppositeSide(resolvedSide)
const flippedPos = computeForSide(flipped)
if (!wouldOverflowMainAxis(flipped, flippedPos.left, flippedPos.top)) {
resolvedSide = flipped
left = flippedPos.left
top = flippedPos.top
}
}
const maxLeft = Math.max(padding, vw - contentRect.width - padding)
const maxTop = Math.max(padding, vh - contentRect.height - padding)
left = Math.min(Math.max(left, padding), maxLeft)
top = Math.min(Math.max(top, padding), maxTop)
const triggerCenterX = triggerRect.left + triggerRect.width / 2
const triggerCenterY = triggerRect.top + triggerRect.height / 2
const arrowGap = 6
if (resolvedSide === "top" || resolvedSide === "bottom") {
const rawArrowLeft = triggerCenterX - left - arrowSize / 2
const minArrowLeft = arrowGap
const maxArrowLeft = contentRect.width - arrowSize - arrowGap
const arrowLeft = Math.min(Math.max(rawArrowLeft, minArrowLeft), maxArrowLeft)
setLayout({ left, top, side: resolvedSide, arrowLeft })
return
}
const rawArrowTop = triggerCenterY - top - arrowSize / 2
const minArrowTop = arrowGap
const maxArrowTop = contentRect.height - arrowSize - arrowGap
const arrowTop = Math.min(Math.max(rawArrowTop, minArrowTop), maxArrowTop)
setLayout({ left, top, side: resolvedSide, arrowTop })
}
const schedule = () => {
if (rafId != null) {
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(rafId)
} else {
clearTimeout(rafId)
}
rafId = null
}
if (typeof requestAnimationFrame !== "undefined") {
rafId = requestAnimationFrame(() => compute())
} else {
rafId = setTimeout(() => compute(), 0) as unknown as number
}
}
schedule()
const onWindowChange = () => schedule()
if (isH5() && typeof window !== "undefined") {
window.addEventListener("resize", onWindowChange)
window.addEventListener("scroll", onWindowChange, true)
}
return () => {
cancelled = true
if (rafId != null) {
if (typeof cancelAnimationFrame !== "undefined") {
cancelAnimationFrame(rafId)
} else {
clearTimeout(rafId)
}
}
if (isH5() && typeof window !== "undefined") {
window.removeEventListener("resize", onWindowChange)
window.removeEventListener("scroll", onWindowChange, true)
}
}
}, [
align,
avoidCollisions,
collisionPadding,
context?.open,
context?.triggerId,
side,
sideOffset,
arrowSize,
])
if (!context?.open) return null
const baseClassName =
"fixed z-50 overflow-visible rounded-md bg-black px-3 py-2 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
const px = (n: number) => `${n}px`
const contentStyle = layout
? (isH5()
? ({ left: px(layout.left), top: px(layout.top) } as React.CSSProperties)
: ({ left: layout.left, top: layout.top } as React.CSSProperties))
: ({
left: 0,
top: 0,
opacity: 0,
pointerEvents: "none",
} as React.CSSProperties)
const arrow =
showArrow && layout ? (
<View
className="absolute rotate-45 bg-black"
style={
layout.side === "top"
? (isH5()
? ({
width: px(arrowSize),
height: px(arrowSize),
bottom: px(-arrowSize / 2),
left: px(layout.arrowLeft ?? 0),
} as React.CSSProperties)
: ({
width: arrowSize,
height: arrowSize,
bottom: -arrowSize / 2,
left: layout.arrowLeft ?? 0,
} as React.CSSProperties))
: layout.side === "bottom"
? (isH5()
? ({
width: px(arrowSize),
height: px(arrowSize),
top: px(-arrowSize / 2),
left: px(layout.arrowLeft ?? 0),
} as React.CSSProperties)
: ({
width: arrowSize,
height: arrowSize,
top: -arrowSize / 2,
left: layout.arrowLeft ?? 0,
} as React.CSSProperties))
: layout.side === "left"
? (isH5()
? ({
width: px(arrowSize),
height: px(arrowSize),
right: px(-arrowSize / 2),
top: px(layout.arrowTop ?? 0),
} as React.CSSProperties)
: ({
width: arrowSize,
height: arrowSize,
right: -arrowSize / 2,
top: layout.arrowTop ?? 0,
} as React.CSSProperties))
: (isH5()
? ({
width: px(arrowSize),
height: px(arrowSize),
left: px(-arrowSize / 2),
top: px(layout.arrowTop ?? 0),
} as React.CSSProperties)
: ({
width: arrowSize,
height: arrowSize,
left: -arrowSize / 2,
top: layout.arrowTop ?? 0,
} as React.CSSProperties))
}
/>
) : null
const overlay = !isH5() ? (
<View
className="fixed inset-0 z-50 bg-transparent"
onClick={() => context.onOpenChange(false)}
/>
) : null
return (
<Portal>
{overlay}
<View
ref={ref}
id={contentId.current}
className={cn(baseClassName, className)}
style={contentStyle}
{...(isH5()
? ({
onMouseEnter: (e: React.MouseEvent) => {
onMouseEnter?.(e)
context?.setHoverPart?.("content", true)
},
onMouseLeave: (e: React.MouseEvent) => {
onMouseLeave?.(e)
context?.setHoverPart?.("content", false)
},
} as React.ComponentPropsWithoutRef<typeof View>)
: {})}
{...props}
>
{arrow}
{children}
</View>
</Portal>
)
})
TooltipContent.displayName = "TooltipContent"
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no,address=no">
<meta name="apple-mobile-web-app-status-bar-style" content="white">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>coze-mini-program</title>
<script><%= htmlWebpackPlugin.options.script %></script>
</head>
<body>
<div id="app">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="lab(2.86037 0.455312 0.568903)"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="
position: fixed;
inset: 0;
margin: auto;
width: 24px;
height: 24px;
animation: __app-loading-spin 1s linear infinite;
">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<style>
@keyframes __app-loading-spin {
to {
transform: rotate(360deg);
}
}
</style>
</div>
</body>
</html>
@@ -0,0 +1,37 @@
import * as React from 'react'
import Taro from '@tarojs/taro'
// Global state to track keyboard height across the app
let globalKeyboardHeight = 0
const listeners = new Set<(height: number) => void>()
const isNotWeb = Taro.getEnv() !== Taro.ENV_TYPE.WEB
if (isNotWeb && typeof Taro.onKeyboardHeightChange === 'function') {
Taro.onKeyboardHeightChange(res => {
globalKeyboardHeight = res.height
listeners.forEach(listener => listener(globalKeyboardHeight))
})
}
export function useKeyboardOffset() {
const [offset, setOffset] = React.useState(globalKeyboardHeight)
React.useEffect(() => {
if (!isNotWeb) return
const handler = (height: number) => {
setOffset(height)
}
listeners.add(handler)
// Update immediately with current global value in case it changed
setOffset(globalKeyboardHeight)
return () => {
listeners.delete(handler)
}
}, [])
return offset
}
+122
View File
@@ -0,0 +1,122 @@
import Taro from '@tarojs/taro'
import { canUseDOM, isH5 } from './platform'
export type Rect = { left: number; top: number; width: number; height: number }
const toNumber = (v: unknown) => {
if (typeof v === 'number') return v
if (typeof v === 'string') {
const n = parseFloat(v)
return Number.isFinite(n) ? n : 0
}
return 0
}
const normalizeRect = (r: Record<string, unknown> | DOMRect | null | undefined): Rect | null => {
if (!r) return null
if ('left' in r && 'top' in r && 'width' in r && 'height' in r) {
return {
left: toNumber(r.left),
top: toNumber(r.top),
width: toNumber(r.width),
height: toNumber(r.height),
}
}
return null
}
export const getViewport = () => {
if (isH5() && canUseDOM()) {
return { width: window.innerWidth, height: window.innerHeight }
}
try {
const info = Taro.getSystemInfoSync()
return {
width: toNumber(info.windowWidth),
height: toNumber(info.windowHeight),
}
} catch {
return { width: 375, height: 667 }
}
}
const getRectH5 = (id: string): Rect | null => {
if (!isH5() || !canUseDOM()) return null
const el = document.getElementById(id)
if (!el) return null
const r = el.getBoundingClientRect()
return normalizeRect(r)
}
export const getRectById = (id: string): Promise<Rect | null> => {
const h5Rect = getRectH5(id)
if (h5Rect) return Promise.resolve(h5Rect)
return new Promise(resolve => {
const query = Taro.createSelectorQuery()
query
.select(`#${id}`)
.boundingClientRect(res => {
const rect = Array.isArray(res) ? res[0] : res
if (rect && 'left' in rect) {
resolve(normalizeRect(rect))
} else {
resolve(null)
}
})
.exec()
})
}
export const computePosition = (params: {
triggerRect: Rect
contentRect: Rect
align: 'start' | 'center' | 'end'
side: 'top' | 'bottom' | 'left' | 'right'
sideOffset: number
}) => {
const { triggerRect, contentRect, align, side, sideOffset } = params
const { width: vw, height: vh } = getViewport()
const margin = 8
const crossStart =
side === 'left' || side === 'right' ? triggerRect.top : triggerRect.left
const crossSize =
side === 'left' || side === 'right'
? triggerRect.height
: triggerRect.width
const contentCrossSize =
side === 'left' || side === 'right'
? contentRect.height
: contentRect.width
const cross = (() => {
if (align === 'start') return crossStart
if (align === 'end') return crossStart + crossSize - contentCrossSize
return crossStart + crossSize / 2 - contentCrossSize / 2
})()
let left = 0
let top = 0
if (side === 'bottom' || side === 'top') {
left = cross
top =
side === 'bottom'
? triggerRect.top + triggerRect.height + sideOffset
: triggerRect.top - contentRect.height - sideOffset
} else {
top = cross
left =
side === 'right'
? triggerRect.left + triggerRect.width + sideOffset
: triggerRect.left - contentRect.width - sideOffset
}
const maxLeft = Math.max(margin, vw - contentRect.width - margin)
const maxTop = Math.max(margin, vh - contentRect.height - margin)
left = Math.min(Math.max(left, margin), maxLeft)
top = Math.min(Math.max(top, margin), maxTop)
return { left, top }
}
@@ -0,0 +1,12 @@
import Taro from "@tarojs/taro"
export const canUseDOM = () => typeof window !== "undefined" && typeof document !== "undefined"
export const isH5 = () => {
try {
return Taro.getEnv() === Taro.ENV_TYPE.WEB
} catch {
return canUseDOM()
}
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+39
View File
@@ -0,0 +1,39 @@
import Taro from '@tarojs/taro'
/**
* 网络请求模块
* 封装 Taro.request、Taro.uploadFile、Taro.downloadFile,自动添加项目域名前缀
* 如果请求的 url 以 http:// 或 https:// 开头,则不会添加域名前缀
*
* IMPORTANT: 项目已经全局注入 PROJECT_DOMAIN
* IMPORTANT: 除非你需要添加全局参数,如给所有请求加上 header,否则不能修改此文件
*/
export namespace Network {
const createUrl = (url: string): string => {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
return `${PROJECT_DOMAIN}${url}`
}
export const request: typeof Taro.request = option => {
return Taro.request({
...option,
url: createUrl(option.url),
})
}
export const uploadFile: typeof Taro.uploadFile = option => {
return Taro.uploadFile({
...option,
url: createUrl(option.url),
})
}
export const downloadFile: typeof Taro.downloadFile = option => {
return Taro.downloadFile({
...option,
url: createUrl(option.url),
})
}
}
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})
@@ -0,0 +1 @@
/* 优先使用 tailwindcss,如无必要请不要使用css */
@@ -0,0 +1,99 @@
import { View, Text } from '@tarojs/components'
import { useLoad } from '@tarojs/taro'
import { Network } from '@/network'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import './index.css'
/**
* 首页 - 欢迎页面
*/
const IndexPage = () => {
useLoad(async () => {
try {
const res = await Network.request({ url: '/api/hello' })
console.log('API Response:', res.data)
} catch (error) {
console.error('API Error:', error)
}
})
return (
<View className="min-h-screen bg-background p-4">
{/* 头部区域 */}
<View className="mb-6">
<Text className="block text-2xl font-bold text-foreground mb-2">
使
</Text>
<Text className="block text-sm text-muted-foreground">
Taro
</Text>
</View>
{/* 功能卡片 */}
<View className="flex flex-col gap-4">
<Card>
<CardContent className="p-4">
<View className="flex items-center justify-between mb-2">
<Text className="block text-lg font-semibold text-foreground">
</Text>
<Badge variant="secondary"></Badge>
</View>
<Text className="block text-sm text-muted-foreground mb-4">
使
</Text>
<Button className="w-full">
<Text className="text-primary-foreground"></Text>
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<View className="flex items-center justify-between mb-2">
<Text className="block text-lg font-semibold text-foreground">
</Text>
<Badge variant="outline"></Badge>
</View>
<View className="flex flex-col gap-2">
<View className="flex items-center gap-2">
<View className="w-2 h-2 rounded-full bg-primary" />
<Text className="block text-sm text-foreground">
便
</Text>
</View>
<View className="flex items-center gap-2">
<View className="w-2 h-2 rounded-full bg-primary" />
<Text className="block text-sm text-foreground">
</Text>
</View>
<View className="flex items-center gap-2">
<View className="w-2 h-2 rounded-full bg-primary" />
<Text className="block text-sm text-foreground">
</Text>
</View>
</View>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<Text className="block text-lg font-semibold text-foreground mb-2">
</Text>
<Text className="block text-sm text-muted-foreground">
</Text>
</CardContent>
</Card>
</View>
</View>
)
}
export default IndexPage
@@ -0,0 +1,23 @@
import Taro from '@tarojs/taro';
/**
* 小程序调试工具
* 在开发版/体验版自动开启调试模式
* 支持微信小程序
*/
export function devDebug() {
const env = Taro.getEnv();
if (env === Taro.ENV_TYPE.WEAPP) {
try {
const accountInfo = Taro.getAccountInfoSync();
const envVersion = accountInfo.miniProgram.envVersion;
console.log('[Debug] envVersion:', envVersion);
if (envVersion !== 'release') {
Taro.setEnableDebug({ enableDebug: true });
}
} catch (error) {
console.error('[Debug] 开启调试模式失败:', error);
}
}
}
@@ -0,0 +1 @@
export const IS_H5_ENV = TARO_ENV === 'h5';
@@ -0,0 +1,16 @@
import { PropsWithChildren } from 'react';
import { IS_H5_ENV } from './env';
import { H5NavBar } from './h5-navbar';
export const H5Container = ({ children }: PropsWithChildren) => {
if (!IS_H5_ENV) {
return <>{children}</>;
}
return (
<>
<H5NavBar />
{children}
</>
);
};
@@ -0,0 +1,391 @@
import { View } from '@tarojs/components';
import { Component, PropsWithChildren, useSyncExternalStore } from 'react';
import { CircleAlert, Copy, RefreshCw, X } from 'lucide-react-taro';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Portal } from '@/components/ui/portal';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from '@/components/ui/toast';
import { cn } from '@/lib/utils';
import { IS_H5_ENV } from './env';
type ErrorState = {
error: Error | null;
};
type ErrorBoundaryProps = PropsWithChildren;
type ErrorReportOptions = {
componentStack?: string;
source?: string;
};
type OverlayStore = {
error: Error | null;
report: string;
source: string;
visible: boolean;
open: boolean;
timestamp: string;
};
const EMPTY_STORE: OverlayStore = {
error: null,
report: '',
source: '',
visible: false,
open: false,
timestamp: '',
};
const ERROR_ACCENT_COLOR = 'hsl(360, 100%, 45%)';
let handlersInstalled = false;
let overlayStore: OverlayStore = EMPTY_STORE;
const listeners = new Set<() => void>();
const emitChange = () => {
listeners.forEach(listener => listener());
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const getSnapshot = () => overlayStore;
const setOverlayStore = (nextStore: OverlayStore) => {
overlayStore = nextStore;
emitChange();
};
const copyText = async (text: string) => {
if (typeof window === 'undefined') return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (error) {
console.warn('[H5ErrorBoundary] Clipboard API copy failed:', error);
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
return copied;
} catch (error) {
console.warn('[H5ErrorBoundary] Fallback copy failed:', error);
return false;
}
};
const normalizeError = (value: unknown) => {
if (value instanceof Error) {
return value;
}
if (typeof value === 'string') {
return new Error(value);
}
try {
return new Error(JSON.stringify(value));
} catch {
return new Error(String(value));
}
};
const buildErrorReport = (error: Error, options: ErrorReportOptions = {}) => {
const lines = [
'[H5 Runtime Error]',
`Time: ${new Date().toISOString()}`,
options.source ? `Source: ${options.source}` : '',
`Name: ${error.name}`,
`Message: ${error.message}`,
error.stack ? `Stack:\n${error.stack}` : '',
options.componentStack ? `Component Stack:\n${options.componentStack}` : '',
typeof navigator !== 'undefined'
? `User Agent: ${navigator.userAgent}`
: '',
].filter(Boolean);
return lines.join('\n\n');
};
const setPanelOpen = (open: boolean) => {
if (!overlayStore.visible) return;
setOverlayStore({
...overlayStore,
open,
});
};
export const showH5ErrorOverlay = (
input: unknown,
options: ErrorReportOptions = {},
) => {
if (typeof window === 'undefined') {
return;
}
const error = normalizeError(input);
const report = buildErrorReport(error, options);
const timestamp = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
setOverlayStore({
error,
report,
source: options.source || 'runtime',
timestamp,
visible: true,
open: false,
});
console.error('[H5ErrorOverlay] Showing error overlay:', error, options);
};
const handleWindowError = (event: ErrorEvent) => {
const error =
event.error || new Error(event.message || 'Unknown H5 runtime error');
showH5ErrorOverlay(error, { source: 'window.error' });
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
showH5ErrorOverlay(event.reason, { source: 'window.unhandledrejection' });
};
export const initializeH5ErrorHandling = () => {
if (!IS_H5_ENV || typeof window === 'undefined' || handlersInstalled) {
return;
}
handlersInstalled = true;
window.addEventListener('error', handleWindowError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
};
const H5ErrorOverlayHost = () => {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
if (!IS_H5_ENV || !store.visible) {
return null;
}
const errorName = store.error?.name || 'Error';
return (
<Portal>
<View className="pointer-events-none fixed inset-0 z-[2147483646]">
<View className="pointer-events-auto fixed bottom-5 left-5">
<Button
variant="outline"
size="icon"
className={cn(
'h-11 w-11 rounded-full shadow-md transition-transform',
)}
style={{
backgroundColor: 'hsl(359, 100%, 97%)',
borderColor: 'hsl(359, 100%, 94%)',
color: ERROR_ACCENT_COLOR,
}}
onClick={() => setPanelOpen(!store.open)}
>
<CircleAlert size={22} color={ERROR_ACCENT_COLOR} />
</Button>
</View>
{store.open && (
<View className="pointer-events-none fixed inset-0 bg-white bg-opacity-15 supports-[backdrop-filter]:backdrop-blur-md">
<View className="absolute inset-0 flex items-center justify-center px-4 py-4">
<View
className="w-full max-w-md"
style={{
width:
'min(calc(100vw - 32px), var(--h5-phone-width, 390px))',
height: 'min(calc(100vh - 32px), 900px)',
}}
>
<Card
className={cn(
'pointer-events-auto h-full rounded-2xl border border-border bg-background text-foreground shadow-2xl',
)}
>
<View className="relative flex h-full flex-col">
<CardHeader className="gap-2 p-4 pb-2">
<View className="flex items-start justify-between gap-3">
<View className="flex flex-wrap items-center gap-2">
<Badge
variant="destructive"
className="border-none bg-red-500 px-3 py-1 text-xs font-medium text-white"
>
Runtime Error
</Badge>
<Badge
variant="outline"
className="px-3 py-1 text-xs"
>
{store.source}
</Badge>
</View>
<View className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => window.location.reload()}
>
<RefreshCw size={15} color="inherit" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full"
onClick={() => setPanelOpen(false)}
>
<X size={17} color="inherit" />
</Button>
</View>
</View>
<View className="flex items-center justify-between gap-3">
<CardTitle className="text-left text-lg">
{errorName}
</CardTitle>
<Button
variant="outline"
size="sm"
className="shrink-0 rounded-lg"
onClick={async () => {
const copied = await copyText(store.report);
if (copied) {
toast.success('已复制错误信息', {
description: '可发送给 Agent 进行自动修复',
position: 'top-center',
});
return;
}
toast.warning('复制失败', {
description: '请直接选中文本后手动复制。',
position: 'top-center',
});
}}
>
<Copy size={15} color="inherit" />
<View></View>
</Button>
</View>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden px-4 pb-4 pt-2">
<View className="flex h-full min-h-0 flex-col gap-2">
<View className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-border px-3 py-2 text-sm">
<View className="flex items-center gap-2">
<View className="text-muted-foreground">Error</View>
<View className="font-medium text-foreground">
{store.error?.name || 'Error'}
</View>
</View>
<View className="h-4 w-px bg-border" />
<View className="flex items-center gap-2">
<View className="text-muted-foreground">
Source
</View>
<View className="font-medium text-foreground">
{store.source}
</View>
</View>
</View>
<View className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-xl border border-border bg-black text-white">
<View className="flex items-center justify-between border-b border-white border-opacity-10 px-3 py-3">
<View className="text-xs font-medium uppercase tracking-wide text-zinc-400">
Full Report
</View>
<Badge
variant="outline"
className="border-zinc-700 bg-transparent px-2 py-1 text-xs text-zinc-400"
>
{store.timestamp}
</Badge>
</View>
<ScrollArea
className="min-h-0 flex-1 w-full"
orientation="both"
>
<View className="inline-block min-w-full whitespace-pre px-3 py-3 pb-8 font-mono text-xs leading-6 text-zinc-200">
{store.report}
</View>
</ScrollArea>
</View>
</View>
</CardContent>
</View>
</Card>
</View>
</View>
</View>
)}
</View>
</Portal>
);
};
class H5ErrorBoundaryInner extends Component<ErrorBoundaryProps, ErrorState> {
static getDerivedStateFromError(error: Error): Partial<ErrorState> {
return { error };
}
state: ErrorState = {
error: null,
};
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (this.state.error && prevProps.children !== this.props.children) {
this.setState({ error: null });
}
}
componentDidCatch(error: Error, info: { componentStack: string }) {
showH5ErrorOverlay(error, {
source: 'React Error Boundary',
componentStack: info.componentStack || '',
});
}
render() {
return (
<>
<H5ErrorOverlayHost />
{this.state.error ? null : this.props.children}
</>
);
}
}
export const H5ErrorBoundary = ({ children }: PropsWithChildren) => {
if (!IS_H5_ENV) {
return <>{children}</>;
}
return <H5ErrorBoundaryInner>{children}</H5ErrorBoundaryInner>;
};
@@ -0,0 +1,239 @@
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePageScroll } from '@tarojs/taro';
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, House } from 'lucide-react-taro';
import { IS_H5_ENV } from './env';
interface NavConfig {
navigationBarTitleText?: string;
navigationBarBackgroundColor?: string;
navigationBarTextStyle?: 'black' | 'white';
navigationStyle?: 'default' | 'custom';
transparentTitle?: 'none' | 'always' | 'auto';
}
enum LeftIcon {
Back = 'back',
Home = 'home',
None = 'none',
}
interface NavState {
visible: boolean;
title: string;
bgColor: string;
textStyle: 'black' | 'white';
navStyle: 'default' | 'custom';
transparent: 'none' | 'always' | 'auto';
leftIcon: LeftIcon;
}
const DEFAULT_NAV_STATE: NavState = {
visible: false,
title: '',
bgColor: '#ffffff',
textStyle: 'black',
navStyle: 'default',
transparent: 'none',
leftIcon: LeftIcon.None,
};
const getGlobalWindowConfig = (): Partial<NavConfig> => {
const app = Taro.getApp();
return app?.config?.window || {};
};
const getTabBarPages = (): Set<string> => {
const tabBar = Taro.getApp()?.config?.tabBar;
return new Set(
tabBar?.list?.map((item: { pagePath: string }) => item.pagePath) || [],
);
};
const getHomePage = (): string => {
const app = Taro.getApp();
return app?.config?.pages?.[0] || 'pages/index/index';
};
const cleanPath = (path: string): string => path.replace(/^\//, '');
const computeLeftIcon = (
route: string,
tabBarPages: Set<string>,
historyLength: number,
homePage: string,
): LeftIcon => {
if (!route) return LeftIcon.None;
const cleanRoute = cleanPath(route);
const cleanHomePage = cleanPath(homePage);
const isHomePage = cleanRoute === cleanHomePage;
const isTabBarPage =
tabBarPages.has(cleanRoute) || tabBarPages.has(`/${cleanRoute}`);
const hasHistory = historyLength > 1;
if (isTabBarPage || isHomePage) return LeftIcon.None;
if (hasHistory) return LeftIcon.Back;
return LeftIcon.Home;
};
export const H5NavBar = () => {
const [navState, setNavState] = useState<NavState>(DEFAULT_NAV_STATE);
const [scrollOpacity, setScrollOpacity] = useState(0);
const updateNavState = useCallback(() => {
const pages = Taro.getCurrentPages();
if (pages.length === 0) {
setNavState(prev => ({ ...prev, visible: false }));
return;
}
const currentPage = pages[pages.length - 1];
const route = currentPage?.route || '';
if (!route) return;
const pageConfig: NavConfig = (currentPage as any)?.config || {};
const globalConfig = getGlobalWindowConfig();
const tabBarPages = getTabBarPages();
const homePage = getHomePage();
const cleanRoute = cleanPath(route);
const cleanHomePage = cleanPath(homePage);
const isHomePage = cleanRoute === cleanHomePage;
const isTabBarPage =
tabBarPages.has(cleanRoute) || tabBarPages.has(`/${cleanRoute}`);
const shouldHideNav =
tabBarPages.size <= 1 &&
pages.length <= 1 &&
(isHomePage || isTabBarPage);
setNavState({
visible: !shouldHideNav,
title:
document.title ||
pageConfig.navigationBarTitleText ||
globalConfig.navigationBarTitleText ||
'',
bgColor:
pageConfig.navigationBarBackgroundColor ||
globalConfig.navigationBarBackgroundColor ||
'#ffffff',
textStyle:
pageConfig.navigationBarTextStyle ||
globalConfig.navigationBarTextStyle ||
'black',
navStyle:
pageConfig.navigationStyle || globalConfig.navigationStyle || 'default',
transparent:
pageConfig.transparentTitle || globalConfig.transparentTitle || 'none',
leftIcon: shouldHideNav
? LeftIcon.None
: computeLeftIcon(cleanRoute, tabBarPages, pages.length, cleanHomePage),
});
}, []);
useDidShow(() => {
updateNavState();
});
usePageScroll(({ scrollTop }) => {
if (navState.transparent === 'auto') {
setScrollOpacity(Math.min(scrollTop / 100, 1));
}
});
useEffect(() => {
if (!IS_H5_ENV) return;
let timer: NodeJS.Timeout | null = null;
const observer = new MutationObserver(() => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
updateNavState();
}, 50);
});
observer.observe(document.head, {
subtree: true,
childList: true,
characterData: true,
});
updateNavState();
return () => {
observer.disconnect();
if (timer) clearTimeout(timer);
};
}, [updateNavState]);
const shouldRender =
IS_H5_ENV && navState.visible && navState.navStyle !== 'custom';
useEffect(() => {
if (!IS_H5_ENV) return;
if (shouldRender) {
document.body.classList.add('h5-navbar-visible');
} else {
document.body.classList.remove('h5-navbar-visible');
}
}, [shouldRender]);
if (!shouldRender) {
return <></>;
}
const iconColor = navState.textStyle === 'white' ? '#fff' : '#333';
const textColorClass =
navState.textStyle === 'white' ? 'text-white' : 'text-gray-800';
const getBgStyle = () => {
if (navState.transparent === 'always') {
return { backgroundColor: 'transparent' };
}
if (navState.transparent === 'auto') {
return { backgroundColor: navState.bgColor, opacity: scrollOpacity };
}
return { backgroundColor: navState.bgColor };
};
const handleBack = () => Taro.navigateBack();
const handleGoHome = () => {
const homePage = getHomePage();
Taro.reLaunch({ url: `/${homePage}` });
};
return (
<>
<View
className="fixed top-0 left-0 right-0 h-11 flex items-center justify-center z-1000"
style={getBgStyle()}
>
{navState.leftIcon === LeftIcon.Back && (
<View
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 flex items-center justify-center"
onClick={handleBack}
>
<ChevronLeft size={24} color={iconColor} />
</View>
)}
{navState.leftIcon === LeftIcon.Home && (
<View
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 flex items-center justify-center"
onClick={handleGoHome}
>
<House size={22} color={iconColor} />
</View>
)}
<Text
className={`text-base font-medium max-w-3/5 truncate ${textColorClass}`}
>
{navState.title}
</Text>
</View>
<View className="h-11 shrink-0" />
</>
);
};
@@ -0,0 +1,222 @@
/**
* H5 端特殊样式注入
* 如无必要,请勿修改本文件
*/
import { IS_H5_ENV } from './env';
const H5_BASE_STYLES = `
/* H5 端隐藏 TabBar 空图标(只隐藏没有 src 的图标) */
.weui-tabbar__icon:not([src]),
.weui-tabbar__icon[src=''] {
display: none !important;
}
.weui-tabbar__item:has(.weui-tabbar__icon:not([src])) .weui-tabbar__label,
.weui-tabbar__item:has(.weui-tabbar__icon[src='']) .weui-tabbar__label {
margin-top: 0 !important;
}
/* Vite 错误覆盖层无法选择文本的问题 */
vite-error-overlay {
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-user-select: text !important;
}
vite-error-overlay::part(window) {
max-width: 90vw;
padding: 10px;
}
.taro_page {
overflow: auto;
}
::-webkit-scrollbar {
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* H5 导航栏页面自动添加顶部间距 */
body.h5-navbar-visible .taro_page {
padding-top: 44px;
}
body.h5-navbar-visible .toaster[data-position^="top"] {
top: 44px !important;
}
/* Sheet 组件在 H5 导航栏下的位置修正 */
body.h5-navbar-visible .sheet-content:not([data-side="bottom"]) {
top: 44px !important;
}
/*
* H5 端 rem 适配:与小程序 rpx 缩放一致
* 375px 屏幕:1rem = 16px,小程序 32rpx = 16px
*/
html {
font-size: 4vw !important;
}
/* H5 端组件默认样式修复 */
taro-view-core {
display: block;
}
taro-text-core {
display: inline;
}
taro-input-core {
display: block;
width: 100%;
}
taro-input-core input {
width: 100%;
background: transparent;
border: none;
outline: none;
}
taro-input-core.taro-otp-hidden-input input {
color: transparent;
caret-color: transparent;
-webkit-text-fill-color: transparent;
}
/* 全局按钮样式重置 */
taro-button-core,
button {
margin: 0 !important;
padding: 0 !important;
line-height: inherit;
display: flex;
align-items: center;
justify-content: center;
}
taro-button-core::after,
button::after {
border: none;
}
taro-textarea-core > textarea,
.taro-textarea,
textarea.taro-textarea {
resize: none !important;
}
`;
const PC_WIDESCREEN_STYLES = `
/* PC 宽屏适配 - 基础布局 */
@media (min-width: 769px) {
html {
font-size: 15px !important;
}
body {
background-color: #f3f4f6 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
min-height: 100vh !important;
}
}
`;
const PC_WIDESCREEN_PHONE_FRAME = `
/* PC 宽屏适配 - 手机框样式(有 TabBar 页面) */
@media (min-width: 769px) {
.taro-tabbar__container {
width: 375px !important;
max-width: 375px !important;
height: calc(100vh - 40px) !important;
max-height: 900px !important;
background-color: #fff !important;
transform: translateX(0) !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1) !important;
border-radius: 20px !important;
overflow: hidden !important;
position: relative !important;
}
.taro-tabbar__panel {
height: 100% !important;
overflow: auto !important;
}
}
/* PC 宽屏适配 - Toast 定位到手机框范围内 */
@media (min-width: 769px) {
body .toaster {
left: 50% !important;
right: auto !important;
width: 375px !important;
max-width: 375px !important;
transform: translateX(-50%) !important;
box-sizing: border-box !important;
}
}
/* PC 宽屏适配 - 手机框样式(无 TabBar 页面,通过 JS 添加 no-tabbar 类) */
@media (min-width: 769px) {
body.no-tabbar #app {
width: 375px !important;
max-width: 375px !important;
height: calc(100vh - 40px) !important;
max-height: 900px !important;
background-color: #fff !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1) !important;
border-radius: 20px !important;
overflow: hidden !important;
position: relative !important;
transform: translateX(0) !important;
}
body.no-tabbar #app .taro_router {
height: 100% !important;
overflow: auto !important;
}
}
`;
function injectStyles() {
const style = document.createElement('style');
style.innerHTML =
H5_BASE_STYLES + PC_WIDESCREEN_STYLES + PC_WIDESCREEN_PHONE_FRAME;
document.head.appendChild(style);
}
function setupTabbarDetection() {
const checkTabbar = () => {
const hasTabbar = !!document.querySelector('.taro-tabbar__container');
document.body.classList.toggle('no-tabbar', !hasTabbar);
};
checkTabbar();
const observer = new MutationObserver(checkTabbar);
observer.observe(document.body, { childList: true, subtree: true });
}
export function injectH5Styles() {
if (!IS_H5_ENV) return;
injectStyles();
setupTabbarDetection();
}
@@ -0,0 +1,31 @@
import { useLaunch } from '@tarojs/taro';
import { PropsWithChildren } from 'react';
import { injectH5Styles } from './h5-styles';
import { devDebug } from './dev-debug';
import { H5Container } from './h5-container';
import {
H5ErrorBoundary,
initializeH5ErrorHandling,
} from './h5-error-boundary';
import { IS_H5_ENV } from './env';
export const Preset = ({ children }: PropsWithChildren) => {
if (IS_H5_ENV) {
initializeH5ErrorHandling();
}
useLaunch(() => {
devDebug();
injectH5Styles();
});
if (IS_H5_ENV) {
return (
<H5ErrorBoundary>
<H5Container>{children}</H5Container>
</H5ErrorBoundary>
);
}
return <>{children}</>;
};
@@ -0,0 +1,4 @@
/** @type {import('stylelint').Config} */
export default {
extends: "stylelint-config-standard",
};
+90 -1
View File
@@ -1 +1,90 @@
# tests
# P01_errlens_app - 测试目录
## 测试结构
```
tests/
├── unit/ # 单元测试
│ ├── components/ # 组件测试
│ ├── lib/ # 工具函数测试
│ └── pages/ # 页面测试
├── integration/ # 集成测试
│ ├── api/ # API 集成测试
│ └── components/ # 组件集成测试
├── e2e/ # 端到端测试
│ └── scenarios/ # 用户场景测试
├── __mocks__/ # Mock 文件
│ ├── network.ts
│ └── taro.ts
├── setup.ts # 测试环境配置
├── jest.config.js # Jest 配置
└── README.md # 本文件
```
## 测试框架
| 框架 | 用途 |
|------|------|
| Jest | 测试运行器 |
| @testing-library/react | React 组件测试 |
| Vitest | 替代 Jest(可选) |
| Supertest | HTTP API 测试 |
## 测试命令
```bash
# 运行所有测试
npm test
# 运行单元测试
npm run test:unit
# 运行集成测试
npm run test:integration
# 运行 E2E 测试
npm run test:e2e
# 监听模式
npm run test:watch
# 生成覆盖率报告
npm run test:coverage
```
## 测试覆盖率目标
| 类型 | 目标覆盖率 |
|------|-----------|
| 单元测试 | >= 80% |
| 集成测试 | >= 60% |
| E2E 测试 | 3+ 核心场景 |
## 待编写测试
### 单元测试
- [ ] Button 组件测试
- [ ] Input 组件测试
- [ ] Card 组件测试
- [ ] Dialog 组件测试
- [ ] utils 函数测试
- [ ] platform 检测测试
### 集成测试
- [ ] 用户登录流程测试
- [ ] 代码分析流程测试
- [ ] 历史记录查询测试
### E2E 测试
- [ ] 完整登录-分析-查看结果流程
- [ ] 错误处理流程
- [ ] 多端兼容性测试
---
**文档版本**v1.0.0
**最后更新**2026-05-22
@@ -0,0 +1,48 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'jest-preset-taro',
testEnvironment: 'jsdom',
testMatch: [
'**/tests/**/*.test.{ts,tsx}',
'**/tests/**/*.spec.{ts,tsx}'
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
transform: {
'^.+\\.(ts|tsx)$': ['babel-jest', { presets: ['@babel/preset-typescript'] }],
'^.+\\.(js|jsx)$': 'babel-jest'
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss)$': 'identity-obj-proxy'
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/types/**',
'!src/**/index.{ts,tsx}'
],
coverageThreshold: {
global: {
branches: 60,
functions: 70,
lines: 70,
statements: 70
}
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
testPathIgnorePatterns: [
'/node_modules/',
'/dist/'
],
verbose: true
}
+42
View File
@@ -0,0 +1,42 @@
// Jest 测试环境配置
import '@testing-library/jest-dom'
// Mock Taro API
jest.mock('@tarojs/taro', () => ({
getEnv: jest.fn(() => 'WEB'),
ENV_TYPE: {
WEAPP: 'WEAPP',
WEB: 'WEB',
RN: 'RN',
TT: 'TT'
},
request: jest.fn(),
uploadFile: jest.fn(),
downloadFile: jest.fn(),
createSelectorQuery: jest.fn(() => ({
select: jest.fn().mockReturnThis(),
boundingClientRect: jest.fn().mockReturnThis(),
exec: jest.fn(cb => cb([{}]))
})),
getSystemInfoSync: jest.fn(() => ({
windowWidth: 375,
windowHeight: 667
}))
}))
// Mock Network
jest.mock('../src/network', () => ({
Network: {
request: jest.fn(),
uploadFile: jest.fn(),
downloadFile: jest.fn()
}
}))
// 全局测试超时
jest.setTimeout(10000)
// 清理
afterEach(() => {
jest.clearAllMocks()
})

Some files were not shown because too many files have changed in this diff Show More