mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
feat: Enhance SearchLoadingAnimation with pause functionality and progress indicators; update Badge component to support icons with customizable positions
This commit is contained in:
@@ -1,912 +0,0 @@
|
||||
# KVideo UI 审计报告 (UI Audit Report)
|
||||
## 基于 Liquid Glass 设计系统的全面评估
|
||||
|
||||
**审计日期**: 2025-11-18
|
||||
**项目**: KVideo - 视频聚合平台
|
||||
**设计系统**: Liquid Glass Design System
|
||||
|
||||
---
|
||||
|
||||
## 📊 执行摘要 (Executive Summary)
|
||||
|
||||
KVideo 项目展现了**优秀的 Liquid Glass 设计系统实现**,核心视觉语言高度一致,组件架构清晰模块化。项目在玻璃态射效果、圆角规范、动画流畅度方面表现出色,已达到 **85% 的设计系统合规度**。
|
||||
|
||||
**优点**:
|
||||
- ✅ 完整的 CSS 变量系统,主题切换流畅
|
||||
- ✅ 核心组件严格遵循 `rounded-2xl` / `rounded-full` 规范
|
||||
- ✅ 毛玻璃效果 (`backdrop-filter`) 实现精准
|
||||
- ✅ 响应式设计细致,移动端适配优秀
|
||||
- ✅ 流体动画系统完整,物理感强
|
||||
|
||||
**待改进**:
|
||||
- ⚠️ 部分组件缺少 ARIA 属性和键盘导航
|
||||
- ⚠️ 部分圆角使用不一致(混用 Tailwind 原生类)
|
||||
- ⚠️ 色彩对比度需验证 WCAG 2.2 AA 标准
|
||||
- ⚠️ 缺少 focus-visible 状态样式
|
||||
- ⚠️ 部分组件超过 150 行限制
|
||||
|
||||
---
|
||||
|
||||
## 🎨 设计系统合规性分析
|
||||
|
||||
### 1. **玻璃态射效果 (Glass Effect) - 95% 合规**
|
||||
|
||||
#### ✅ 优秀实践
|
||||
```css
|
||||
/* globals.css - 完美的玻璃态射基础 */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(25px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(25px) saturate(180%);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 完美实现了毛玻璃效果的三大核心:`backdrop-filter`、`saturate`、半透明背景
|
||||
- 提供了 `-webkit-` 前缀以支持 Safari
|
||||
- 正确使用 CSS 变量确保主题一致性
|
||||
|
||||
#### ⚠️ 需改进的地方
|
||||
**位置**: `components/ui/Card.tsx` - Line 17-18
|
||||
```tsx
|
||||
// 当前实现
|
||||
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
|
||||
|
||||
// 问题: Tailwind 4.0 语法需要验证,建议使用 CSS 类
|
||||
```
|
||||
|
||||
**建议**: 在 `globals.css` 中定义专用类,避免内联样式的可维护性问题
|
||||
|
||||
---
|
||||
|
||||
### 2. **圆角规范 (Border Radius) - 80% 合规**
|
||||
|
||||
#### ✅ 完全符合规范的组件
|
||||
1. **Button** (`components/ui/Button.tsx`): `rounded-[var(--radius-2xl)]` ✅
|
||||
2. **Badge** (`components/ui/Badge.tsx`): `rounded-[var(--radius-full)]` ✅
|
||||
3. **Card** (`components/ui/Card.tsx`): `rounded-[var(--radius-2xl)]` ✅
|
||||
4. **ThemeSwitcher**: 外层 `rounded-full`,按钮 `rounded-full` ✅
|
||||
5. **Input**: `rounded-[var(--radius-2xl)]` ✅
|
||||
|
||||
#### ⚠️ 不一致的使用
|
||||
**位置**: `components/search/VideoGrid.tsx` - Line 73
|
||||
```tsx
|
||||
// 混用 Tailwind 原生类和 CSS 变量
|
||||
style={{ borderRadius: 'var(--radius-2xl)' }}
|
||||
// vs
|
||||
className="rounded-[var(--radius-2xl)]"
|
||||
```
|
||||
|
||||
**问题**: 同一组件内同时使用 `style` 和 `className` 设置圆角,不一致
|
||||
|
||||
**建议**: 统一使用 `className` 方式或全部使用 `style`
|
||||
|
||||
---
|
||||
|
||||
### 3. **色彩系统与对比度 (Color System & Contrast) - 75% 合规**
|
||||
|
||||
#### ✅ 优秀实践
|
||||
```css
|
||||
/* globals.css - 完整的亮/暗色变量系统 */
|
||||
:root {
|
||||
--text-color-light: #1d1d1f; /* 深色文字 */
|
||||
--text-color-dark: #f5f5f7; /* 浅色文字 */
|
||||
--accent-color-light: #007aff; /* iOS 蓝 */
|
||||
--accent-color-dark: #0a84ff; /* 更亮的蓝 */
|
||||
}
|
||||
```
|
||||
|
||||
#### ⚠️ 对比度验证缺失
|
||||
**问题**: 未找到明确的 WCAG 2.2 对比度测试文档或注释
|
||||
|
||||
**必须验证的组件**:
|
||||
1. `Badge` - `text-white` on `--accent-color` (需达到 4.5:1)
|
||||
2. `Button.primary` - `text-white` on `--accent-color`
|
||||
3. `SearchHistoryDropdown` - `text-[var(--text-color-secondary)]` on `--glass-bg`
|
||||
4. `TypeBadges` - 选中态文字与背景对比度
|
||||
|
||||
**建议**:
|
||||
```bash
|
||||
# 使用工具验证
|
||||
npm install --save-dev @a11y/color-contrast-checker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. **动画系统 (Animation System) - 90% 合规**
|
||||
|
||||
#### ✅ 优秀实践
|
||||
```css
|
||||
/* globals.css - 完整的物理感动画库 */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
--transition-fluid: 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
```
|
||||
|
||||
**分析**:
|
||||
- 使用 `cubic-bezier(0.2, 0.8, 0.2, 1)` 实现自然加速/减速
|
||||
- 动画命名清晰(`fade-in`, `slide-up`, `spin-slow`)
|
||||
- 提供了 `.animate-*` 工具类
|
||||
|
||||
#### ⚠️ 性能优化建议
|
||||
**位置**: `components/search/VideoGrid.tsx` - Line 79-80
|
||||
```tsx
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
```
|
||||
|
||||
**问题**: 图片缩放动画未使用 GPU 加速
|
||||
|
||||
**建议**:
|
||||
```tsx
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500 will-change-transform"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧩 组件审计详情
|
||||
|
||||
### A. 核心 UI 组件 (`components/ui/`)
|
||||
|
||||
#### 1. **Button Component** ✅ 优秀
|
||||
**文件**: `components/ui/Button.tsx`
|
||||
**行数**: 48 行 (符合 <150 行规范)
|
||||
|
||||
**优点**:
|
||||
- 严格使用 `rounded-[var(--radius-2xl)]`
|
||||
- 完整的 hover/active 状态
|
||||
- 提供 `primary` 和 `secondary` 变体
|
||||
|
||||
**待改进**:
|
||||
```tsx
|
||||
// 缺少 disabled 状态的 aria-disabled 属性
|
||||
<button
|
||||
disabled={props.disabled}
|
||||
aria-disabled={props.disabled} // ❌ 缺失
|
||||
```
|
||||
|
||||
#### 2. **Card Component** ✅ 优秀
|
||||
**文件**: `components/ui/Card.tsx`
|
||||
**行数**: 39 行
|
||||
|
||||
**优点**:
|
||||
- 完整的玻璃态射效果
|
||||
- 可选的 hover 状态
|
||||
- 支持 onClick 交互
|
||||
|
||||
**待改进**:
|
||||
```tsx
|
||||
// 当 onClick 存在时,应该是语义化的 <button>
|
||||
// 当前使用 <div> + onClick 不符合可访问性标准
|
||||
return (
|
||||
<div onClick={onClick}> {/* ❌ 应该是 <button> */}
|
||||
```
|
||||
|
||||
#### 3. **Input Component** ✅ 优秀
|
||||
**文件**: `components/ui/Input.tsx`
|
||||
**行数**: 49 行
|
||||
|
||||
**优点**:
|
||||
- 使用 `forwardRef` 支持 ref 传递
|
||||
- 完整的 error 状态处理
|
||||
- label 绑定规范
|
||||
|
||||
**待改进**:
|
||||
```tsx
|
||||
// 缺少 focus-visible 样式
|
||||
focus:outline-none
|
||||
focus:border-[var(--accent-color)]
|
||||
// 应该添加
|
||||
focus-visible:ring-2
|
||||
focus-visible:ring-[var(--accent-color)]
|
||||
focus-visible:ring-offset-2
|
||||
```
|
||||
|
||||
#### 4. **Badge Component** ✅ 完美
|
||||
**文件**: `components/ui/Badge.tsx`
|
||||
**行数**: 27 行
|
||||
|
||||
**优点**:
|
||||
- 严格使用 `rounded-[var(--radius-full)]`
|
||||
- 完整的 primary/secondary 变体
|
||||
- 响应式字体大小
|
||||
|
||||
**无需改进** ✨
|
||||
|
||||
---
|
||||
|
||||
### B. 搜索组件 (`components/search/`)
|
||||
|
||||
#### 1. **SearchForm** ⚠️ 需优化
|
||||
**文件**: `components/search/SearchForm.tsx`
|
||||
**行数**: 122 行
|
||||
|
||||
**问题 1**: 清除按钮缺少键盘访问
|
||||
```tsx
|
||||
// Line 89-96
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
// ❌ 缺少键盘事件处理
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleClear();
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
**问题 2**: 搜索历史下拉框需要 ARIA 属性
|
||||
```tsx
|
||||
// 当前的 Input 组件
|
||||
<Input
|
||||
ref={inputRef}
|
||||
// ❌ 缺失
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={showHistory}
|
||||
aria-controls="search-history-listbox"
|
||||
role="combobox"
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. **VideoGrid** ⚠️ 需优化
|
||||
**文件**: `components/search/VideoGrid.tsx`
|
||||
**行数**: 146 行
|
||||
|
||||
**问题 1**: 移动端交互逻辑复杂,可访问性差
|
||||
```tsx
|
||||
// Line 40-52 - 双击逻辑对键盘用户不友好
|
||||
const handleCardClick = (e: React.MouseEvent, videoId: string, videoUrl: string) => {
|
||||
const isMobile = window.innerWidth < 1024;
|
||||
if (isMobile) {
|
||||
if (activeCardId === videoId) {
|
||||
window.location.href = videoUrl;
|
||||
} else {
|
||||
e.preventDefault();
|
||||
setActiveCardId(videoId);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**建议**: 使用 `<button>` 触发详情展开,`<Link>` 用于导航,分离关注点
|
||||
|
||||
**问题 2**: 图片缺少加载失败处理
|
||||
```tsx
|
||||
// Line 73-79
|
||||
<img
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
// ❌ 应添加
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder-image.png';
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 3. **TypeBadges** ⚠️ 需优化
|
||||
**文件**: `components/search/TypeBadges.tsx`
|
||||
**行数**: 162 行 (超过 150 行限制)
|
||||
|
||||
**问题**: 文件过长,违反单一职责原则
|
||||
|
||||
**建议**: 拆分为两个文件
|
||||
```
|
||||
TypeBadges.tsx (主组件,60 行)
|
||||
TypeBadgeItem.tsx (单个徽章,40 行)
|
||||
TypeBadgeList.tsx (徽章列表容器,50 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### C. 播放器组件 (`components/player/`)
|
||||
|
||||
#### 1. **VideoPlayer** ⚠️ 需优化
|
||||
**文件**: `components/player/VideoPlayer.tsx`
|
||||
**行数**: 117 行
|
||||
|
||||
**问题 1**: 错误状态缺少 ARIA live region
|
||||
```tsx
|
||||
// Line 75-91 - 错误提示
|
||||
<div className="text-center text-white max-w-md px-4">
|
||||
{/* ❌ 应添加 */}
|
||||
<div role="alert" aria-live="assertive">
|
||||
<Icons.AlertTriangle size={48} />
|
||||
<p className="text-lg font-semibold mb-2">播放失败</p>
|
||||
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**问题 2**: 返回按钮应该使用 `<Link>` 而不是 `onClick`
|
||||
```tsx
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack} // ❌ 非 SPA 友好
|
||||
// 应该
|
||||
as={Link}
|
||||
href={previousUrl}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. **EpisodeList** ✅ 优秀
|
||||
**文件**: `components/player/EpisodeList.tsx`
|
||||
**行数**: 65 行
|
||||
|
||||
**优点**:
|
||||
- 当前播放集数高亮明确
|
||||
- 使用语义化 `<button>` 元素
|
||||
- 空状态处理完善
|
||||
|
||||
**待改进**: 添加键盘导航
|
||||
```tsx
|
||||
// 添加 arrow key 支持
|
||||
<div
|
||||
role="list"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
// 聚焦上一集
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
// 聚焦下一集
|
||||
}
|
||||
}}
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### D. 历史记录组件
|
||||
|
||||
#### **WatchHistorySidebar** ⚠️ 需重大优化
|
||||
**文件**: `components/history/WatchHistorySidebar.tsx`
|
||||
**行数**: 215 行 (违反 150 行规范)
|
||||
|
||||
**问题 1**: 文件过长,需拆分
|
||||
```
|
||||
WatchHistorySidebar.tsx (主组件 + 布局,80 行)
|
||||
HistoryItem.tsx (单个历史条目,60 行)
|
||||
HistoryEmptyState.tsx (空状态,30 行)
|
||||
```
|
||||
|
||||
**问题 2**: 侧边栏缺少焦点管理
|
||||
```tsx
|
||||
// Line 84-91 - Sidebar
|
||||
<aside
|
||||
className={...}
|
||||
// ❌ 缺失
|
||||
role="complementary"
|
||||
aria-label="观看历史侧边栏"
|
||||
aria-hidden={!isOpen}
|
||||
tabIndex={isOpen ? 0 : -1}
|
||||
>
|
||||
```
|
||||
|
||||
**问题 3**: 删除按钮需要确认对话框
|
||||
```tsx
|
||||
// Line 188-196 - Delete button
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// ❌ 直接删除,应该先确认
|
||||
if (window.confirm('确定要删除这条历史记录吗?')) {
|
||||
removeFromHistory(item.videoId, item.source);
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ♿ 可访问性 (Accessibility) 审计
|
||||
|
||||
### 严重问题 (Critical Issues)
|
||||
|
||||
#### 1. **键盘导航缺失** 🔴 高优先级
|
||||
**影响组件**:
|
||||
- `VideoGrid` (卡片点击)
|
||||
- `TypeBadges` (类型选择)
|
||||
- `SearchHistoryDropdown` (历史选择)
|
||||
|
||||
**问题**: 用户无法仅使用键盘操作这些交互元素
|
||||
|
||||
**解决方案**:
|
||||
```tsx
|
||||
// VideoGrid.tsx - 添加键盘支持
|
||||
<Link
|
||||
href={videoUrl}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// 导航到视频页
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
```
|
||||
|
||||
#### 2. **ARIA 属性不完整** 🔴 高优先级
|
||||
**影响组件**:
|
||||
- `WatchHistorySidebar` (侧边栏角色)
|
||||
- `SearchForm` (combobox 属性)
|
||||
- `VideoPlayer` (错误提示)
|
||||
|
||||
**问题**: 屏幕阅读器无法正确理解组件功能
|
||||
|
||||
**解决方案**:
|
||||
```tsx
|
||||
// WatchHistorySidebar.tsx
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-labelledby="history-sidebar-title"
|
||||
aria-modal="false" // 非模态侧边栏
|
||||
>
|
||||
<h2 id="history-sidebar-title">观看历史</h2>
|
||||
```
|
||||
|
||||
#### 3. **对比度未验证** 🟡 中优先级
|
||||
**需测试的元素**:
|
||||
```
|
||||
1. Badge (primary): #007aff 背景 + white 文字
|
||||
2. Button (disabled): opacity-50 状态
|
||||
3. SearchLoadingAnimation: 进度条色彩
|
||||
4. TypeBadges (selected): 选中态对比度
|
||||
```
|
||||
|
||||
**工具**: 使用 [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
|
||||
#### 4. **focus-visible 样式缺失** 🟡 中优先级
|
||||
**问题**: 所有交互元素仅使用 `focus:outline-none`,键盘用户无法看到焦点
|
||||
|
||||
**解决方案** (全局添加):
|
||||
```css
|
||||
/* globals.css */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 响应式设计审计
|
||||
|
||||
### 优点 ✅
|
||||
1. **完整的断点系统**: `sm:` / `md:` / `lg:` / `xl:` / `2xl:` 使用规范
|
||||
2. **移动端适配优秀**: 触摸区域最小 44x44px (`min-h-[44px]` in Button)
|
||||
3. **字体大小响应式**: `text-sm md:text-base` 模式一致
|
||||
|
||||
### 待改进 ⚠️
|
||||
|
||||
#### 1. **VideoGrid 列数在超大屏幕过多**
|
||||
```tsx
|
||||
// 当前: 2xl:grid-cols-7 (7 列)
|
||||
// 建议: 2xl:grid-cols-6 max-w-[1920px] mx-auto
|
||||
```
|
||||
|
||||
#### 2. **SearchForm 在小屏幕输入框被挤压**
|
||||
```tsx
|
||||
// Line 83-85
|
||||
className="text-lg pr-24 md:pr-32 truncate"
|
||||
// 建议: text-base sm:text-lg
|
||||
```
|
||||
|
||||
#### 3. **WatchHistorySidebar 宽度在小屏过宽**
|
||||
```tsx
|
||||
// Line 84
|
||||
w-[90%] max-w-[420px]
|
||||
// 在 iPhone SE (375px) 上 = 337.5px,接近全屏
|
||||
// 建议: w-[85%] sm:w-[90%]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构建议
|
||||
|
||||
### 1. **文件行数超标** 🔴 必须修复
|
||||
**超过 150 行的文件**:
|
||||
- `TypeBadges.tsx` (162 行) → 拆分为 3 个文件
|
||||
- `WatchHistorySidebar.tsx` (215 行) → 拆分为 3 个文件
|
||||
- `SearchForm.tsx` (122 行) → 接近限制,可考虑提取 hooks
|
||||
- `VideoGrid.tsx` (146 行) → 接近限制,可提取 `VideoCard.tsx`
|
||||
- `PopularFeatures.tsx` (321 行) → 严重超标,拆分为 5 个文件
|
||||
|
||||
### 2. **创建 `hooks/` 目录** 🟡 建议
|
||||
**提取自定义逻辑**:
|
||||
```
|
||||
lib/hooks/
|
||||
useKeyboardNavigation.ts (从 VideoGrid 提取)
|
||||
useFocusTrap.ts (从 WatchHistorySidebar 提取)
|
||||
useMediaQuery.ts (从 TypeBadges 提取)
|
||||
useClickOutside.ts (从 SearchHistoryDropdown 提取)
|
||||
```
|
||||
|
||||
### 3. **创建 `accessibility/` 工具库** 🟡 建议
|
||||
```tsx
|
||||
// lib/accessibility/focus-management.ts
|
||||
export const trapFocus = (container: HTMLElement) => { ... }
|
||||
export const restoreFocus = (element: HTMLElement) => { ... }
|
||||
|
||||
// lib/accessibility/aria-announcer.ts
|
||||
export const announceToScreenReader = (message: string) => {
|
||||
const announcer = document.getElementById('aria-live-announcer');
|
||||
if (announcer) announcer.textContent = message;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 TODO 清单 (优先级排序)
|
||||
|
||||
### 🔴 Critical (必须立即修复)
|
||||
|
||||
#### 1. **修复文件行数超标** (预计 4 小时)
|
||||
- [✅] 拆分 `PopularFeatures.tsx` (321 行 → 拆分为 5 个文件)
|
||||
- [✅] `PopularFeatures.tsx` (主组件,80 行)
|
||||
- [✅] `MovieGrid.tsx` (电影网格,60 行)
|
||||
- [✅] `TagManager.tsx` (标签管理,50 行)
|
||||
- [✅] `MovieCard.tsx` (单个卡片,40 行)
|
||||
- [✅] `InfiniteScroll.tsx` (滚动加载,40 行)
|
||||
|
||||
- [✅] 拆分 `WatchHistorySidebar.tsx` (215 行 → 拆分为 3 个文件)
|
||||
- [✅] `WatchHistorySidebar.tsx` (主组件 + 布局,80 行)
|
||||
- [✅] `HistoryItem.tsx` (单个历史条目,60 行)
|
||||
- [✅] `HistoryEmptyState.tsx` (空状态,30 行)
|
||||
|
||||
- [✅] 拆分 `TypeBadges.tsx` (162 行 → 拆分为 3 个文件)
|
||||
- [✅] `TypeBadges.tsx` (主组件,60 行)
|
||||
- [✅] `TypeBadgeItem.tsx` (单个徽章,40 行)
|
||||
- [✅] `TypeBadgeList.tsx` (徽章列表容器,50 行)
|
||||
|
||||
#### 2. **添加全局 focus-visible 样式** (预计 30 分钟)
|
||||
- [✅] 在 `globals.css` 中添加全局焦点样式
|
||||
```css
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
```
|
||||
- [✅] 移除所有组件的 `focus:outline-none`(仅保留必要的)
|
||||
- [✅] 测试所有交互元素的键盘可见性
|
||||
|
||||
#### 3. **修复 Card 组件的语义化问题** (预计 1 小时)
|
||||
- [✅] 当 `onClick` 存在时,将 `<div>` 改为 `<button>`
|
||||
- [✅] 为 `button` 类型的 Card 添加 `type="button"`
|
||||
- [✅] 更新所有使用 Card 的地方,确保样式一致
|
||||
|
||||
#### 4. **WCAG 对比度验证** (预计 2 小时)
|
||||
- [✅] 安装 `@a11y/color-contrast-checker`
|
||||
- [✅] 测试所有文字与背景的对比度
|
||||
- [✅] Badge (primary & secondary)
|
||||
- [✅] Button (primary, secondary, disabled)
|
||||
- [✅] SearchLoadingAnimation 进度条
|
||||
- [✅] TypeBadges 选中态
|
||||
- [✅] 创建 `CONTRAST_TEST_RESULTS.md` 文档
|
||||
- [✅] 调整不符合标准的色彩值
|
||||
|
||||
---
|
||||
|
||||
### 🟡 High Priority (高优先级,2 周内完成)
|
||||
|
||||
#### 5. **完善 ARIA 属性** (预计 3 小时)
|
||||
- [✅] **SearchForm.tsx**
|
||||
- [✅] 添加 `role="combobox"`
|
||||
- [✅] 添加 `aria-expanded={showHistory}`
|
||||
- [✅] 添加 `aria-controls="search-history-listbox"`
|
||||
- [✅] 添加 `aria-autocomplete="list"`
|
||||
|
||||
- [✅] **WatchHistorySidebar.tsx**
|
||||
- [✅] 添加 `role="complementary"`
|
||||
- [✅] 添加 `aria-labelledby="history-sidebar-title"`
|
||||
- [✅] 添加 `aria-hidden={!isOpen}`
|
||||
- [✅] 实现焦点陷阱 (focus trap)
|
||||
|
||||
- [✅] **VideoPlayer.tsx**
|
||||
- [✅] 错误提示添加 `role="alert"`
|
||||
- [✅] 添加 `aria-live="assertive"`
|
||||
|
||||
- [✅] **VideoGrid.tsx**
|
||||
- [✅] 添加 `role="list"` 到网格容器
|
||||
- [✅] 添加 `role="listitem"` 到每个卡片
|
||||
|
||||
#### 6. **添加键盘导航支持** (预计 4 小时) ✅ **已完成**
|
||||
- [x] **VideoGrid.tsx**
|
||||
- [x] 添加 `onKeyDown` 处理 Enter/Space 键
|
||||
- [x] 实现方向键导航(上下左右)
|
||||
- [x] 添加 `tabIndex={0}` 到每个卡片
|
||||
- [x] 添加 `aria-label` 描述性标签
|
||||
- [x] 添加视觉焦点指示器 (ring)
|
||||
|
||||
- [x] **TypeBadges.tsx**
|
||||
- [x] 添加 `onKeyDown` 处理 Enter/Space 键
|
||||
- [x] 实现方向键在徽章间切换
|
||||
- [x] 添加 `role="group"` 和 `aria-label="类型筛选"`
|
||||
- [x] 添加 `aria-pressed` 状态
|
||||
- [x] 添加视觉焦点指示器
|
||||
- [x] 支持移动端滚动到视图
|
||||
|
||||
- [x] **SearchHistoryDropdown.tsx**
|
||||
- [x] 添加方向键上下选择
|
||||
- [x] 添加 Escape 键关闭下拉框
|
||||
- [x] 添加 Home/End 键跳转首尾
|
||||
- [x] 添加 `aria-selected` 状态
|
||||
- [x] 添加视觉焦点指示器
|
||||
- [x] 支持 Enter/Space 键选择
|
||||
|
||||
- [x] **EpisodeList.tsx**
|
||||
- [x] 添加方向键上下切换集数
|
||||
- [x] 添加 `role="radiogroup"`
|
||||
- [x] 当前集数添加 `aria-current="true"`
|
||||
- [x] 添加 `role="radio"` 到每个按钮
|
||||
- [x] 添加 `aria-checked` 状态
|
||||
- [x] 添加 `focus-visible` 样式
|
||||
- [x] 支持自动滚动到焦点项
|
||||
|
||||
**实现细节**:
|
||||
- ✅ 创建了 `useKeyboardNavigation` 自定义 Hook,统一管理键盘导航逻辑
|
||||
- ✅ 支持三种导航模式:`horizontal`(水平)、`vertical`(垂直)、`grid`(网格)
|
||||
- ✅ 网格导航自动检测列数,支持响应式布局
|
||||
- ✅ 所有交互元素均添加 `tabIndex={0}` 支持键盘聚焦
|
||||
- ✅ 使用 `focus-visible:ring-2` 提供清晰的视觉焦点反馈
|
||||
- ✅ 完整的 ARIA 属性支持,符合 WAI-ARIA 标准
|
||||
- ✅ 焦点项自动滚动到视图内,优化用户体验
|
||||
|
||||
#### 7. **图片加载优化** (预计 2 小时)
|
||||
- [x] 创建 `public/placeholder-poster.svg` 占位图
|
||||
- [x] **VideoGrid.tsx** - 添加 `onError` 处理
|
||||
```tsx
|
||||
<img
|
||||
src={video.vod_pic}
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = '/placeholder-poster.svg';
|
||||
}}
|
||||
/>
|
||||
```
|
||||
- [x] **WatchHistorySidebar.tsx** - 同样添加 `onError`
|
||||
- [x] **PopularFeatures.tsx** - 添加 `onError`
|
||||
|
||||
#### 8. **性能优化 - GPU 加速** (预计 1 小时)
|
||||
- [x] 给所有 hover scale 动画添加 `will-change-transform`
|
||||
- [x] 给 fixed/sticky 元素添加 `transform: translateZ(0)`
|
||||
- [x] 优化 `SearchLoadingAnimation` 的 shimmer 动画
|
||||
|
||||
---
|
||||
|
||||
### 🟢 Medium Priority (中优先级,1 个月内完成)
|
||||
|
||||
#### 9. **响应式优化** (预计 2 小时)
|
||||
- [x] **VideoGrid.tsx**
|
||||
- [x] 修改 `2xl:grid-cols-7` → `2xl:grid-cols-6`
|
||||
- [x] 添加 `max-w-[1920px] mx-auto` 限制最大宽度
|
||||
|
||||
- [x] **SearchForm.tsx**
|
||||
- [x] 修改 `text-lg` → `text-base sm:text-lg`
|
||||
- [x] 调整移动端按钮内边距
|
||||
|
||||
- [x] **WatchHistorySidebar.tsx**
|
||||
- [x] 修改 `w-[90%]` → `w-[85%] sm:w-[90%]`
|
||||
|
||||
#### 10. **创建辅助 Hooks** (预计 3 小时)
|
||||
- [x] 创建 `lib/hooks/useKeyboardNavigation.ts`
|
||||
```tsx
|
||||
export function useKeyboardNavigation(items: any[], onSelect: (item: any) => void) {
|
||||
// 实现方向键导航逻辑
|
||||
}
|
||||
```
|
||||
- [x] 创建 `lib/hooks/useFocusTrap.ts`
|
||||
```tsx
|
||||
export function useFocusTrap(containerRef: RefObject<HTMLElement>) {
|
||||
// 实现焦点陷阱
|
||||
}
|
||||
```
|
||||
- [x] 创建 `lib/hooks/useMediaQuery.ts`
|
||||
```tsx
|
||||
export function useMediaQuery(query: string) {
|
||||
// 实现媒体查询 hook
|
||||
}
|
||||
```
|
||||
- [x] 创建 `lib/hooks/useClickOutside.ts`
|
||||
```tsx
|
||||
export function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {
|
||||
// 点击外部关闭
|
||||
}
|
||||
```
|
||||
|
||||
#### 11. **添加确认对话框组件** (预计 2 小时) ✅ **已完成**
|
||||
- [x] 创建 `components/ui/ConfirmDialog.tsx`
|
||||
```tsx
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
```
|
||||
- [x] 在 `WatchHistorySidebar` 中使用
|
||||
- [x] 在删除历史时显示确认对话框
|
||||
|
||||
**实现细节**:
|
||||
- ✅ 创建了完整的 `ConfirmDialog` 组件,遵循 Liquid Glass 设计系统
|
||||
- ✅ 支持 danger/warning/info 三种变体
|
||||
- ✅ 完整的 ARIA 属性支持 (`alertdialog`, `aria-modal`, `aria-labelledby`, `aria-describedby`)
|
||||
- ✅ 键盘支持:Escape 键关闭,焦点管理
|
||||
- ✅ 在 `WatchHistorySidebar` 中集成,用于删除单个历史和清空全部历史
|
||||
- ✅ 更新 `Button` 组件支持 `forwardRef`,增强可访问性
|
||||
|
||||
#### 12. **创建可访问性工具库** (预计 3 小时) ✅ **已完成**
|
||||
- [x] 创建 `lib/accessibility/focus-management.ts`
|
||||
- [x] `trapFocus(container: HTMLElement)`
|
||||
- [x] `restoreFocus(element: HTMLElement)`
|
||||
- [x] `getFocusableElements(container: HTMLElement)`
|
||||
- [x] `saveFocus()` - 额外添加的实用函数
|
||||
|
||||
- [x] 创建 `lib/accessibility/aria-announcer.ts`
|
||||
- [x] `announceToScreenReader(message: string)`
|
||||
- [x] `announceError(message: string)` - 额外添加
|
||||
- [x] `announceSuccess(message: string)` - 额外添加
|
||||
- [x] `announceLoading(message: string)` - 额外添加
|
||||
- [x] `clearAnnouncer()` - 额外添加
|
||||
- [x] 在 `layout.tsx` 中添加 live region
|
||||
```tsx
|
||||
<div
|
||||
id="aria-live-announcer"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="sr-only"
|
||||
/>
|
||||
```
|
||||
|
||||
- [x] 创建 `lib/accessibility/keyboard-utils.ts`
|
||||
- [x] `isActivationKey(event: KeyboardEvent)`
|
||||
- [x] `handleEscape(callback: () => void)`
|
||||
- [x] `hasModifierKey(event: KeyboardEvent)` - 额外添加
|
||||
- [x] `getArrowKeyDirection(event: KeyboardEvent)` - 额外添加
|
||||
- [x] `preventDefaultForKeys(event: KeyboardEvent, keys: string[])` - 额外添加
|
||||
- [x] `createKeyboardHandler(handlers: Record<string, Function>)` - 额外添加
|
||||
|
||||
- [x] 创建 `lib/accessibility/index.ts` - 统一导出所有工具
|
||||
|
||||
**实现细节**:
|
||||
- ✅ **focus-management.ts**: 完整的焦点管理工具,包括焦点陷阱、焦点恢复、获取可聚焦元素
|
||||
- ✅ **aria-announcer.ts**: 屏幕阅读器播报工具,支持不同优先级(polite/assertive)
|
||||
- ✅ **keyboard-utils.ts**: 键盘交互工具,涵盖激活键、Escape 键、方向键等
|
||||
- ✅ 在 `app/layout.tsx` 中添加了 ARIA live region
|
||||
- ✅ 在 `globals.css` 中添加了 `.sr-only` 工具类
|
||||
- ✅ 所有工具都有完整的 TypeScript 类型定义和中英文注释
|
||||
- ✅ 创建了统一的导出文件 `lib/accessibility/index.ts`,简化导入
|
||||
|
||||
---
|
||||
|
||||
### 🔵 Low Priority (低优先级,时间允许时完成)
|
||||
|
||||
#### 13. **统一圆角使用方式** (预计 1 小时)
|
||||
- [✅] 全局搜索 `style={{ borderRadius: 'var(--radius-`
|
||||
- [✅] 统一改为 `className="rounded-[var(--radius-2xl)]"`
|
||||
- [✅] 或者相反,全部统一使用 `style`
|
||||
|
||||
#### 14. **添加暗色模式动画过渡** (预计 1 小时)
|
||||
- [✅] 在 `ThemeProvider.tsx` 中添加 View Transition API
|
||||
```tsx
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => {
|
||||
applyTheme(newTheme);
|
||||
});
|
||||
} else {
|
||||
applyTheme(newTheme);
|
||||
}
|
||||
```
|
||||
|
||||
#### 15. **优化 SearchLoadingAnimation** (预计 1 小时)
|
||||
- [ ] 添加暂停/恢复功能
|
||||
- [ ] 添加动画完成回调
|
||||
- [ ] 优化 shimmer 效果性能
|
||||
|
||||
#### 16. **为 Badge 添加 icon 支持** (预计 30 分钟)
|
||||
- [ ] 在 `Badge.tsx` 中添加 `icon` prop
|
||||
```tsx
|
||||
interface BadgeProps {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
```
|
||||
- [ ] 更新文档
|
||||
|
||||
#### 17. **创建 Storybook** (预计 8 小时)
|
||||
- [ ] 安装 Storybook 7.x
|
||||
- [ ] 为所有 UI 组件创建 stories
|
||||
- [ ] 添加 A11y addon
|
||||
- [ ] 配置主题切换
|
||||
|
||||
---
|
||||
|
||||
## 📊 合规性评分总结
|
||||
|
||||
| 类别 | 得分 | 状态 |
|
||||
|------|------|------|
|
||||
| **玻璃态射效果** | 95/100 | ✅ 优秀 |
|
||||
| **圆角规范** | 80/100 | ⚠️ 需改进 |
|
||||
| **色彩系统** | 75/100 | ⚠️ 需验证 |
|
||||
| **动画系统** | 90/100 | ✅ 优秀 |
|
||||
| **响应式设计** | 85/100 | ✅ 良好 |
|
||||
| **可访问性** | 60/100 | 🔴 需重大改进 |
|
||||
| **架构规范** | 70/100 | ⚠️ 部分超标 |
|
||||
|
||||
**总体评分**: **79/100** (良好)
|
||||
|
||||
---
|
||||
|
||||
## 🎓 最佳实践建议
|
||||
|
||||
### 1. **建立组件审查清单**
|
||||
每个新组件提交前检查:
|
||||
```markdown
|
||||
- [ ] 文件行数 < 150 行
|
||||
- [ ] 圆角使用 var(--radius-2xl) 或 var(--radius-full)
|
||||
- [ ] 包含完整的 ARIA 属性
|
||||
- [ ] 支持键盘导航
|
||||
- [ ] WCAG 2.2 AA 对比度达标
|
||||
- [ ] 添加 focus-visible 样式
|
||||
- [ ] 包含 PropTypes 或 TypeScript 接口
|
||||
- [ ] 响应式断点测试通过
|
||||
```
|
||||
|
||||
### 2. **使用 pre-commit hooks**
|
||||
```bash
|
||||
# .husky/pre-commit
|
||||
npm run lint
|
||||
npm run type-check
|
||||
npm run test:a11y
|
||||
```
|
||||
|
||||
### 3. **定期进行可访问性审计**
|
||||
```bash
|
||||
# 安装工具
|
||||
npm install --save-dev @axe-core/react
|
||||
npm install --save-dev eslint-plugin-jsx-a11y
|
||||
|
||||
# 运行审计
|
||||
npm run audit:a11y
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 参考资源
|
||||
|
||||
1. **Liquid Glass Design System**: 项目根目录的系统提示词
|
||||
2. **WCAG 2.2**: https://www.w3.org/WAI/WCAG22/quickref/
|
||||
3. **ARIA Authoring Practices**: https://www.w3.org/WAI/ARIA/apg/
|
||||
4. **React Accessibility**: https://react.dev/learn/accessibility
|
||||
5. **Tailwind CSS Accessibility**: https://tailwindcss.com/docs/screen-readers
|
||||
|
||||
---
|
||||
|
||||
## ✅ 结论
|
||||
|
||||
KVideo 项目在视觉设计和核心 UI 实现上展现了**高水准的专业性**,Liquid Glass 设计系统的核心理念得到了充分体现。然而,**可访问性和代码架构方面存在明显改进空间**。
|
||||
|
||||
**建议优先级**:
|
||||
1. **立即修复**: 文件拆分、focus-visible、对比度验证 (1-2 周)
|
||||
2. **短期完成**: ARIA 属性、键盘导航 (2-4 周)
|
||||
3. **中期改进**: Hooks 提取、确认对话框、图片优化 (1-2 月)
|
||||
4. **长期优化**: Storybook、完整文档、自动化测试 (2-3 月)
|
||||
|
||||
遵循此审计报告,项目可在 **3 个月内达到 95+ 分的合规度**,成为 Liquid Glass 设计系统的**标杆实现**。
|
||||
|
||||
---
|
||||
|
||||
**审计人**: GitHub Copilot (Liquid Glass Design Architect)
|
||||
**审计版本**: v1.0
|
||||
**下次审计建议日期**: 2025-12-18
|
||||
@@ -1,29 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
|
||||
interface SearchLoadingAnimationProps {
|
||||
currentSource?: string;
|
||||
checkedSources?: number;
|
||||
totalSources?: number;
|
||||
isPaused?: boolean;
|
||||
onComplete?: (checkedSources: number, totalSources: number) => void;
|
||||
}
|
||||
|
||||
export function SearchLoadingAnimation({
|
||||
currentSource,
|
||||
checkedSources = 0,
|
||||
totalSources = 16,
|
||||
isPaused = false,
|
||||
onComplete,
|
||||
}: SearchLoadingAnimationProps) {
|
||||
const [dots, setDots] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const dotInterval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(dotInterval);
|
||||
}, []);
|
||||
const dotIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasCalledComplete = useRef(false);
|
||||
|
||||
// Calculate progress (0-100%)
|
||||
const progress = totalSources > 0 ? (checkedSources / totalSources) * 100 : 0;
|
||||
const isComplete = progress >= 100;
|
||||
|
||||
// Animation pause/resume logic
|
||||
useEffect(() => {
|
||||
if (isPaused || isComplete) {
|
||||
if (dotIntervalRef.current) {
|
||||
clearInterval(dotIntervalRef.current);
|
||||
dotIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
dotIntervalRef.current = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
if (dotIntervalRef.current) {
|
||||
clearInterval(dotIntervalRef.current);
|
||||
dotIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isPaused, isComplete]);
|
||||
|
||||
// Call onComplete callback when animation finishes
|
||||
useEffect(() => {
|
||||
if (isComplete && onComplete && !hasCalledComplete.current) {
|
||||
hasCalledComplete.current = true;
|
||||
// Small delay to allow animation to settle
|
||||
const timeout = setTimeout(() => {
|
||||
onComplete(checkedSources, totalSources);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isComplete, onComplete, checkedSources, totalSources]);
|
||||
|
||||
const statusText = `${checkedSources}/${totalSources} 个源`;
|
||||
|
||||
return (
|
||||
@@ -55,26 +90,40 @@ export function SearchLoadingAnimation({
|
||||
className="h-1 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-[var(--radius-full)]"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative will-change-[width] rounded-[var(--radius-full)]"
|
||||
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative will-change-transform rounded-[var(--radius-full)]"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
>
|
||||
{/* Shimmer Effect - Optimized for GPU */}
|
||||
{/* Shimmer Effect - Optimized for GPU with contain for better performance */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer"
|
||||
className="absolute inset-0 animate-shimmer"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.3) 50%, transparent 100%)',
|
||||
willChange: 'transform',
|
||||
transform: 'translateZ(0)'
|
||||
transform: 'translateZ(0)',
|
||||
contain: 'strict'
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Info - Real-time count */}
|
||||
{/* Progress Info - Real-time count with pause indicator */}
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-[var(--text-color-secondary)]">
|
||||
<span>{statusText}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{statusText}
|
||||
{isPaused && (
|
||||
<span className="px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--glass-bg)] text-[10px]">
|
||||
已暂停
|
||||
</span>
|
||||
)}
|
||||
{isComplete && (
|
||||
<span className="px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white text-[10px]">
|
||||
完成
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+26
-1
@@ -4,14 +4,37 @@ interface BadgeProps {
|
||||
children: React.ReactNode;
|
||||
variant?: 'primary' | 'secondary';
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'primary', className = '' }: BadgeProps) {
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'primary',
|
||||
className = '',
|
||||
icon,
|
||||
iconPosition = 'left'
|
||||
}: BadgeProps) {
|
||||
const variants = {
|
||||
primary: "bg-[var(--accent-color)] text-white shadow-[var(--shadow-sm)]",
|
||||
secondary: "bg-[var(--glass-bg)] backdrop-blur-[10px] [-webkit-backdrop-filter:blur(10px)] border border-[var(--glass-border)] text-[var(--text-color)]",
|
||||
};
|
||||
|
||||
const iconElement = icon && (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center transition-transform duration-200 ${
|
||||
iconPosition === 'left' ? 'mr-1' : 'ml-1'
|
||||
}`}
|
||||
style={{
|
||||
width: '0.875em',
|
||||
height: '0.875em',
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`
|
||||
@@ -24,7 +47,9 @@ export function Badge({ children, variant = 'primary', className = '' }: BadgePr
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{icon && iconPosition === 'left' && iconElement}
|
||||
{children}
|
||||
{icon && iconPosition === 'right' && iconElement}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user