xác định props, variants, states. component này làm được những gì? ai sẽ dùng nó?
tạo component với storybook
bài hướng dẫn này mô tả đầy đủ quy trình tạo một react component trong storybook, sau đó tái sử dụng nó trong trang astro. mỗi bước đều có code mẫu và giải thích — bạn có thể làm thủ công theo từng bước.
chúng ta sẽ tạo component Badge — một nhãn nhỏ hiển thị trạng thái. component này đã có sẵn trong thư viện, nhưng ta sẽ làm lại từ đầu để hiểu quy trình.
1. cấu trúc thư mục
mỗi component sống trong thư mục riêng, theo mẫu:
packages/core/src/components/{Name}/
├── {name}.css # style — chỉ dùng var(--token)
├── {Name}.tsx # react component
├── {Name}.stories.tsx # storybook stories
├── {Name}.test.tsx # unit test
└── {name}.docs.mdx # tài liệu storybook (tùy chọn) quy ước đặt tên:
- thư mục + file .tsx: PascalCase —
Badge/,Badge.tsx - file .css + .docs.mdx: kebab-case —
badge.css,badge.docs.mdx - không viết tắt trừ khi là chuẩn ngành (btn, sm/md/lg, ui)
2. tạo file css
file packages/core/src/components/Badge/badge.css:
/* Badge — 1thay component */
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 2px 10px;
border-radius: var(--radius-full);
font: var(--text-ui-sm);
text-transform: lowercase;
white-space: nowrap;
}
.badge-neutral { background: var(--color-neutral-200); color: var(--color-neutral-700); }
.badge-brand { background: var(--color-brand-600); color: var(--text-inverse); }
.badge-success { background: var(--color-success); color: var(--text-inverse); }
.badge-warning { background: var(--color-warning); color: var(--text-inverse); }
.badge-error { background: var(--color-error); color: var(--text-inverse); } nguyên tắc quan trọng nhất: mọi giá trị css phải là var(--token). không hardcode px, màu hex, hay font family. token được định nghĩa trong packages/core/src/tokens/tokens.css — đó là nguồn chân lý duy nhất.
danh sách token thường dùng:
| loại | token | ví dụ |
|---|---|---|
| màu brand | --color-brand-50 → --color-brand-900 | var(--color-brand-600) |
| màu neutral | --color-neutral-50 → --color-neutral-900 | var(--color-neutral-200) |
| màu ngữ nghĩa | --color-success, --color-warning, --color-error | var(--color-error) |
| text | --text-primary, --text-secondary, --text-muted, --text-inverse | var(--text-muted) |
| surface | --surface-bg, --surface-dim, --surface-raised | var(--surface-dim) |
| spacing | --space-xs → --space-3xl | var(--space-md) |
| radius | --radius-sm, --radius-md, --radius-lg, --radius-full | var(--radius-full) |
| font | --text-hero → --text-ui-sm (type scale) | var(--text-ui-sm) |
| border | --border-color, --border-width | var(--border-color) |
| motion | --motion-fast, --motion-normal, --motion-slow | var(--motion-fast) |
| z-index | --z-dropdown, --z-sticky, --z-modal, --z-toast | var(--z-sticky) |
3. tạo file react component
file packages/core/src/components/Badge/Badge.tsx:
import type { ReactNode } from 'react';
import './badge.css';
type BadgeVariant = 'neutral' | 'brand' | 'success' | 'warning' | 'error';
interface BadgeProps {
/** noi dung hien thi ben trong badge. */
children: ReactNode;
/** mau sac: neutral, brand, success, warning, error. @default 'neutral' */
variant?: BadgeVariant;
/** CSS class bo sung. */
className?: string;
}
export function Badge({ children, variant = 'neutral', className = '' }: BadgeProps) {
return (
<span className={`badge badge-${variant}${className ? ` ${className}` : ''}`}>
{children}
</span>
);
} những điểm cần lưu ý:
- export named —
export function Badge, không dùngexport default - luôn export
Propsinterface — để astro và storybook có thể dùng type - nhận
classNameprop — để người dùng có thể override style khi cần - import css cùng cấp —
import './badge.css' - dùng jsdoc comment cho mỗi prop — storybook sẽ tự động hiển thị
- đặt giá trị mặc định cho prop tùy chọn —
variant = 'neutral'
4. tạo file storybook stories
file packages/core/src/components/Badge/Badge.stories.tsx:
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Badge } from './Badge';
const meta: Meta<typeof Badge> = {
title: 'DASHBOARD/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['neutral', 'brand', 'success', 'warning', 'error']
},
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/embed?embed_host=storybook&url=https://www.figma.com/design/OAinJNCk0DZ1p1R1xyPfvx/1thay',
},
},
};
export default meta;
type Story = StoryObj<typeof Badge>;
// 5 variants
export const Neutral: Story = { args: { variant: 'neutral', children: 'neutral' } };
export const Brand: Story = { args: { variant: 'brand', children: 'brand' } };
export const Success: Story = { args: { variant: 'success', children: 'success' } };
export const Warning: Story = { args: { variant: 'warning', children: 'warning' } };
export const Error: Story = { args: { variant: 'error', children: 'error' } };
// all variants side by side
export const All: Story = {
render: () => (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<Badge variant="neutral">trung tinh</Badge>
<Badge variant="brand">thuong hieu</Badge>
<Badge variant="success">thanh cong</Badge>
<Badge variant="warning">canh bao</Badge>
<Badge variant="error">loi</Badge>
</div>
),
};
// dark mode
export const DarkMode: Story = {
render: () => (
<div data-theme="dark"
style={{
background: 'var(--surface-bg)',
padding: 'var(--space-lg)',
borderRadius: 'var(--radius-lg)',
display: 'flex', gap: 'var(--space-sm)', flexWrap: 'wrap'
}}
>
<Badge variant="neutral">neutral</Badge>
<Badge variant="brand">brand</Badge>
<Badge variant="success">success</Badge>
<Badge variant="warning">warning</Badge>
<Badge variant="error">error</Badge>
</div>
),
}; giải thích:
CSF 3 format— dùngMeta<typeof Badge>vàStoryObj<typeof Badge>. không dùngTemplate.bind()hayComponentStory— đó là cú pháp cũtitle— category trong storybook sidebar. xem danh sách category ở mục 8 bên dướitags: ['autodocs']— tự động tạo trang docs cho componentargTypes— định nghĩa control panel trong storybook. dùngselectcho enum,booleancho toggle,textcho string- mỗi story — một trạng thái hoặc biến thể của component. đặt tên story bằng PascalCase
- tối thiểu: mỗi variant một story, dark mode story, all brands story, interaction test nếu có tương tác
5. tạo file unit test
file packages/core/src/components/Badge/Badge.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Badge } from './Badge';
describe('Badge', () => {
it('renders children text', () => {
render(<Badge>hoan thanh</Badge>);
expect(screen.getByText('hoan thanh')).toBeInTheDocument();
});
it('applies variant class', () => {
render(<Badge variant="success">ok</Badge>);
const el = screen.getByText('ok');
expect(el.className).toContain('badge-success');
});
it('defaults to neutral variant', () => {
render(<Badge>mac dinh</Badge>);
const el = screen.getByText('mac dinh');
expect(el.className).toContain('badge-neutral');
});
}); nguyên tắc test:
- test render cơ bản — component có hiển thị đúng không?
- test props — mỗi variant/size có áp dụng đúng class không?
- test default — khi không truyền prop thì giá trị mặc định có đúng không?
- test tương tác (nếu có) — click, focus, keyboard
- không test implementation detail — test những gì người dùng thấy, không test code nội bộ
6. verify
chạy từng bước để kiểm tra component hoạt động:
# 1. chạy test
npm test
# 2. build storybook — kiểm tra không lỗi build
npm run build-storybook
# 3. dev storybook — xem trực quan component
npm run storybook
# mở http://localhost:6006
# 4. kiểm tra lint
npm run lint nếu tất cả pass, component đã sẵn sàng để dùng trong astro.
7. tái sử dụng component trong astro
sau khi component đã có trong storybook, bạn có thể import trực tiếp vào bất kỳ trang astro nào:
---
// src/pages/patterns/trang-moi.astro
import DocsLayout from '../../layouts/DocsLayout.astro';
import { Badge } from '@1thay/core';
import { Card, CardHeader, CardBody } from '@1thay/core';
---
<DocsLayout title="trang moi" section="patterns">
<Card>
<CardHeader>trang thai don hang</CardHeader>
<CardBody>
<Badge variant="success">da giao</Badge>
<Badge variant="warning">dang xu ly</Badge>
<Badge variant="error">da huy</Badge>
</CardBody>
</Card>
</DocsLayout> quy tắc import trong astro:
- dùng alias
@1thay/core/components/— không dùng relative path - chỉ thêm
client:loadkhi component cần interactivity — badge là component tĩnh, không cần - component có tương tác (button, input, switch) mới cần
client:load - luôn import component đã có sẵn trong storybook — không tự ý tạo variant mới trong astro
8. chọn category cho component
storybook được tổ chức thành 4 category dựa trên bản chất kỹ thuật:
| category | bản chất | ví dụ component |
|---|---|---|
| CORE | foundational primitives | Logo, Icon, Divider, BrandSwitch |
| DASHBOARD | app shell + data display | Table, StatCard, Badge, Card, Sidebar, TopNav, Modal, Toast |
| LANDING | form controls | Button, Input, Select, Checkbox, Switch, Radio |
| PATTERNS | examples (không có API) | Examples, Floorplans, Installation |
quy tắc chọn: phân loại theo bản chất kỹ thuật, không theo use case. nếu component có thể dùng ở nhiều nơi, chọn category phản ánh bản chất của nó. badge hiển thị dữ liệu → DASHBOARD. button là form control → LANDING. logo là identity → CORE.
9. thêm vào sidebar docs
sau khi tạo trang astro mới, thêm entry vào DocsLayout.astro để nó xuất hiện trong sidebar:
// sites/docs-1thay/src/layouts/DocsLayout.astro
// tim NAV_DATA object va them link vao section tuong ung
guidelines: [
{
label: 'guidelines',
wip: true,
links: [
{ label: 'getting started', href: '/guidelines/getting-started' },
{ label: 'tao component', href: '/guidelines/teaching' }, // <- them dong nay
// ...
],
},
], 10. bảng kiểm tra cuối cùng
trước khi commit, kiểm tra tất cả các mục sau:
| # | tiêu chí | lệnh kiểm tra |
|---|---|---|
| 1 | css chỉ dùng token | grep -P '\d+px|#[0-9a-fA-F]{3,6}' badge.css (không được có kết quả) |
| 2 | test pass | npm test |
| 3 | storybook build sạch | npm run build-storybook |
| 4 | astro build sạch | npm run build |
| 5 | lint không lỗi | npm run lint |
| 6 | token snapshot khớp | npm run check-tokens |
| 7 | story có đủ variant + dark mode + brands | mở storybook kiểm tra trực quan |
| 8 | docs sidebar đã cập nhật | kiểm tra astro page hiển thị trong sidebar |
anti-patterns — những điều không nên làm
- không copy-paste css từ component khác — mỗi component có style riêng
- không dùng
style prop cho style tĩnh — tất cả style phải nằm trong file css - không
export default — dùng named export để import dễ kiểm soát - không hardcode giá trị — kể cả trong storybook stories (dùng
var(--space-md) thay vì 16px) - không tạo component khi chưa có use case — mỗi component phải có ít nhất 1 trang astro sử dụng nó
- không import từ
.stories vào astro — story là để dev, không phải để dùng trong production - không bỏ qua accessibility — dùng thẻ semantic, thêm aria-label, đảm bảo focus visible
tóm tắt quy trình
1. thiết kế api 2. viết css chỉ dùng var(--token). cover đủ states: hover, focus, disabled, loading.
3. viết component named export, typed props, className override, semantic html.
4. viết stories mỗi variant 1 story, dark mode, all brands, interaction test.
5. viết test render, props, defaults. không test implementation detail.
6. verify test → build-storybook → build → lint. tất cả phải pass.
7. dùng trong astro import từ @1thay/core, thêm client:load nếu cần, cập nhật sidebar.
8. commit & deploy commit lên main, cloudflare pages tự động deploy cả 1thay.com và design.1thay.com.