tạo component với storybook

trang này đang được xây dựng — nội dung chưa hoàn thiện

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:

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ạitokenví dụ
màu brand--color-brand-50--color-brand-900var(--color-brand-600)
màu neutral--color-neutral-50--color-neutral-900var(--color-neutral-200)
màu ngữ nghĩa--color-success, --color-warning, --color-errorvar(--color-error)
text--text-primary, --text-secondary, --text-muted, --text-inversevar(--text-muted)
surface--surface-bg, --surface-dim, --surface-raisedvar(--surface-dim)
spacing--space-xs--space-3xlvar(--space-md)
radius--radius-sm, --radius-md, --radius-lg, --radius-fullvar(--radius-full)
font--text-hero--text-ui-sm (type scale)var(--text-ui-sm)
border--border-color, --border-widthvar(--border-color)
motion--motion-fast, --motion-normal, --motion-slowvar(--motion-fast)
z-index--z-dropdown, --z-sticky, --z-modal, --z-toastvar(--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 ý:

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:

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:

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:

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:

categorybản chấtví dụ component
COREfoundational primitivesLogo, Icon, Divider, BrandSwitch
DASHBOARDapp shell + data displayTable, StatCard, Badge, Card, Sidebar, TopNav, Modal, Toast
LANDINGform controlsButton, Input, Select, Checkbox, Switch, Radio
PATTERNSexamples (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
1css chỉ dùng tokengrep -P '\d+px|#[0-9a-fA-F]{3,6}' badge.css (không được có kết quả)
2test passnpm test
3storybook build sạchnpm run build-storybook
4astro build sạchnpm run build
5lint không lỗinpm run lint
6token snapshot khớpnpm run check-tokens
7story có đủ variant + dark mode + brandsmở storybook kiểm tra trực quan
8docs sidebar đã cập nhậtkiể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

xác định props, variants, states. component này làm được những gì? ai sẽ dùng nó?

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.