الگوهای فرم
راهنمای طراحی فرمهای قابل استفاده، دسترسپذیر، و سازگار با 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"