الگوهای ریسپانسیو

راهنمای ساختاردهی رابط کاربری برای نمایش صحیح در موبایل، تبلت، و دسکتاپ

مقدمه

محصولات سوشیال لیسنینگ در دستگاه‌های مختلف استفاده می‌شوند. در سیستم طراحی پرتو، رویکرد پیش‌فرض mobile-first است: ابتدا نمایش موبایل تعریف می‌شود، سپس در نقاط شکست بزرگ‌تر بازنویسی می‌شود.


نمونه بصری

همان قطعهٔ داشبورد با دستور «موبایل-اول»: ردیف متریک‌ها از یک ستون شروع می‌شود و در sm دو ستونه و در xl چهار ستونه می‌شود؛ ستون اصلی و جانبی زیر lg روی هم می‌افتند. عرض پیش‌نمایش (یا پنجره) را تغییر دهید تا جابه‌جایی نقاط شکست را ببینید.

کل منشن‌ها

12,480

نرخ تعامل

4.6٪

دیدگاه مثبت

68٪

پست‌های امروز

324

موضوع‌های پربحث

کمپین تخفیف فصلیمثبت
بازخورد بسته‌بندیخنثی
رونمایی محصول جدیدمثبت

خلاصه هفته

گفت‌وگوهای برند این هفته 8٪ رشد داشته است؛ بیشترین سهم از کمپین تخفیف فصلی است.

نقاط شکست (Breakpoints)

سیستم طراحی پرتو از نقاط شکست استاندارد Tailwind پیروی می‌کند:

نامحداقل عرضموارد استفاده
پیش‌فرض (موبایل)—نمایش موبایل‌اول
sm640pxتبلت کوچک
md768pxتبلت
lg1024pxلپ‌تاپ
xl1280pxدسکتاپ
2xl1536pxصفحات بزرگ

الگوی 1 — چیدمان شبکه‌ای (Grid Layout)

کارت‌های متریک در موبایل تک‌ستونه، در تبلت دو‌ستونه، و در دسکتاپ چهارستونه نمایش داده می‌شوند.

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
  <MetricCard>
    <MetricCardHeader>
      <MetricCardLabel>دنبال‌کننده‌ها</MetricCardLabel>
    </MetricCardHeader>
    <MetricCardContent>
      <MetricCardValue>125 هزار</MetricCardValue>
    </MetricCardContent>
  </MetricCard>
  <MetricCard>
    <MetricCardHeader>
      <MetricCardLabel>تعامل</MetricCardLabel>
    </MetricCardHeader>
    <MetricCardContent>
      <MetricCardValue>4.2٪</MetricCardValue>
    </MetricCardContent>
  </MetricCard>
  {/* ... */}
</div>

الگوی 2 — ناوبری کناری ریسپانسیو

منوی اصلی برنامه را خودتان ریسپانسیو نکنید. قاب همهٔ محصولات ProductFrame است، یک بار در layout ریشه، و این رفتار را از قبل دارد: روی دسکتاپ منوی کناری (برچسب‌دار یا نوار آیکونی) در سمت شروع (راست)، روی موبایل پنل کشویی که از دکمهٔ منوی نوار بالا باز می‌شود؛ قاب دست‌ساز نسازید.

آنچه می‌ماند، نوار کناری درون یک صفحه است (مثل فهرست بخش‌های یک گزارش بلند): یک PageSection از خود صفحه، که روی موبایل فهرست را بالای محتوا می‌گذارد و از lg به بعد آن را ستون کناری سمت شروع می‌کند (با TableOfContents). بدون <main> دیگر؛ صفحه از قبل داخل main قاب است. چنین صفحه‌ای در هیچ قالبی جا نمی‌شود (ستون کناری DetailPage در سمت پایان است و کارت می‌گیرد)، پس یک CustomPage با DS-GAP ثبت‌شده است: سرِ صفحه، عرض و ریتم را قالب می‌دهد و محتوایش PageSectionهای شماست.

import { CustomPage, PageSection } from '@partodata/ui/templates'
import { TableOfContents } from '@partodata/ui'
import { BarChart3, FileText, Smile } from 'lucide-react'

const sections = [
  { id: 'summary', label: 'خلاصه', icon: <FileText /> },
  { id: 'trend', label: 'روند منشن‌ها', icon: <BarChart3 /> },
  { id: 'sentiment', label: 'احساس مخاطبان', icon: <Smile /> },
]

export default function WeeklyReportPage() {
  return (
    <CustomPage
      dsGap="DS-GAP-14: فهرست بخش‌های گزارش در ستون کناری صفحه"
      title="گزارش هفتگی کمپین"
      description="عملکرد کمپین تخفیف فصلی در هفتهٔ گذشته"
      width="wide"
    >
      <PageSection>
        {/* موبایل: فهرست بالای محتوا · از lg: ستون کناری چسبان در سمت شروع (راست) */}
        <div className="grid grid-cols-1 gap-6 lg:grid-cols-[220px_1fr]">
          <aside className="lg:sticky lg:top-20 lg:h-fit">
            <TableOfContents items={sections} offset={80} title="بخش‌های گزارش" />
          </aside>
          <div className="flex flex-col gap-8">
            <section id="summary">…</section>
            <section id="trend">…</section>
            <section id="sentiment">…</section>
          </div>
        </div>
      </PageSection>
    </CustomPage>
  )
}

الگوی 3 — جدول داده در موبایل

جداول داده در صفحات کوچک باید با اسکرول افقی قابل مشاهده باشند.

import { DataTable, type DataTableColumn } from '@partodata/ui'

// 1. جدول با scroll افقی برای موبایل
;<div className="overflow-x-auto">
  <DataTable columns={columns} data={data} />
</div>

// 2. پنهان کردن ستون‌های کم‌اهمیت در موبایل
const columns: DataTableColumn<Row>[] = [
  { id: 'name', header: 'نام', cell: (row) => row.name },
  {
    id: 'followers',
    header: 'دنبال‌کننده‌ها',
    cell: (row) => row.followers.toLocaleString('en-US'),
    // این ستون در موبایل پنهان می‌شود (با className)
    className: 'hidden sm:table-cell',
  },
  {
    id: 'engagement',
    header: 'تعامل',
    cell: (row) => `${row.engagement}٪`,
    className: 'hidden md:table-cell',
  },
  {
    id: 'status',
    header: 'وضعیت',
    cell: (row) => <Badge>{row.status}</Badge>,
  },
]

الگوی 4 — کارت‌های پروفایل ریسپانسیو

در موبایل، اطلاعات پروفایل به‌صورت عمودی و در دسکتاپ به‌صورت افقی نمایش داده می‌شوند.

import { Avatar, AvatarImage, AvatarFallback, Badge } from '@partodata/ui'
import { PlatformMark } from '@partodata/ui/social'

function InfluencerProfileHeader({ influencer }) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
      {/* اطلاعات اصلی */}
      <div className="flex items-center gap-4">
        <Avatar className="size-16">
          <AvatarImage src={influencer.avatar} />
          <AvatarFallback>{influencer.name[0]}</AvatarFallback>
        </Avatar>

        <div className="flex flex-col gap-1">
          <h1 className="text-display">{influencer.name}</h1>
          <div className="flex flex-wrap items-center gap-2">
            <PlatformMark source={influencer.platform} />
            <Badge variant="outline">@{influencer.username}</Badge>
          </div>
        </div>
      </div>

      {/* آمار — در موبایل زیر اطلاعات، در دسکتاپ کنار */}
      <div className="grid grid-cols-3 gap-4 rounded-lg border p-4 sm:flex sm:gap-8">
        <div className="text-center">
          <p className="text-stat">{influencer.followers.toLocaleString('en-US')}</p>
          <p className="text-xs text-foreground-lighter">دنبال‌کننده</p>
        </div>
        <div className="text-center">
          <p className="text-stat">{influencer.following.toLocaleString('en-US')}</p>
          <p className="text-xs text-foreground-lighter">دنبال‌کننده</p>
        </div>
        <div className="text-center">
          <p className="text-stat">{influencer.posts.toLocaleString('en-US')}</p>
          <p className="text-xs text-foreground-lighter">پست</p>
        </div>
      </div>
    </div>
  )
}

الگوی 5 — فیلترها و جستجو در موبایل

در موبایل، فیلترها در یک Drawer نمایش داده می‌شوند تا فضای صفحه را اشغال نکنند.

'use client'

import { useState } from 'react'
import {
  Button,
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerFooter,
  DrawerClose,
  SearchInput,
  ToggleGroup,
  ToggleGroupItem,
} from '@partodata/ui'
import { SlidersHorizontal } from 'lucide-react'

function SearchWithFilters() {
  const [filtersOpen, setFiltersOpen] = useState(false)
  const [activeFilters, setActiveFilters] = useState<string[]>([])

  const filterOptions = [
    { label: 'اینستاگرام', value: 'instagram' },
    { label: 'تیک‌تاک', value: 'tiktok' },
    { label: 'میکرو اینفلوئنسر', value: 'micro' },
    { label: 'ماکرو اینفلوئنسر', value: 'macro' },
  ]

  return (
    <div className="flex flex-col gap-3">
      {/* نوار جستجو + دکمه فیلتر */}
      <div className="flex gap-2">
        <SearchInput placeholder="جستجوی اینفلوئنسر..." className="flex-1" />
        {/* دکمه فیلتر — در موبایل نمایش داده می‌شود */}
        <Button variant="outline" size="sm" className="md:hidden" onClick={() => setFiltersOpen(true)}>
          <SlidersHorizontal className="size-4" />
          فیلترها
        </Button>
      </div>

      {/* فیلترهای inline — فقط در دسکتاپ */}
      <div className="hidden flex-wrap gap-2 md:flex">
        <ToggleGroup type="multiple" value={activeFilters} onValueChange={setActiveFilters}>
          {filterOptions.map((filter) => (
            <ToggleGroupItem key={filter.value} value={filter.value}>
              {filter.label}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </div>

      {/* Drawer فیلتر برای موبایل */}
      <Drawer open={filtersOpen} onOpenChange={setFiltersOpen} direction="bottom">
        <DrawerContent>
          <DrawerHeader>
            <DrawerTitle>فیلترها</DrawerTitle>
          </DrawerHeader>

          <div className="flex flex-wrap gap-2 p-4">
            <ToggleGroup type="multiple" value={activeFilters} onValueChange={setActiveFilters}>
              {filterOptions.map((filter) => (
                <ToggleGroupItem key={filter.value} value={filter.value}>
                  {filter.label}
                </ToggleGroupItem>
              ))}
            </ToggleGroup>
          </div>

          <DrawerFooter>
            <DrawerClose asChild>
              <Button variant="primary">اعمال فیلترها</Button>
            </DrawerClose>
          </DrawerFooter>
        </DrawerContent>
      </Drawer>
    </div>
  )
}

الگوی 6 — نمودارها در موبایل

نمودارها باید با ارتفاع کمتر در موبایل نمایش داده شوند تا اطلاعات اصلی قابل مشاهده باشند.

import { PartoLineChart } from '@partodata/ui'

// ارتفاع نمودار بر اساس اندازه صفحه
function ResponsiveChart({ data }) {
  return (
    <div>
      {/* موبایل: ارتفاع کمتر */}
      <div className="block sm:hidden">
        <PartoLineChart data={data} height={200} />
      </div>
      {/* تبلت و دسکتاپ: ارتفاع معمول */}
      <div className="hidden sm:block">
        <PartoLineChart data={data} height={350} />
      </div>
    </div>
  )
}

نکات مهم

چه چیزی باید در موبایل پنهان شود؟

المانموبایلتبلتدسکتاپ
ستون‌های فرعی جدولپنهاننیمه‌پنهاننمایش
منوی اصلی (قاب)Drawer—ثابت
فهرست کناری صفحهبالای محتوا—کنار محتوا
فیلترهای inlineDrawerنیمهنمایش
نوار اعمال دسته‌ایBottom barToolbarToolbar
آمار پروفایلعمودیافقیافقی

از useIsMobile() با احتیاط استفاده کنید

این hook در سرور و اولین رندر کلاینت false برمی‌گرداند (مقدار داخلی پیش از mount با !! به false تبدیل می‌شود) و پس از mount مقدار واقعی را می‌دهد. یعنی دستگاه موبایل ابتدا برای یک فریم شاخهٔ دسکتاپ را رندر می‌کند:

import { useIsMobile } from '@partodata/ui'

function FiltersArea() {
  const isMobile = useIsMobile()

  // در SSR و اولین paint، مقدار همیشه false است؛
  // کاربر موبایل یک فریم FiltersInline را می‌بیند (فلش کوتاه).
  return isMobile ? <FiltersDrawer /> : <FiltersInline />
}

برای نمایش/پنهان‌سازی صرفاً بصری، به‌جای این hook از کلاس‌های breakpoint استفاده کنید (md:hidden، hidden md:flex) که در SSR هم بدون فلش درست رندر می‌شوند. useIsMobile() را برای تفاوت‌های رفتاری نگه دارید که با CSS قابل بیان نیستند (مانند Drawer در برابر فیلتر inline در الگوی 5) و فلش یک‌فریمی را بپذیرید.

کلاس‌های RTL-safe برای responsive

// WRONG — physical
<div className="ml-4 sm:ml-0">

// CORRECT — logical (RTL-safe)
<div className="ms-4 sm:ms-0">

چه زمانی از کدام الگو استفاده کنید

  • جدول داده — همیشه overflow-x-auto + ستون‌های پنهان برای موبایل
  • فیلترها — Drawer در موبایل، inline در دسکتاپ
  • پروفایل — چیدمان عمودی در موبایل، افقی در دسکتاپ
  • داشبورد — شبکه 1/2/4 ستونه بر اساس breakpoint
  • نمودارها — ارتفاع کمتر در موبایل

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

اشتباهات پرتکراری که در پیاده‌سازی صفحات ریسپانسیو دیده می‌شوند — هر مورد شامل اشتباه، دلیل، و الگوی درست است.

خواص فیزیکی CSS در واریانت‌های breakpoint

اشتباه: کپی‌کردن نمونه‌های LTR که کلاس‌های فیزیکی را پشت پیشوند breakpoint پنهان می‌کنند — مانند md:ml-auto، lg:pl-6 یا sm:text-left.

// ❌ غلط — فیزیکی؛ فقط در نمای دسکتاپِ RTL خراب می‌شود و در تست موبایل دیده نمی‌شود
<div className="flex flex-col md:flex-row">
  <Button variant="primary" className="md:ml-auto">
    ذخیره گزارش کمپین
  </Button>
</div>

// ✅ درست — خواص منطقی (logical) در همهٔ breakpointها
<div className="flex flex-col md:flex-row">
  <Button variant="primary" className="md:ms-auto">
    ذخیره گزارش کمپین
  </Button>
</div>

چرا دردسرساز است: کلاس فیزیکی‌ای که پشت پیشوند md: یا lg: پنهان شده فقط در همان عرض فعال می‌شود؛ اگر بازبینی فقط در نمای موبایل انجام شود، دکمه در دسکتاپ RTL به سمت اشتباه هل داده می‌شود و هیچ‌کس متوجه نمی‌شود. قانون سیستم طراحی «RTL Native» است: همیشه ms/me/ps/pe/text-start/text-end — در همهٔ واریانت‌های ریسپانسیو، نه فقط در کلاس‌های پایه. (در خود کتابخانه این قانون با قاعدهٔ ESLint سفارشی no-physical-css-properties اجباری شده است؛ در کد مصرف‌کننده باید خودتان مراقب باشید.)

رنگ hardcoded در المان‌های مخصوص موبایل

اشتباه: نوار موبایل‌فقطِ یک صفحه (پشت md:hidden) با رنگ ثابت مانند bg-white یا border-gray-200 استایل می‌شود، چون «فقط یک نوار کوچک است».

// ❌ غلط — رنگ ثابت؛ در تم تیرهٔ پیش‌فرض، یک نوار سفید ناهماهنگ ظاهر می‌شود
<div className="flex items-center gap-2 border-b border-gray-200 bg-white p-4 md:hidden">
  <Button variant="default">فیلترها</Button>
</div>

// ✅ درست — توکن‌های معنایی؛ در هر دو تم درست رندر می‌شود
<div className="flex items-center gap-2 border-b border-border bg-background p-4 md:hidden">
  <Button variant="default">فیلترها</Button>
</div>

چرا دردسرساز است: تم پایهٔ پرتو تیره است — :root بدون هیچ تنظیمی توکن‌های تیره را حمل می‌کند و روشن انتخاب صریح است. یعنی bg-white دقیقاً در حالت پیش‌فرض هر مصرف‌کننده، و دقیقاً در نمای موبایل که کمتر بازبینی می‌شود، یک المان با تم مخالف نمایش می‌دهد. المان‌های breakpoint-فقط استثنا نیستند: قانون «بدون رنگ hardcoded» شامل آن‌ها هم می‌شود.

mount دوگانهٔ کامپوننت‌های داده‌محور در دو breakpoint

اشتباه: الگوی «دو بار رندر، یکی پنهان» (block sm:hidden / hidden sm:block) که برای نمودار presentational مناسب است، برای کامپوننتی که خودش داده fetch می‌کند نیز کپی می‌شود.

// ❌ غلط — هر دو نمونه mount می‌شوند: دو بار fetch و دو state صفحه‌بندی جداگانه
<div className="sm:hidden">
  <CampaignPostsTable pageSize={10} />
</div>
<div className="hidden sm:block">
  <CampaignPostsTable pageSize={50} />
</div>

// ✅ درست — یک ListPage با fetch واحد و صفحه‌بندی قالب؛ تفاوت نمایش با className ستون‌ها (الگوی 3)
<ListPage title="پست‌های کمپین" state={state} pagination={pagination}>
  <DataTable columns={columns} data={rows} />
</ListPage>

چرا دردسرساز است: کلاس hidden فقط نمایش را حذف می‌کند، نه mount را؛ هر دو نمونه درخواست شبکه می‌فرستند (دوبرابر شدن بار سرور) و state جداگانه نگه می‌دارند — کاربر در موبایل به صفحهٔ 3 می‌رود، دستگاه را می‌چرخاند و ناگهان صفحهٔ 1 نمای دسکتاپ را می‌بیند. رندر دوگانه فقط برای کامپوننت‌های presentational خالص (مانند نمودار الگوی 6 که تنها prop ارتفاعش فرق دارد) قابل قبول است — و حتی آن‌جا هم fetch داده باید بیرون از دو شاخه انجام شود.

برش سمت کلاینت به‌جای صفحه‌بندی سرور در نمای کارت موبایل

اشتباه: در موبایل، جدول با یک لیست کارت جایگزین می‌شود که فقط rows.slice(0, 10) را نشان می‌دهد و صفحه‌بندی حذف می‌شود. (اینجا ردیف‌ها پست‌اند، از مدل SocialPost؛ فهرست پست در موبایل یک EntityCollection از Post است.)

// ❌ غلط — نمای موبایل برای همیشه در 10 ردیف اولِ همان یک صفحه‌ای که سرور برگردانده گیر می‌کند
{
  isMobile ? (
    rows.slice(0, 10).map((post) => <Post key={post.id} post={post} />)
  ) : (
    <DataTable columns={columns} data={rows} pagination={pagination} />
  )
}

// ✅ درست — fetch، state و صفحه‌بندی مال ListPage است، بیرون از دو شاخه؛ دو شاخه فقط نمایش‌اند
;<ListPage
  title="پست‌های کمپین"
  state={state}
  pagination={{ currentPage: page, totalPages, onPageChange: setPage, totalRows, pageSize: 25 }}
>
  <div className="sm:hidden">
    <EntityCollection
      entity="post"
      items={rows}
      getId={(post) => post.id}
      layouts={['card']}
      label="پست‌ها"
      renderItem={(post, item) => <Post post={post} {...item} />}
    />
  </div>
  <div className="hidden sm:block">
    <DataTable columns={columns} data={rows} />
  </div>
</ListPage>

چرا دردسرساز است: DataTable سیستم طراحی سرور-محور است — طبق مستندات خودِ کامپوننت، totalRows را «طبق گزارش سرور» می‌گیرد چون از دادهٔ یک صفحه قابل استنتاج نیست. وقتی نمای موبایل به برش کلاینتی تنزل پیدا کند، کاربر موبایل فقط بخشی از اولین صفحهٔ دریافتی را می‌بیند؛ جستجو و مرتب‌سازی‌ای که سرور اعمال کرده با آنچه موبایل نشان می‌دهد واگرا می‌شود و دو کاربر روی دو دستگاه به دو «کل نتایج» متفاوت می‌رسند. الگوی درست همان است که در دام قبلی گفته شد: state صفحه‌بندی بالا نگه داشته می‌شود و دو شاخه فقط presentation هستند.

فارسی محاوره‌ای برای کوتاه‌کردن متن موبایل

اشتباه: برای صرفه‌جویی در فضای موبایل، برچسب‌ها به فارسی محاوره‌ای کوتاه می‌شوند — «اعمال کن»، «بی‌خیال».

// ❌ غلط — تغییر لحن؛ محاوره‌ای در موبایل، رسمی در دسکتاپ
<Button variant="primary" className="sm:hidden">
  اعمال کن
</Button>

// ✅ درست — کوتاه‌سازی با حذف واژه، نه حذف رسمیت
<Button variant="primary" className="sm:hidden">
  اعمال فیلترها
</Button>

// ✅ درست — یا فقط آیکون با برچسب دسترس‌پذیر کامل
<Button icon={<Check />} className="sm:hidden" aria-label="اعمال فیلترها" />

چرا دردسرساز است: قانون محتوایی سیستم طراحی، فارسی رسمی در همهٔ متن‌های محصول است («استفاده کنید» نه «استفاده کن») و breakpoint این قانون را تغییر نمی‌دهد. کاربری که در دسکتاپ لحن رسمی و در موبایل لحن محاوره‌ای می‌بیند، محصول را ناسازگار و غیرحرفه‌ای حس می‌کند. کوتاه‌سازی درست از حذف واژه‌های قابل حذف یا استفاده از آیکون با aria-label کامل به دست می‌آید، نه از تغییر register زبانی.


صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • صفحهٔ جدول داده — اگر جدول ریسپانسیو شما بخشی از یک صفحهٔ لیست کامل است (جستجو، فیلتر، صفحه‌بندی سرور)، الگوی کامل آن صفحه — از state تا حالت خالی — آن‌جا آمده است.
  • ترکیب داشبورد — اگر شبکهٔ 1/2/4 ستونهٔ الگوی 1 را برای ساخت یک داشبورد کامل سوشال لیسنینگ می‌خواهید، ترتیب و ترکیب بخش‌ها را از آن الگو بگیرید.
  • مودالیتی — اگر مطمئن نیستید فیلترهای موبایل باید در Drawer باز شوند یا Sheet یا Dialog، راهنمای انتخاب بین سطوح مودال آن‌جاست.
  • قاب محصول (ProductFrame) — منوی اصلی محصول و رفتار ریسپانسیو آن را خودش دارد؛ الگوی 2 فقط برای نوار کناری درون یک صفحه است.

فیلترِ قابل‌تغییر، `FilterChip` نیست

FilterChip یک <span> غیرتعاملی است: label اجباری دارد، فرزندانش را نادیده می‌گیرد، و cursor-default است. نسخهٔ پیشین این صفحه آن را با active و onClick به‌عنوان toggle به کار می‌برد — نتیجه چیپ‌های کاملاً خالی بود که با صفحه‌کلید هم دسترس‌پذیر نبودند. برای انتخاب چندگانه ToggleGroup با type="multiple" را به کار ببرید؛ FilterChip برای نمایش فیلترِ اعمال‌شده با دکمهٔ حذف (label + onRemove) است.