8.8 KiB
8.8 KiB
name, description, compatibility, metadata
| name | description | compatibility | metadata | ||||
|---|---|---|---|---|---|---|---|
| react-ui-components | UI component patterns với React Aria, Radix UI, và Storybook cho GoodGo. Use for accessible components, composition patterns, animations, và component documentation. | react-aria>=3, @radix-ui/*>=1, storybook>=10, framer-motion>=12 |
|
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
- Accessibility First: Use React Aria/Radix for keyboard & screen reader support
- Composition over Props: Build flexible components via composition
- Variant-based Styling: Use CVA for maintainable variant logic
- 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)
// 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
// 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
// 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
// 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
// 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
// ❌ 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
// ❌ 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
// ❌ 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 - A11y patterns
- Tailwind Design System - Styling patterns
- React Testing Patterns - Component testing
- React Aria Docs
- Radix UI Docs
- Storybook Docs