Browse Source

✨ feat(ui): 集成@weapp-tailwindcss/merge并添加基础UI组件

- 添加@weapp-tailwindcss/merge依赖,用于小程序环境下的Tailwind类名合并
- 新增Input和Label基础UI组件,支持变体样式和图标
- 创建cn工具函数,结合clsx和twMerge处理复杂类名逻辑

📝 docs(ui): 更新UI规范文档

- 新增类名合并规范章节,详细说明twMerge的使用方法和最佳实践
- 添加tailwind-merge使用规范章节,包含基本用法和小程序特殊处理
- 完善目录结构和工具函数文档,补充cva集成示例

🔧 chore(deps): 更新依赖配置

- 在package.json中添加@weapp-tailwindcss/merge和class-variance-authority依赖
- 安装class-variance-authority用于UI组件变体管理
- 锁定相关依赖版本,确保构建一致性
yourname 4 months ago
parent
commit
1b5f8226b5

+ 116 - 2
.roo/rules/16-mini-program-ui.md

@@ -10,6 +10,7 @@
 - **React 18** - 前端框架
 - **Tailwind CSS v4** - 原子化CSS框架
 - **@egoist/tailwindcss-icons** - 图标库集成
+- **@weapp-tailwindcss/merge** - Tailwind类名合并工具(小程序版tailwind-merge)
 - **clsx** - 条件样式类名管理
 
 ## 目录结构
@@ -47,6 +48,32 @@ mini/
 </View>
 ```
 
+#### 1.2 类名合并规范
+```typescript
+// ✅ 使用twMerge处理动态类名冲突
+import { twMerge } from '@weapp-tailwindcss/merge'
+
+// 处理静态和动态类名的冲突
+<View className={twMerge('px-4 py-2', isActive ? 'bg-blue-500' : 'bg-gray-200')}>
+  <Text>按钮</Text>
+</View>
+
+// 处理多个条件类名的合并
+<View className={twMerge(
+  'flex items-center',
+  isActive && 'bg-blue-500 text-white',
+  isDisabled && 'opacity-50 cursor-not-allowed',
+  customClassName
+)}>
+  <Text>复杂组件</Text>
+</View>
+
+// ❌ 避免手动拼接类名导致冲突
+<View className={`px-4 py-2 ${isActive ? 'bg-blue-500' : 'bg-gray-200'} ${customClassName}`}>
+  <Text>按钮</Text>
+</View>
+```
+
 #### 1.2 响应式设计
 ```typescript
 // 使用Tailwind的响应式前缀
@@ -297,13 +324,46 @@ module.exports = {
 ```typescript
 // mini/src/utils/cn.ts
 import { clsx, type ClassValue } from 'clsx'
-import { twMerge } from 'tailwind-merge'
+import { twMerge } from '@weapp-tailwindcss/merge'
 
 export function cn(...inputs: ClassValue[]) {
   return twMerge(clsx(inputs))
 }
 ```
 
+#### 6.2 小程序专用类名处理
+```typescript
+// 小程序环境下的类名合并
+import { twMerge } from '@weapp-tailwindcss/merge'
+
+// 标准用法(自动处理小程序转义)
+const classes = twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
+// → 'hovercbg-dark-red p-3 bg-_hB91C1C_'
+
+// 手动指定版本(如果需要)
+import { twMerge as twMergeV4 } from '@weapp-tailwindcss/merge/v4'
+import { twMerge as twMergeV3 } from '@weapp-tailwindcss/merge/v3'
+
+// 使用cva进行组件变体管理
+import { cva } from 'class-variance-authority'
+
+const buttonVariants = cva(
+  'inline-flex items-center justify-center rounded-md text-sm font-medium',
+  {
+    variants: {
+      variant: {
+        default: 'bg-blue-500 text-white hover:bg-blue-600',
+        destructive: 'bg-red-500 text-white hover:bg-red-600',
+      },
+      size: {
+        sm: 'h-8 px-3 text-xs',
+        lg: 'h-12 px-6 text-base',
+      },
+    },
+  }
+)
+```
+
 ### 7. 最佳实践
 
 #### 7.1 状态管理
@@ -362,9 +422,63 @@ const [data, setData] = useState<User[]>([])
 // <View className="outline outline-1 outline-red-500" />
 ```
 
+### 10. tailwind-merge使用规范
+
+#### 10.1 基本用法
+```typescript
+// 单类名合并
+const result = twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
+// → 'hovercbg-dark-red p-3 bg-_hB91C1C_'
+
+// 处理冲突类名
+twMerge('px-4', 'px-2') // → 'px-2'
+twMerge('text-red-500', 'text-blue-500') // → 'text-blue-500'
+```
+
+#### 10.2 条件类名处理
+```typescript
+// 使用cn工具函数处理条件类名
+import { cn } from '@/utils/cn'
+
+const Button = ({ variant, size, disabled, className }) => {
+  return (
+    <Button
+      className={cn(
+        'inline-flex items-center justify-center rounded-md',
+        variant === 'primary' && 'bg-blue-500 text-white',
+        variant === 'secondary' && 'bg-gray-200 text-gray-800',
+        size === 'sm' && 'px-3 py-1 text-sm',
+        size === 'lg' && 'px-6 py-3 text-lg',
+        disabled && 'opacity-50 cursor-not-allowed',
+        className // 允许外部覆盖
+      )}
+    >
+      按钮
+    </Button>
+  )
+}
+```
+
+#### 10.3 小程序特殊处理
+```typescript
+// 跨端使用
+import { create } from '@weapp-tailwindcss/merge'
+
+const { twMerge } = create({
+  // 在当前环境为小程序时启用转义
+  disableEscape: true
+})
+
+// 版本选择
+import { twMerge as twMergeV4 } from '@weapp-tailwindcss/merge/v4' // Tailwind v4
+import { twMerge as twMergeV3 } from '@weapp-tailwindcss/merge/v3' // Tailwind v3
+```
+
 ## 注意事项
 
 1. **兼容性**:确保所有类名在小程序环境中有效
 2. **性能**:避免过度嵌套和复杂选择器
 3. **可维护性**:保持组件结构清晰,样式统一
-4. **可读性**:合理使用空格和换行,提高代码可读性
+4. **可读性**:合理使用空格和换行,提高代码可读性
+5. **tailwind-merge**:始终使用twMerge或cn工具函数处理动态类名,避免类名冲突
+6. **版本兼容**:根据Tailwind CSS版本选择正确的tailwind-merge版本

+ 85 - 0
docs/tailwind-merge.md

@@ -0,0 +1,85 @@
+tailwind-merge
+tailwind-merge 是一个用于处理和优化 Tailwind CSS 类的工具,它的主要功能是合并和去除冗余或冲突的 CSS 类。
+
+我们平常在使用 Tailwind CSS 时,有时会出现重复或冲突的类名,特别是在动态应用类时,手动管理这些类可能会变得很复杂。
+
+tailwind-merge 可以帮助自动化这一过程,确保最终应用的 CSS 类是唯一且不会发生冲突。
+
+tailwind-merge 是完全运行时的方案,在运行时进行处理,而 tailwindcss 是编译时
+
+使用场景
+动态类名:当你在 JavaScript 或 React 等框架中动态生成类名时,tailwind-merge 可以帮助你合并和优化这些类名,避免冗余或冲突。
+条件类名:在基于某些条件添加 CSS 类时,tailwind-merge 确保条件类之间不会发生冲突,比如在响应式设计中或主题切换时。
+开发效率:开发人员可以节省大量的时间,不必手动检查和优化 Tailwind CSS 类的组合,确保样式一致且符合预期。
+它特别适合你开发组件的时候,对原有组件的 class 进行覆盖,所以像 shadcn/ui 这种 UI 库 直接把它作为了直接依赖。
+
+H5 使用方式
+import { twMerge } from 'tailwind-merge'
+
+twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
+// → 'hover:bg-dark-red p-3 bg-[#B91C1C]'
+
+小程序中的使用方式
+@weapp-tailwindcss/merge 是一个 tailwind-merge 的小程序版本,需要和 weapp-tailwindcss 一起使用
+
+其中 weapp-tailwindcss 为 @weapp-tailwindcss/merge 提供编译时支持。
+
+安装
+npm
+Yarn
+pnpm
+Bun
+pnpm add @weapp-tailwindcss/merge
+
+使用方式
+使用方式和 tailwind-merge 几乎完全相同
+
+import { twMerge } from '@weapp-tailwindcss/merge'
+
+twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
+// → 'hovercbg-dark-red p-3 bg-_hB91C1C_'
+
+从 @weapp-tailwindcss/merge 导出的方法和 tailwind-merge 相同
+
+另外目前编译时是以调用方法的名称作为寻址索引的,如果你需要方法重命名,
+
+比如把 twMerge -> cn,需要设置这个 ignoreCallExpressionIdentifiers 配置项
+
+特性
+@weapp-tailwindcss/merge 内置了 tailwind-merge@2(对应 tailwindcss@3) 和 tailwind-merge@3(对应 tailwindcss@4) 2 个版本,
+
+在安装的时候,根据你的 tailwindcss 版本自动进行切换。
+
+如果你需要手动指定,你可以从下列路径中导出
+
+// tailwindcss v3
+import { twMerge } from '@weapp-tailwindcss/merge/v3'
+// tailwindcss v4
+import { twMerge } from '@weapp-tailwindcss/merge/v4'
+
+另外 @weapp-tailwindcss/merge 还内置了 cva 功能 (class-variance-authority)
+
+import { cva } from '@weapp-tailwindcss/merge/cva'
+
+假如你熟悉原子类组件封装,你就知道这是什么了。
+
+跨多端使用
+import { create } from '@weapp-tailwindcss/merge';
+
+const { twMerge } = create(
+  {
+    // 在当前环境为只有小程序的环境的时候,需要转义,其他就禁止
+    disableEscape: true
+  }
+)
+
+const x = twMerge('px-2 py-1 bg-red hover:bg-dark-red', 'p-3 bg-[#B91C1C]')
+
+实时编辑器
+function () {
+  return <MergeDemo/>
+}
+结果
+参数 disableEscape:false
+点我切换参数
+结果:hovercbg-dark-red p-3 bg-_hB91C1C_

+ 2 - 0
mini/package.json

@@ -62,7 +62,9 @@
     "@tarojs/runtime": "4.1.4",
     "@tarojs/shared": "4.1.4",
     "@tarojs/taro": "4.1.4",
+    "@weapp-tailwindcss/merge": "^1.2.3",
     "abortcontroller-polyfill": "^1.7.8",
+    "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
     "react": "^18.0.0",
     "react-dom": "^18.0.0"

+ 54 - 0
mini/pnpm-lock.yaml

@@ -59,9 +59,15 @@ importers:
       '@tarojs/taro':
         specifier: 4.1.4
         version: 4.1.4(@tarojs/components@4.1.4(@tarojs/helper@4.1.4)(@types/react@18.3.23)(html-webpack-plugin@5.6.3(webpack@5.91.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.18(typescript@5.8.3))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.91.0(@swc/core@1.3.96)))(webpack@5.91.0(@swc/core@1.3.96)))(@tarojs/helper@4.1.4)(@tarojs/shared@4.1.4)(@types/react@18.3.23)(html-webpack-plugin@5.6.3(webpack@5.91.0(@swc/core@1.3.96)))(postcss@8.5.6)(rollup@3.29.5)(vue@3.5.18(typescript@5.8.3))(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.91.0(@swc/core@1.3.96)))(webpack@5.91.0(@swc/core@1.3.96))
+      '@weapp-tailwindcss/merge':
+        specifier: ^1.2.3
+        version: 1.2.3(tailwindcss@4.1.11)
       abortcontroller-polyfill:
         specifier: ^1.7.8
         version: 1.7.8
+      class-variance-authority:
+        specifier: ^0.7.1
+        version: 0.7.1
       clsx:
         specifier: ^2.1.1
         version: 2.1.1
@@ -2524,6 +2530,9 @@ packages:
   '@weapp-tailwindcss/mangle@1.0.5':
     resolution: {integrity: sha512-v59vYs2405ttUyLDXsdrkdwGrp0LEeZfgkycGuVE/T4h3zr45amofbRv3FnZxjMgWpyqSW+tSsxNiNFH6VPbDA==}
 
+  '@weapp-tailwindcss/merge@1.2.3':
+    resolution: {integrity: sha512-d5msj9MdguDvxd9OaskbRubppSbHRptyz0V3auzu4Uc6o1O2v1vuKbXSyoG3T+UUnNx9gUQ8kVkXDjr/QRWrDQ==}
+
   '@weapp-tailwindcss/postcss@1.0.17':
     resolution: {integrity: sha512-y1wpJ/HbsYilSzVKYdP6QyzUPHkFD08yqyav2BJ7LdeRE8EfN+WMvcnq58GAio2ZfmP3w610xsjRRyIug9mqEw==}
 
@@ -3018,6 +3027,9 @@ packages:
   citty@0.1.6:
     resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
 
+  class-variance-authority@0.7.1:
+    resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
   classnames@2.5.1:
     resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
 
@@ -6869,6 +6881,21 @@ packages:
     resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
     engines: {node: '>=10.0.0'}
 
+  tailwind-merge@2.6.0:
+    resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
+
+  tailwind-merge@3.0.2:
+    resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
+
+  tailwind-merge@3.3.1:
+    resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
+
+  tailwind-variants@1.0.0:
+    resolution: {integrity: sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==}
+    engines: {node: '>=16.x', pnpm: '>=7.x'}
+    peerDependencies:
+      tailwindcss: '*'
+
   tailwindcss-config@1.1.0:
     resolution: {integrity: sha512-pQp2IHR0mxh56rFLY55Ko63LguzQybY0Uhcc9nKW5dME/TCPejmnfiCkbtbbYnoBWGSxr9mnZZpi9U453d2tSQ==}
 
@@ -10103,6 +10130,18 @@ snapshots:
       '@weapp-core/regex': 1.0.1
       '@weapp-tailwindcss/shared': 1.0.3
 
+  '@weapp-tailwindcss/merge@1.2.3(tailwindcss@4.1.11)':
+    dependencies:
+      class-variance-authority: 0.7.1
+      clsx: 2.1.1
+      local-pkg: 1.1.1
+      semver: 7.7.2
+      tailwind-merge: 3.3.1
+      tailwind-merge-v2: tailwind-merge@2.6.0
+      tailwind-variants: 1.0.0(tailwindcss@4.1.11)
+    transitivePeerDependencies:
+      - tailwindcss
+
   '@weapp-tailwindcss/postcss@1.0.17':
     dependencies:
       '@weapp-core/escape': 4.0.1
@@ -10741,6 +10780,10 @@ snapshots:
     dependencies:
       consola: 3.4.2
 
+  class-variance-authority@0.7.1:
+    dependencies:
+      clsx: 2.1.1
+
   classnames@2.5.1: {}
 
   clean-css@4.2.4:
@@ -15013,6 +15056,17 @@ snapshots:
       string-width: 4.2.3
       strip-ansi: 6.0.1
 
+  tailwind-merge@2.6.0: {}
+
+  tailwind-merge@3.0.2: {}
+
+  tailwind-merge@3.3.1: {}
+
+  tailwind-variants@1.0.0(tailwindcss@4.1.11):
+    dependencies:
+      tailwind-merge: 3.0.2
+      tailwindcss: 4.1.11
+
   tailwindcss-config@1.1.0:
     dependencies:
       jiti: 2.5.1

+ 76 - 0
mini/src/components/ui/input.tsx

@@ -0,0 +1,76 @@
+import { Input as TaroInput, InputProps as TaroInputProps } from '@tarojs/components'
+import { cn } from '@/utils/cn'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { forwardRef } from 'react'
+
+const inputVariants = cva(
+  'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
+  {
+    variants: {
+      variant: {
+        default: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
+        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+        filled: 'border-none bg-gray-50 hover:bg-gray-100',
+      },
+      size: {
+        default: 'h-10 px-3 py-2',
+        sm: 'h-9 px-2 text-sm',
+        lg: 'h-11 px-4 text-lg',
+        icon: 'h-10 w-10',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+      size: 'default',
+    },
+  }
+)
+
+export interface InputProps extends Omit<TaroInputProps, 'className'>, VariantProps<typeof inputVariants> {
+  className?: string
+  leftIcon?: string
+  rightIcon?: string
+  error?: boolean
+  errorMessage?: string
+}
+
+const Input = forwardRef<HTMLInputElement, InputProps>(
+  ({ className, variant, size, leftIcon, rightIcon, error, errorMessage, ...props }, ref) => {
+    return (
+      <div className="w-full">
+        <div className="relative">
+          {leftIcon && (
+            <div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
+              <div className={cn('w-5 h-5 text-gray-400', leftIcon)} />
+            </div>
+          )}
+          
+          <TaroInput
+            ref={ref}
+            className={cn(
+              inputVariants({ variant, size, className }),
+              error && 'border-red-500 focus:border-red-500 focus:ring-red-500',
+              leftIcon && 'pl-10',
+              rightIcon && 'pr-10',
+            )}
+            {...props}
+          />
+          
+          {rightIcon && (
+            <div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
+              <div className={cn('w-5 h-5 text-gray-400', rightIcon)} />
+            </div>
+          )}
+        </div>
+        
+        {error && errorMessage && (
+          <p className="mt-1 text-sm text-red-600">{errorMessage}</p>
+        )}
+      </div>
+    )
+  }
+)
+
+Input.displayName = 'Input'
+
+export { Input, inputVariants }

+ 55 - 0
mini/src/components/ui/label.tsx

@@ -0,0 +1,55 @@
+import { View, Text } from '@tarojs/components'
+import { cn } from '@/utils/cn'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { forwardRef } from 'react'
+
+const labelVariants = cva(
+  'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
+  {
+    variants: {
+      variant: {
+        default: 'text-gray-900',
+        secondary: 'text-gray-600',
+        destructive: 'text-red-600',
+      },
+      size: {
+        default: 'text-sm',
+        sm: 'text-xs',
+        lg: 'text-base',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+      size: 'default',
+    },
+  }
+)
+
+export interface LabelProps {
+  className?: string
+  variant?: VariantProps<typeof labelVariants>['variant']
+  size?: VariantProps<typeof labelVariants>['size']
+  children: React.ReactNode
+  required?: boolean
+  htmlFor?: string
+}
+
+const Label = forwardRef<HTMLLabelElement, LabelProps>(
+  ({ className, variant, size, children, required, htmlFor, ...props }, ref) => {
+    return (
+      <View className="mb-2">
+        <Text
+          className={cn(labelVariants({ variant, size, className }))}
+          {...props}
+        >
+          {children}
+          {required && <Text className="text-red-500 ml-1">*</Text>}
+        </Text>
+      </View>
+    )
+  }
+)
+
+Label.displayName = 'Label'
+
+export { Label, labelVariants }

+ 6 - 0
mini/src/utils/cn.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from '@weapp-tailwindcss/merge'
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs))
+}