الگوهای فرم

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

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

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

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

نمونه بصری

همان فرم «تنظیمات هشدار» صفحهٔ FormPage و برنامهٔ نمونه: هفت فیلد در سه گروه.


ساختار پایه: FormPage و FormRow

صفحه‌ای که یک فرم است و یک‌بار ذخیره می‌شود، FormPage است و هر فیلد یک FormRow که از render در FormField برمی‌گردد. قالب فرم، کارت آن، دکمه‌های «انصراف» و ثبت، پیام خطای ذخیره و جای برچسب‌ها را خودش می‌سازد؛ صفحه فقط فیلدها و ذخیره را می‌نویسد. فرم تا 5 فیلد ردیف‌هایش را مستقیم در FormPage می‌گذارد؛ فرم 6 فیلد یا بیشتر هر فیلد را در یک FormSection عنوان‌دار (نمونهٔ بالا). این صفحه یک Server Component در app/…/page.tsx است که <WeeklyReportScreen /> را بدون ویژگی رندر می‌کند؛ ذخیره در خود صفحهٔ کلاینت است، نه ویژگی‌ای که از صفحه می‌رسد.

'use client'
import {  } from 'react-hook-form'
import { , , ,  } from '@partodata/ui'
import { ,  } from '@partodata/ui/form'
import { ,  } from '@partodata/ui/templates'

type  = {
  : string
  : string[]
  : boolean
}

// The app's save request; simulated here (a short wait) — replace it with the API call.
async function (: ): <> {
  await new (() => (, 700))
  return 
}

// «گزارش هفتگی» has its own menu item: no `back`, and «انصراف» discards the changes.
export function () {
  const  = <>({
    : { : '', : [], : false },
    : 'onTouched',
  })

  // A failed save is the form's root error: FormPage shows it above the form, and the next submit clears it.
  const  = .(async () => {
    try {
      .(await ())
      .('گزارش هفتگی ذخیره شد')
    } catch {
      .('root', { : 'ذخیره انجام نشد؛ لحظه‌ای بعد دوباره تلاش کنید.' })
    }
  })

  return (
    < {...}>
      <
        ="گزارش هفتگی"
        ={}
        ={() => .()}
        ="ذخیره"
        ={..}
        ={...?.}
      >
        <
          ={.}
          ="title"
          ={{ : 'عنوان گزارش را وارد کنید' }}
          ={({ ,  }) => (
            < ="عنوان گزارش"  ={.?.}>
              < ="گفتمان درباره برند" {...} />
            </>
          )}
        />
        <
          ={.}
          ="keywords"
          ={{ : () => . > 0 || 'دست‌کم یک کلیدواژه وارد کنید' }}
          ={({ ,  }) => (
            <
              ="کلیدواژه‌ها"
              
              ="هر کلیدواژه را بنویسید و Enter بزنید."
              ={.?.}
            >
              < ={.} ={.} ="رونمایی محصول جدید" />
            </>
          )}
        />
        <
          ={.}
          ="email"
          ={({  }) => (
            < ="ارسال ایمیل" ="گزارش هر هفته به ایمیل اعضا هم فرستاده می‌شود.">
              < ={.} ={.} />
            </>
          )}
        />
      </>
    </>
  )
}

کنترل هر فیلد را نوع آن تعیین می‌کند و هر کنترل به شکل خودش به field وصل می‌شود ({...field} فقط برای Input و Textarea)؛ جدول کامل در صفحهٔ FormRow است.


نمایش خطا

خطای هر فیلد (پایین فیلد)

FormRow پیام error را زیر کنترل نشان می‌دهد، آن را با role="alert" اعلام می‌کند و به کنترل aria-invalid و aria-describedby می‌دهد. هر قاعده پیام خودش را دارد (required: '…'، pattern: { value, message: '…' }، validate: (value) => … || '…')؛ قاعدهٔ بی‌پیام (required: true) فقط یک پیام عمومی نشان می‌دهد و در حالت توسعه هشدار می‌دهد. فرم قالب اعتبارسنجی مرورگر را خاموش می‌کند (noValidate)، پس قالب ایمیل را pattern بررسی می‌کند:

<FormField
  control={form.control}
  name="address"
  rules={{
    required: 'نشانی ایمیل را وارد کنید',
    pattern: { value: /^\S+@\S+\.\S+$/, message: 'نشانی ایمیل درست نیست' },
  }}
  render={({ field, fieldState }) => (
    <FormRow label="نشانی ایمیل" required error={fieldState.error?.message}>
      <Input kind="email" {...field} />
    </FormRow>
  )}
/>

خطای ذخیره (بالای فرم)

ذخیره‌ای که شکست بخورد خطای کلی فرم است: در catch ذخیره form.setError('root', …)، و FormPage آن را با error بالای فرم نشان می‌دهد، اعلام می‌کند و فوکوس را به آن می‌برد؛ ارسال بعدی آن را پاک می‌کند. Callout یا متن خطای خودتان را کنار فرم نگذارید:

const save = form.handleSubmit(async (values) => {
  try {
    form.reset(await saveWeeklyReport(values))
    toast.success('گزارش هفتگی ذخیره شد')
  } catch {
    form.setError('root', { message: 'ذخیره انجام نشد؛ لحظه‌ای بعد دوباره تلاش کنید.' })
  }
})

<FormPage title="گزارش هفتگی" onSubmit={save} onCancel={() => form.reset()} submitLabel="ذخیره"
  submitting={form.formState.isSubmitting} error={form.formState.errors.root?.message}>
  …
</FormPage>

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

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

فرم چندمرحله‌ای هیچ‌کدام از قالب‌های صفحه نیست: صفحه‌اش یک CustomPage با dsGap است. فیلدهای هر مرحله باز هم FormRowاند.

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/templates'
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">
        < ={}>
          < ="اطلاعات پایه" />
          < ="اطلاعات تماس" />
          < ="بازبینی" />
        </>

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

        {/* مرحله 2 */}
        { === 1 && (
          < ="space-y-4">
            < ="text-heading">اطلاعات تماس</>
            <
              ={.}
              ="email"
              ={{ : 'نشانی ایمیل را وارد کنید' }}
              ={({ ,  }) => (
                < ="نشانی ایمیل"  ={.?.}>
                  < ="email" {...} />
                </>
              )}
            />
            < ="flex gap-2">
              < ="default" ={() => (0)}>
                قبلی
              </>
              < ="primary" ={() => (2)}>
                مرحله بعد
              </>
            </>
          </>
        )}

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

Validation

با Zod

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

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

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

با schema، پیام هر قاعده در خود schema است و FormRow همان را از fieldState.error?.message نشان می‌دهد؛ rules روی FormField لازم نیست.


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

FormRow کار دسترسی‌پذیری هر فیلد را خودش انجام می‌دهد؛ هیچ‌کدام را دستی نسازید:

  • برچسب نام کنترل است (aria-labelledby؛ در Select روی SelectTrigger) و کلیک روی آن کنترل را فوکوس می‌کند؛
  • required نشانهٔ * را بعد از برچسب می‌گذارد (پنهان از فناوری کمکی) و به کنترل aria-required می‌دهد؛
  • description و پیام error با aria-describedby به کنترل وصل‌اند، و پیام با role="alert" اعلام می‌شود و کنترل aria-invalid می‌گیرد.
<FormRow
  label="نشانی ایمیل"
  required
  description="هشدارها به این نشانی فرستاده می‌شوند."
  error={fieldState.error?.message}
>
  <Input kind="email" {...field} />
</FormRow>

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

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

  • هر فیلد را با FormRow بسازید؛ جای برچسب را ظرف تعیین می‌کند: در صفحهٔ فرم و دیالوگ بالای فیلد، در بخش صفحهٔ تنظیمات کنار فیلد (از عرض md). خودتان برچسب را جابه‌جا نکنید
  • کنترل را نوع فیلد تعیین می‌کند (جدول «کنترل هر نوع فیلد» در FormRow): چند گزینه از چند گزینه MultiSelect (نه گروهی از Checkboxها)، عدد و درصد NumberInputLocale (برای درصد unit="%")، یکی از چند گزینه Select، کلیدواژه‌ها TagInput، روشن/خاموش Switch؛ و هر کنترل را همان‌طور که ستون آخر آن جدول می‌گوید به field وصل کنید: {...field} فقط برای Input و Textarea (روی NumberInputLocale، MultiSelect یا Switch کامپایل می‌شود ولی مقدار به فرم نمی‌رسد)
  • گروه‌بندی صفحهٔ فرم یک قاعده دارد: تا 5 فیلد بی‌گروه؛ 6 فیلد یا بیشتر در FormSectionهای عنوان‌دار، 2 تا 4 فیلد در هر گروه (فیلدی که فقط با یک شرط دیده می‌شود هم شمرده می‌شود). کارت فرم را FormPage می‌سازد: Card خودتان را داخلش نگذارید
  • خطای هر فیلد را error در همان FormRow نشان می‌دهد، نه یک خلاصهٔ کلی؛ هر قاعده پیام خودش را دارد
  • دکمه‌های «انصراف» و ثبت را FormPage می‌سازد (onCancel، submitLabel)؛ دکمهٔ ثبت خودتان را ننویسید
  • از placeholder برای مثال استفاده کنید، نه برای توضیح
  • فیلد اجباری required روی FormRow است، همراه rules.required روی FormField آن (ستاره را خودتان ننویسید)
  • ذخیرهٔ موفق را فقط با toast.success('… ذخیره شد') بگویید؛ نه Callout و نه متن دست‌ساز

دام‌های رایج

ویژگی‌های فیزیکی 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' }} به جای توکن معنایی.

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

الگوی درست: خطای فیلد را به error در FormRow بسپارید و هر متن خطای دستی دیگر را با توکن معنایی رنگ کنید:

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

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

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

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

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

الگوی درست: کارت فرم را FormPage (یا SettingsSection) می‌سازد، با توکن‌هایی که در هر دو تم مقدار درست دارند؛ کارت خودتان را دور فرم نسازید، و فرم را ابتدا در تم تیره بررسی کنید:

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

// ✅ درست — کارت فرم را FormPage می‌سازد
<FormPage title="گزارش هفتگی" onSubmit={save} onCancel={() => form.reset()} submitLabel="ذخیره">

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

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

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

الگوی درست: مقادیر جست‌وجو و فیلتر را به‌صورت پارامتر query به سرور بفرستید و پاسخ صفحه‌بندی‌شده را به صفحه بدهید. در صفحهٔ فهرست، جست‌وجو و فیلترها search و filters قالب ListPage هستند (نه یک فرم جدا بالای جدول) و صفحه‌بندی pagination همان قالب:

// سرور بر اساس جست‌وجو و فیلترها فیلتر می‌کند و فقط یک صفحه برمی‌گرداند
const { data, isLoading, error, run } = useAsync<Paged<Result>>()

<ListPage
  title="نتایج کمپین تخفیف فصلی"
  search={
    <SearchInput
      placeholder="جست‌وجو در نتایج"
      aria-label="جست‌وجو در نتایج"
      value={q}
      onChange={(e) => filterBy(setQ)(e.target.value)}
      onClear={() => filterBy(setQ)('')}
    />
  }
  filtered={q !== ''}
  onClearFilters={clear}
  state={pageState({ data: data?.items, isLoading, error, onRetry: load, emptyCopy: { title: 'هنوز نتیجه‌ای ثبت نشده است' } })}
  pagination={{
    currentPage: page,
    totalPages: Math.ceil((data?.total ?? 0) / 25),
    onPageChange: setPage,
    totalRows: data?.total ?? 0, // بازهٔ «1 تا 25 از 1,240» را قالب زیر فهرست می‌گذارد
    pageSize: 25,
  }}
>
  <DataTable columns={columns} data={data?.items ?? []} />
</ListPage>

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

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

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

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


اجزای سطح پایین‌تر: فیلدی بیرون از قالب‌ها

FormItem، FormLabel، FormControl، FormDescription و FormMessage فقط برای فیلدی است که چیدمانش را خودتان می‌سازید، بیرون از قالب‌ها و بیرون از FormRow. در FormPage، SettingsSection یا فرم یک دیالوگ هرگز آن‌ها را به کار نبرید؛ همین کارها را FormRow انجام می‌دهد و قانون parto/form-row افزونهٔ ESLint آن‌ها را علامت می‌زند. همان اتصال 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'
import {  } from 'react-hook-form'

interface ExampleFormValues {
  : string
}

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

  function (: ExampleFormValues) {
    .()
  }

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

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

صفحات مرتبط

  • FormPage و FormRow — قالب صفحهٔ فرم و ردیف هر فیلد، با جدول کنترل هر نوع فیلد

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.

  • الگوهای خطا — نمایش خطاهای API و سطح صفحه

  • محتوا و لحن — قوانین نوشتن label، placeholder، و پیام خطا

  • دسترسی‌پذیری — ارتباط label با input، role="alert"