پرتوپرتو

الگوهای فرم

راهنمای طراحی فرم‌های قابل استفاده، دسترس‌پذیر، و سازگار با RTL در پرتو

اصول طراحی فرم

فرم‌ها رایج‌ترین نقطه تعامل کاربر با محصول هستند. یک فرم خوب:

  • واضح است — کاربر می‌داند چه باید وارد کند
  • بازخورد می‌دهد — خطاها و موفقیت‌ها فوراً نمایش داده می‌شوند
  • مقاوم در برابر خطا است — وقتی اشتباهی رخ می‌دهد، کاربر می‌داند چه کاری بکند
  • قابل دسترسی است — با صفحه‌کلید و screen reader کار می‌کند

نمونه بصری


ساختار پایه

// Form* ship from their own subpath entry, not the main barrel (RSC compatibility).
import { , , , , ,  } from '@partodata/ui/form'
import { ,  } from '@partodata/ui'
import {  } from 'react-hook-form'

interface ExampleFormValues {
  : string
}

export function () {
  const  = <ExampleFormValues>({ : { : '' } })

  function (: ExampleFormValues) {
    .()
  }

  return (
    < {...}>
      < ={.()} ="space-y-4">
        <
          ={.}
          ="username"
          ={({  }) => (
            <>
              <>نام کاربری</>
              <>
                < ="نام کاربری خود را وارد کنید" {...} />
              </>
              < />
            </>
          )}
        />

        < ="submit">ثبت</>
      </>
    </>
  )
}

نمایش خطا

خطای inline (پایین فیلد)

<FormField
  name="email"
  render={({ field }) => (
    <FormItem>
      <FormLabel>ایمیل</FormLabel>
      <FormControl>
        <Input type="email" {...field} />
      </FormControl>
      <FormMessage /> {/* خطا اینجا نمایش داده می‌شود */}
    </FormItem>
  )}
/>

خطای کلی فرم (بالای فرم)

{form.formState.errors.root && (
  <Alert variant="destructive">
    <AlertTitle>خطا در ارسال</AlertTitle>
    <AlertDescription>
      {form.formState.errors.root.message}
    </AlertDescription>
  </Alert>
)}

فرم‌های چند مرحله‌ای

پیشرفت مراحل را با Stepper نمایش دهید، نه با نوار دست‌ساز — وضعیت هر مرحله (تکمیل‌شده/فعال/در انتظار)، شماره‌گذاری فارسی، و aria-label را خودش مدیریت می‌کند. مقدار activeStep از صفر شروع می‌شود.

import {  } from 'react'
import {  } from 'react-hook-form'
// Form* ship from their own subpath entry, not the main barrel (RSC compatibility).
import { , , , , ,  } from '@partodata/ui/form'
import { , , ,  } from '@partodata/ui'

interface SignupFormValues {
  : string
  : string
}

export function () {
  // Stepper is 0-based: 0 = «اطلاعات پایه».
  const [, ] = (0)
  const  = <SignupFormValues>({
    : 'onTouched',
    : { : '', : '' },
  })

  function (: SignupFormValues) {
    .()
  }

  return (
    < {...}>
      < ={.()} ="space-y-6">
        < ={}>
          < ="اطلاعات پایه" />
          < ="اطلاعات تماس" />
          < ="بازبینی" />
        </>

        {/* مرحله ۱ */}
        { === 0 && (
          < ="space-y-4">
            < ="text-lg font-semibold">اطلاعات پایه</>
            <
              ={.}
              ="name"
              ={{ : 'نام الزامی است' }}
              ={({  }) => (
                <>
                  <>نام</>
                  <>
                    < ="نام خود را وارد کنید" {...} />
                  </>
                  < />
                </>
              )}
            />
            {/* htmlType defaults to "button", so a step change never submits the form */}
            < ={() => (1)}>مرحله بعد</>
          </>
        )}

        {/* مرحله ۲ */}
        { === 1 && (
          < ="space-y-4">
            < ="text-lg font-semibold">اطلاعات تماس</>
            <
              ={.}
              ="email"
              ={{ : 'ایمیل الزامی است' }}
              ={({  }) => (
                <>
                  <>ایمیل</>
                  <>
                    < ="email" ="ltr" ="example@domain.com" {...} />
                  </>
                  < />
                </>
              )}
            />
            < ="flex gap-2">
              < ="outline" ={() => (0)}>قبلی</>
              < ={() => (2)}>مرحله بعد</>
            </>
          </>
        )}

        {/* مرحله ۳ — ارسال */}
        { === 2 && (
          < ="flex gap-2">
            < ="outline" ={() => (1)}>قبلی</>
            < ="submit" ="primary" ={..}>
              ثبت
            </>
          </>
        )}
      </>
    </>
  )
}

Validation

با Zod

import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'

const schema = z.object({
  username: z
    .string()
    .min(3, 'نام کاربری باید حداقل ۳ کاراکتر باشد')
    .max(20, 'نام کاربری نباید بیش از ۲۰ کاراکتر باشد'),
  email: z
    .string()
    .email('آدرس ایمیل نامعتبر است'),
  password: z
    .string()
    .min(8, 'رمز عبور باید حداقل ۸ کاراکتر باشد'),
})

const form = useForm({
  resolver: zodResolver(schema),
  defaultValues: { username: '', email: '', password: '' },
})

دسترسی‌پذیری فرم‌ها

// همیشه label با input مرتبط باشد
<FormLabel htmlFor="email">ایمیل</FormLabel>
<Input id="email" aria-describedby="email-error" />
<FormMessage id="email-error" />

// فیلدهای اجباری مشخص شوند
<FormLabel>
  ایمیل
  <span aria-hidden="true" className="text-destructive ms-1">*</span>
  <span className="sr-only"> (اجباری)</span>
</FormLabel>

// خطاها با role="alert" اعلام شوند
<div role="alert" aria-live="polite">
  <FormMessage />
</div>

بهترین روش‌ها و دام‌های رایج

بهترین روش‌ها

  • label را بالای فیلد قرار دهید، نه کنار آن — برای RTL و LTR هر دو بهتر است
  • خطا را پایین فیلد مربوطه نمایش دهید، نه در یک خلاصه کلی
  • دکمه submit را پس از آخرین فیلد قرار دهید
  • از placeholder برای مثال استفاده کنید، نه برای توضیح
  • فیلدهای اجباری را با * مشخص کنید
  • بعد از submit موفق، یک feedback واضح نمایش دهید (toast یا success state)

دام‌های رایج

۱. ویژگی‌های فیزیکی CSS در چیدمان RTL

اشتباه: استفاده از کلاس‌های جهت‌دار فیزیکی مانند pl-10، ml-2 یا text-left برای جای دادن آیکون داخل input، فاصله ستاره فیلد اجباری، یا تراز پیام خطا.

چرا مشکل‌ساز است: صفحه‌های پرتو RTL هستند؛ کلاس فیزیکی عنصر را به سمت اشتباه می‌برد — padding آیکون روی متن ورودی می‌افتد و ستاره اجباری به جای بعد از label، قبل از آن ظاهر می‌شود. داخل خود پکیج قانون ESLint no-physical-css-properties این الگو را رد می‌کند، اما در کد مصرف‌کننده هیچ محافظی وجود ندارد.

الگوی درست: همیشه از ویژگی‌های منطقی استفاده کنید (ml → ms، mr → me، pl → ps، pr → pe، text-left → text-start):

// ❌ نادرست — در RTL آیکون روی متن می‌افتد
<Input className="pl-10 text-left" />

// ✅ درست — در RTL و LTR هر دو صحیح است
<Input className="ps-10 text-start" />

۲. رنگ هاردکد برای حالت خطا

اشتباه: نمایش پیام خطا با text-red-500 یا style={{ color: '#ef4444' }} به جای توکن معنایی.

چرا مشکل‌ساز است: توکن‌های پرتو رنگ کامل هستند و در هر تم مقدار مناسب همان تم را دارند؛ رنگ ثابت فقط برای یک تم تنظیم شده و در تم دیگر کنتراست کافی ندارد. علاوه بر این، FormMessage خودش با توکن text-destructive رندر می‌شود — قرمز دستی شما با آن یکسان نخواهد بود و فرم دو سایه قرمز متفاوت پیدا می‌کند.

الگوی درست: خطا را به <FormMessage /> بسپارید و هر متن خطای دستی را با توکن معنایی رنگ کنید:

// ❌ نادرست — فقط در یک تم درست دیده می‌شود
<p className="text-red-500">آدرس ایمیل نامعتبر است</p>

// ✅ درست — توکن در هر دو تم مقدار صحیح دارد
<p className="text-destructive">آدرس ایمیل نامعتبر است</p>

۳. نادیده گرفتن تم تیره (dark-first)

اشتباه: ساخت کارت فرم با bg-white dark:bg-zinc-900 و بررسی ظاهر فقط در تم روشن.

چرا مشکل‌ساز است: تم پایه پرتو تیره است — مصرف‌کننده‌ای که هیچ تمی تنظیم نکند، تیره رندر می‌شود؛ bg-white بدون پوشش کامل حالت تیره یعنی یک کارت سفید خیره‌کننده وسط داشبورد تیره. واریانت‌های دستی dark: هم نسخه دومی از تصمیم رنگی می‌سازند که با به‌روزرسانی توکن‌ها همگام نمی‌ماند.

الگوی درست: یک کلاس توکن واحد که در هر دو تم مقدار درست دارد، و بررسی فرم ابتدا در تم تیره:

// ❌ نادرست — دو تصمیم رنگی جدا که از توکن‌ها عقب می‌مانند
<div className="bg-white dark:bg-zinc-900 border-gray-200 rounded-md p-6">

// ✅ درست — توکن در هر دو تم خودش حل می‌شود
<div className="bg-surface-100 border border-default rounded-md p-6">

۴. فیلتر سمت کلاینت وقتی فرم به DataTable وصل است

اشتباه: فرم جستجو یا فیلتر (مثلاً فیلتر نتایج «کمپین تخفیف فصلی») کل مجموعه داده را یک‌جا fetch می‌کند و فیلتر و صفحه‌بندی در مرورگر انجام می‌شود.

چرا مشکل‌ساز است: DataTable پرتو از پایه server-paged طراحی شده است — مقدار totalRows را باید سرور گزارش کند و جدول نمی‌تواند آن را از داده‌های صفحه فعلی استنتاج کند. با حجم واقعی داده‌های پایش (هزاران ردیف)، fetch کامل زمان بارگذاری و حافظه را می‌بلعد و state فرم با state جدول از هم جدا می‌افتد.

الگوی درست: مقادیر فرم را به‌صورت پارامتر query به سرور بفرستید و پاسخ صفحه‌بندی‌شده را مستقیم به جدول بدهید:

// سرور بر اساس مقادیر فرم فیلتر می‌کند و فقط یک صفحه برمی‌گرداند
const { data, totalRows, totalPages } = usePagedSearch({
  query: form.watch('query'),
  page,
  pageSize,
})

<DataTable
  columns={columns}
  data={data}
  pagination={{
    currentPage: page,
    totalPages,
    onPageChange: setPage,
    pageSize,
    onPageSizeChange: setPageSize,
    totalRows, // خلاصه «۱–۲۰ از ۱٬۲۴۰» را فعال می‌کند
  }}
/>

۵. لحن غیررسمی یا ناهمگون در label و پیام خطا

اشتباه: «ایمیلت رو وارد کن»، «رمزت خیلی کوتاهه» — یا ترکیب لحن رسمی و غیررسمی در فیلدهای مختلف یک فرم.

چرا مشکل‌ساز است: زبان استاندارد پرتو فارسی رسمی است و مخاطب آن تحلیل‌گران و تیم‌های سازمانی هستند؛ لحن غیررسمی یا ناهمگون اعتبار محصول را کم می‌کند و پیام خطایی که در یک فیلد رسمی و در فیلد بعدی محاوره‌ای است، فرم را ترجمه‌نشده و ناتمام جلوه می‌دهد.

الگوی درست: «آدرس ایمیل خود را وارد کنید»، «رمز عبور باید حداقل ۸ کاراکتر باشد» — همان لحنی که در پیام‌های schema بخش Validation همین صفحه به کار رفته است. قواعد کامل نوشتار در محتوا و لحن آمده است.


صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • الگوهای خطا — نمایش خطاهای API و سطح صفحه
  • محتوا و لحن — قوانین نوشتن label، placeholder، و پیام خطا
  • دسترسی‌پذیری — ارتباط label با input، role="alert"