دیزاین سیستم چیه ؟ دیزاین سیستم به یه سری اصول٬ کامپوننت و قوانینی که ظاهر اپلیکیشن و تجربه کاربری برنامه مارو در بر میگیره٬ میگن. به طور مثال دکمه ها یا اندازه متن ها.

چرا باید اصلا از دیزاین سیستم استفاده کنیم ؟

بدون وجود دیزاین سیستم هر نیروی بر اساس سلیقه شخصی، ایده ها و convention خودش یک طرح رو پیاده سازی میکنه و این به مرور کد رو خیلی کثیف و غیرقابل maintain میکنه در اصل دیزاین سیستم راه حلی برای یکپارچه کردن طراحی محصول بین تیم فنی٬ طراحان و مدیریت است تا تجربه کاربری واحدی رو به کاربر نهایی انتقال بدیم چون معمولا در حین توسعه نرم افزار معمولا ui/ux در خلال کار گم میشه و باید ساز و کاری وجود داشته باشه که در هرجای فاز توسعه امکان بررسی component های پیاده شده با guideline برند باشه، دیزاین سیستم همون راه حله

همچنین توی پروژه ای که تعداد کامپوننت ها زیاد میشه اگه به صورت ساختار مندی پیش نرفته باشیم٬ خیلی کامپوننت ها شلوغ میشه و تسک ها فرسایشی جلو میره و maintain پروژه٬ سخت و سختر میشه.

اگر شما یک تیم ۱۰ نفره دارید و دیزاین سیستم واحدی ندارید این به این معنی هستش که ۱۰ تا دیزاین سیستم یا شایدم بیشتر دارید

نمونه دیزاین سیستم در فیگما:

figma designsystem

چرخه دیزاین سیستم که توی تیممون

../assets/

ساخت دیزاین سیستم از ابتدا

وقتی میخوایم دیزاین سیستم رو از اول بسازیم معمولا بین دو گزینه باید انتخاب کنیم

  1. همه کدهارو خودمون بنویسیم
  2. فریم ورک های مثل ant.d, MUI رو سفارشی سازی کنیم و زمان زیادی رو صرف تطابق دادن به طرح کنیم.

باید دنبال یک راه حل بینابینی باشیم. راه حلی که به نظرم هست استفاده از راه حل اول + کتابخونه های headless

headless در اصطلاح به کتابخونه‌های میگن که فقط منطق و js رو هندل میکنه و ui خاصی نداره. این باعث میشه چیزهای مثل modal Collapse و خیلی چیزهای دیگه رو لازم نباشه روی ساخت اولیه اش وقت بزاریم یعنی چرخ رو از اول اختراع نکنیم

برای ساخت دیزاین سیستم ما از چند کتابخونه استفاده میکنیم.

  1. tailwind
  2. cva
  3. storybook
  4. headless/ui
  5. jest and react testing-library

اول از همه برای اینکه خیالمون برای مسیر دهی راحت و تمیز باشه. میایم alias برای مسیرمون توی فایل tsconfig.json مینویسیم

"paths": {
      "@ui/*": ["./src/components/ui/*"]
    }

و بعد تمام پکیج هارو به پروژه اضافه میکنیم

yarn add tailwindcss cva@npm:class-variance-authority storybook @headlessui/react

این سینتکس ‍cva@npm:class-variance-authority برای اینه که میخوایم یک پکیج رو با یه اسم دیگه در لوکال داشته باشیم یعنی به جای اینکه در ایمپورت بنویسیم ‍ ‍‍‍class-variance-authority مینویسیم CVA

ساختار پوشه بندی :

src/
└── components/
    └── ui/
        └── Button/
            ├── index.tsx
            ├── button.test.tsx
            └── button.stories.tsx
     └── template/
     └── layout/

قدم اول. ساختار اولیه

ساختار اولیه دکمه رو ایجاد میکنیم

default function Button() {
  return (
    <button>Button</button>
  )
}

قدم دوم. ساخت انواع دکمه

دکمه میتونه حالت های مختلفی داشته باشه مثلا outline یا primary یا حتی اندازه های مختلفی میتونه داشته باشه باید تمام حالت های که در دیزاین سیستم هست رو پیاده کنیم

import React from 'react';


// Define the ButtonProps interface
interface ButtonProps {
  intent?: 'primary' | 'secondary' | 'default';
  size?: 'small' | 'medium' | 'large';
  roundness?: 'square' | 'round' | 'pill';
}

// Create the Button component
const Button: React.FC<ButtonProps> = ({
  intent = 'default',
  size = 'medium',
  roundness = 'round',
  children,
}) => {
  // Define the mapping of variant styles
  const variantStyles: { [key: string]: string } = {
    primary: 'bg-green-500 hover:bg-green-600',
    secondary: 'bg-red-500 hover:bg-red-600',
    default: 'bg-gray-500 hover:bg-gray-600',
  };

  const sizeStyles: { [key: string]: string } = {
    small: 'text-sm py-1 px-2',
    medium: 'text-base py-2 px-4',
    large: 'text-lg py-4 px-8',
  };

  const roundnessStyles: { [key: string]: string } = {
    square: 'rounded-none',
    round: 'rounded-md',
    pill: 'rounded-full',
  };

  const getButtonStyle = (): string => {
    const intentStyle = variantStyles[intent] || variantStyles['default'];
    const sizeStyle = sizeStyles[size] || sizeStyles['medium'];
    const roundnessStyle = roundnessStyles[roundness] || roundnessStyles['round'];

    return `h-fit text-white uppercase transition-colors duration-150 ${intentStyle} ${sizeStyle} ${roundnessStyle}`;
  };

  return (
    <button
      className={getButtonStyle()}
    >
      {children}
    </button>
  );
};

export default Button;

توی مثال بالا حجم کد خیلی بالاس و از طرفی اگر بخواهیم مثلا وقتی size برایر medium بود و intent هم بود یه کلاس دیگه هم اضافه کنیم کلی کد دیگه هم باید اضافه کنیم.اینجاس که CVA وارد بازی میشه مثال بالا رو با CVA دوباره مینویسیم

import { cva } from "class-variance-authority";
import type { FC } from 'react';

const ButtonVariants = cva(
    /* button base style */
    "h-fit text-white uppercase transition-colors duration-150",
    {
        variants: {
            /* button colors */
            intent: {
                primary:
                    "bg-green-500 hover:bg-green-600",
                secondary:
                    "bg-red-500 hover:bg-red-600",
                default:
                    "bg-gray-500 hover:bg-gray-600",
            },

            /* button sizes */
            size: {
                small: ["text-sm", "py-1", "px-2"],
                medium: ["text-base", "py-2", "px-4"],
                large: ["text-lg", "py-4", "px-8"],
            },

            /* button roundness */
            roundness: {
                square: "rounded-none",
                round: "rounded-md",
                pill: "rounded-full",
            },
        },

        // default parameter
        defaultVariants: {
            intent: "default",
            size: "medium",
            roundness: "round"
        },
    }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
  VariantProps<typeof ButtonVariants> {
}

const Button: FC<ButtonProps> = (props) => {
  const { size, variant, className } = props;
  return (
    <button type='button' {...props} className={buttonStyles({ variant, size, className })} />
  );
};

با کمک cva دیگه لازم نیست interface خاصی هم تعریف کنیم یه interface خالی که از دکمه html و مقدارهای CVA ارث بری میکنه.

خب توی ساخت دیزاین سیستم ممکنه بخواهید که چیزهای رو پیاده کنید که منطق هم دارن و در وب خیلی رایج هستند مثلا پیاده سازی select یا dropdown یا modal و مثال های مثل این، برای اینجور مواقع میتونید از کتابخونه‌های headless که استایل خاصی ندارن و خیلی خوب سفارشی سازی میشن استفاده کنید مثلاheadless/ui یا radix/ui

قدم سوم. storybook

بعد از اینکه حالت های مختلف دکمه زده شد باید در جایی به صورت ایزوله قرار بگیره که دیزاینر ها نظرشون رو بگن و ما اصلاحات رو انجام بدیم برای ایجاد همچین فضای میتونیم از storybook استفاده کنیم.

با استفاده از command های زیر کانفیگ اولیه اش انجام میشه

npx storybook@latest init

خب بعد از کانفیگ ما باید برای کامپوننتمون یک مستند بنویسیم به صورتی که برای storbook قابل فهم باشه. کنار فایل کامپوننتمون یک فایل به اسم button.stories.tsx ایجاد میکنیم.

نوشتن story

استوری نوشتن داستان یک کامپوننت است که چه ورودی های می گیرد و چه چیزی در به end user نشان میدهد

// Button.stories.ts|tsx

import type { Meta, StoryObj } from '@storybook/react';

import Button from '.';

const meta: Meta<typeof Button> = {
  component: Button,
  title: 'UI/Button'
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    children: 'تست'
  },
};

حالا با دستور زیر میتونیم ببینیم چه اتفاقی افتاده

yarn storybook

خروجی هم به این صورت میشه storybook preview

همیچنین storybook قابلیت این رو داره که برای هر کامپوونتون مستندات نمیزی بنویسید و خروجی مثل زیر داشته باشید

img

قدم چهارم. نوشتن تست

بعد از اینکه کد ریویو و ریویو طرح توسط طراح ها انجام شد و تغییراتش انجام شد در مراحل آخر باید unit test برای کامپوننت‌مون بنویسیم تا حالت های مختلفش رو تست کنیم. از کتابخونه های مثل jest, react-testing library میتونیم استفاده کنیم. مثال برای کامپوننت‌مون :

import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';

import Button from '.'; // Replace './Button' with the correct path to your Button component

describe('Button Component', () => {
  it('renders with default variant and size', () => {
    render(<Button>Click Me</Button>);

    const buttonElement = screen.getByRole('button');
    expect(buttonElement).toHaveClass('bg-gray-500');
    expect(buttonElement).toHaveClass('text-base');
    expect(buttonElement).toHaveClass('rounded-md');
    expect(buttonElement).toHaveTextContent('Click Me');
  });

  it('applies the correct variant and size styles', () => {
    render(<Button variant='text' size='small'>Primary Button</Button>);

    const buttonElement = screen.getByRole('button');
    expect(buttonElement).toHaveClass('bg-green-500');
    expect(buttonElement).toHaveClass('text-lg');
    expect(buttonElement).toHaveClass('rounded-full');
    expect(buttonElement).toHaveTextContent('Primary Button');
  });

  it('calls the onClick callback when clicked', () => {
    const onClickMock = jest.fn();
    render(<Button onClick={onClickMock}>Click Me</Button>);

    const buttonElement = screen.getByRole('button');
    fireEvent.click(buttonElement);

    expect(onClickMock).toHaveBeenCalledTimes(1);
  });
});

نکته

ما بیشتر وقتمون رو صرف خوندن کد میکنیم تا نوشتن پس باید کدمون رو راحت خوند وقتی از tailwind در jsx مون استفاده میکنیم سرعت توسعه مون میره بالا ولی از اون طرف زمان دیباگمون خیلی افزایش پیدا میکنه، مثلا کد زیر دقیقا چی رو قرار نشون بده ؟

import React from 'react'
import styles from 'banner.module.css'

function Banner(){
    return (
		 <div className="bg-green-100 border-t border-b border-green-500 text-green-700 px-4 py-3" role="alert">
        		<p className="font-bold">Informational message</p>
        		<p className="text-sm">Some additional text to explain said message.</p>
    	     </div>
	)
}

البته وقتی tailwind داریم کلاس هامون مثل این مثال من انقدر کم نیست و طول خط ها بالای ۴۰ کیلومتره. خوب اینجوری کار کردن با tailwind اصلا خوب نیست زمان زیادی برای دیباگ و یا متوجه شدن کد میخواد. راه بهتره چیه ؟ راه بهتر این که با css-module همین استایل هارو داخل css بنویسید. مثلا:

import React from 'react'
import styles from 'banner.module.css'

function Banner(){
    return (
		 <div className={styles.bannerContainer} role="alert">
        		<p className={styles.bannerTitle}>Informational message</p>
        		<p className={styles.bannerDescription}>Some additional text to explain said message.</p>
    	     </div>
	)
}

و کد CSS

.bannerContainer {
    @apply bg-green-100 border-t border-b border-green-500 text-green-700 px-4 py-3
}

.bannerTitle {
    @apply font-bold
}
.bannerDescription {
    @apply text-sm
}

جمع بندی

برای جمع بندی، این مقاله مقدمه ای برای ساختن دیزاین سیستم های بود یادتون باشه به ازای هر پروژه تصمیم درستی بگیرید نه یک تصمیم را برای هر پروژه استفاده کنید ممکنه این تصمیم برای پروژه های کوچیک Overengineering باشه و ممنونم که این بلاگ پست رو خوندید امیدوارم حاوی نکته‌ای آموزنده ای براتون بوده باشه :)

منابعی برای مطالعه بیشتر