This commit is contained in:
Ho Ngoc Hai
2026-05-23 18:37:02 +07:00
parent f15d91ee29
commit 76d75c753b
3993 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,355 @@
---
name: react-ui-components
description: UI component patterns với React Aria, Radix UI, và Storybook cho GoodGo. Use for accessible components, composition patterns, animations, và component documentation.
compatibility: "react-aria>=3, @radix-ui/*>=1, storybook>=10, framer-motion>=12"
metadata:
author: Velik Ho
version: "1.0"
---
# React UI Components / Components UI React
## When to Use This Skill / Khi Nào Sử Dụng
Use this skill when:
- Creating reusable components / Tạo components tái sử dụng
- Implementing accessible UI / Triển khai UI accessible
- Writing Storybook stories / Viết Storybook stories
- Adding animations / Thêm animations
- Styling with CVA & Tailwind / Styling với CVA & Tailwind
## Core Principles / Nguyên Tắc Cốt Lõi
1. **Accessibility First**: Use React Aria/Radix for keyboard & screen reader support
2. **Composition over Props**: Build flexible components via composition
3. **Variant-based Styling**: Use CVA for maintainable variant logic
4. **Document with Stories**: Every component needs Storybook stories
## Key Patterns / Mẫu Chính
### Component Structure
```
features/shared/components/
├── Button/
│ ├── Button.tsx # Main component
│ ├── Button.stories.tsx # Storybook stories
│ ├── Button.test.tsx # Unit tests
│ └── index.ts # Barrel export
└── index.ts # Public exports
```
### CVA for Variants (Class Variance Authority)
```tsx
// components/Button/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { clsx } from 'clsx';
/**
* EN: Button variants using CVA
* VI: Variants cho Button sử dụng CVA
*/
const buttonVariants = cva(
// Base styles
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-accent-primary text-white hover:bg-accent-primary/90',
secondary: 'bg-bg-secondary text-text-primary hover:bg-bg-tertiary',
ghost: 'hover:bg-bg-secondary',
destructive: 'bg-accent-error text-white hover:bg-accent-error/90',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
lg: 'h-12 px-6 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
export function Button({
className,
variant,
size,
isLoading,
children,
...props
}: ButtonProps) {
return (
<button
className={clsx(buttonVariants({ variant, size }), className)}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? <Spinner /> : children}
</button>
);
}
```
### React Aria Pattern
```tsx
// components/Switch/Switch.tsx
import { useToggleState } from 'react-stately';
import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria';
import { useRef } from 'react';
/**
* EN: Accessible switch using React Aria
* VI: Switch accessible sử dụng React Aria
*/
export function Switch({ children, ...props }) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement>(null);
const { inputProps } = useSwitch(props, state, ref);
const { focusProps, isFocusVisible } = useFocusRing();
return (
<label className="flex items-center gap-2 cursor-pointer">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} />
</VisuallyHidden>
<div
className={clsx(
'w-10 h-6 rounded-full transition-colors',
state.isSelected ? 'bg-accent-primary' : 'bg-bg-tertiary',
isFocusVisible && 'ring-2 ring-border-focus'
)}
>
<div
className={clsx(
'w-5 h-5 rounded-full bg-white shadow-md transition-transform',
state.isSelected && 'translate-x-4'
)}
/>
</div>
{children}
</label>
);
}
```
### Radix UI Pattern
```tsx
// components/Dialog/Dialog.tsx
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { clsx } from 'clsx';
/**
* EN: Dialog using Radix primitives
* VI: Dialog sử dụng Radix primitives
*/
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export function DialogContent({ className, children, ...props }) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm" />
<DialogPrimitive.Content
className={clsx(
'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'bg-bg-elevated rounded-xl shadow-xl p-6 max-w-md w-full',
'focus:outline-none',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
);
}
export const DialogTitle = DialogPrimitive.Title;
export const DialogDescription = DialogPrimitive.Description;
export const DialogClose = DialogPrimitive.Close;
```
### Framer Motion Animations
```tsx
// components/FadeIn/FadeIn.tsx
import { motion } from 'framer-motion';
/**
* EN: Fade-in animation wrapper
* VI: Wrapper animation fade-in
*/
export function FadeIn({ children, delay = 0 }) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay,
ease: [0.4, 0, 0.2, 1], // ease-out
}}
>
{children}
</motion.div>
);
}
// Stagger animation for lists
export function StaggerList({ children }) {
return (
<motion.ul
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.05 } },
}}
>
{children}
</motion.ul>
);
}
export function StaggerItem({ children }) {
return (
<motion.li
variants={{
hidden: { opacity: 0, x: -10 },
visible: { opacity: 1, x: 0 },
}}
>
{children}
</motion.li>
);
}
```
### Storybook Stories
```tsx
// components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
/**
* EN: Button component stories
* VI: Stories cho Button component
*/
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'destructive'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
children: 'Primary Button',
variant: 'primary',
},
};
export const Secondary: Story = {
args: {
children: 'Secondary Button',
variant: 'secondary',
},
};
export const Loading: Story = {
args: {
children: 'Loading...',
isLoading: true,
},
};
// Interactive story with play function
export const Interactive: Story = {
args: { children: 'Click me' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
},
};
```
## Common Mistakes / Lỗi Thường Gặp
### 1. Not Using Accessible Primitives
```tsx
// ❌ BAD: Custom toggle without accessibility
<div onClick={toggle} className="switch" />
// ✅ GOOD: Use React Aria or Radix
<Switch isSelected={on} onChange={toggle} />
```
### 2. Hardcoding Styles Instead of Variants
```tsx
// ❌ BAD: Conditional className logic scattered
<button className={isLarge ? 'h-12 px-6' : 'h-10 px-4'} />
// ✅ GOOD: Use CVA variants
<button className={buttonVariants({ size: isLarge ? 'lg' : 'md' })} />
```
### 3. Missing Focus Indicators
```tsx
// ❌ BAD: Removes focus outline
.button:focus { outline: none; }
// ✅ GOOD: Custom focus ring
.button:focus-visible {
outline: none;
ring: 2px solid var(--border-focus);
}
```
## Quick Reference / Tham Chiếu Nhanh
| Library | Use Case |
|---------|----------|
| CVA | Variant-based styling |
| clsx | Conditional classNames |
| React Aria | Low-level accessible hooks |
| Radix UI | High-level accessible primitives |
| Framer Motion | Animations |
| Storybook | Component documentation |
## Resources / Tài Nguyên
- [React Accessibility](../react-accessibility/SKILL.md) - A11y patterns
- [Tailwind Design System](../tailwind-design-system/SKILL.md) - Styling patterns
- [React Testing Patterns](../react-testing-patterns/SKILL.md) - Component testing
- [React Aria Docs](https://react-spectrum.adobe.com/react-aria/)
- [Radix UI Docs](https://www.radix-ui.com/)
- [Storybook Docs](https://storybook.js.org/)