پرتوپرتو

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

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

مقدمه

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


نقاط شکست (Breakpoints)

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

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

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

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

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
  <MetricCard>
    <MetricCardHeader>
      <MetricCardTitle>فالوورها</MetricCardTitle>
    </MetricCardHeader>
    <MetricCardContent>
      <MetricCardValue>۱۲۵K</MetricCardValue>
    </MetricCardContent>
  </MetricCard>
  <MetricCard>
    <MetricCardHeader>
      <MetricCardTitle>تعامل</MetricCardTitle>
    </MetricCardHeader>
    <MetricCardContent>
      <MetricCardValue>۴.۲٪</MetricCardValue>
    </MetricCardContent>
  </MetricCard>
  {/* ... */}
</div>

الگوی ۲ — سایدبار ریسپانسیو

در موبایل، سایدبار به‌صورت کشویی (Sheet) نمایش داده می‌شود. در دسکتاپ، در کنار محتوا قرار می‌گیرد.

import {
  SidebarProvider,
  Sidebar,
  SidebarTrigger,
  SidebarInset,
  SidebarContent,
  SidebarMenu,
  SidebarMenuItem,
  SidebarMenuButton,
} from '@parto-system-design/ui'
import { BarChart2, Users, Settings } from 'lucide-react'

export function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <Sidebar>
        <SidebarContent>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton isActive>
                <BarChart2 />
                داشبورد
              </SidebarMenuButton>
            </SidebarMenuItem>
            <SidebarMenuItem>
              <SidebarMenuButton>
                <Users />
                اینفلوئنسرها
              </SidebarMenuButton>
            </SidebarMenuItem>
            <SidebarMenuItem>
              <SidebarMenuButton>
                <Settings />
                تنظیمات
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarContent>
      </Sidebar>

      <SidebarInset>
        {/* دکمه باز/بسته کردن سایدبار فقط در موبایل نمایش داده می‌شود */}
        <header className="flex items-center gap-2 border-b p-4 md:hidden">
          <SidebarTrigger />
          <span className="font-semibold">پارتو</span>
        </header>

        <main className="flex-1 p-4 md:p-6">{children}</main>
      </SidebarInset>
    </SidebarProvider>
  )
}

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

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

import { DataTable, type DataTableColumn } from '@parto-system-design/ui'

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

// ۲. پنهان کردن ستون‌های کم‌اهمیت در موبایل
const columns: DataTableColumn<Row>[] = [
  { id: 'name', header: 'نام', cell: (row) => row.name },
  {
    id: 'followers',
    header: 'فالوورها',
    cell: (row) => row.followers.toLocaleString('fa-IR'),
    // این ستون در موبایل پنهان می‌شود (با 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>,
  },
]

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

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

import { Avatar, AvatarImage, AvatarFallback, Badge, SocialPlatformBadge } from '@parto-system-design/ui'

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 size="xl">
          <AvatarImage src={influencer.avatar} />
          <AvatarFallback>{influencer.name[0]}</AvatarFallback>
        </Avatar>

        <div className="flex flex-col gap-1">
          <h1 className="text-xl font-bold">{influencer.name}</h1>
          <div className="flex flex-wrap items-center gap-2">
            <SocialPlatformBadge platform={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-lg font-bold">{influencer.followers.toLocaleString('fa-IR')}</p>
          <p className="text-xs text-foreground-muted">فالوور</p>
        </div>
        <div className="text-center">
          <p className="text-lg font-bold">{influencer.following.toLocaleString('fa-IR')}</p>
          <p className="text-xs text-foreground-muted">دنبال‌شونده</p>
        </div>
        <div className="text-center">
          <p className="text-lg font-bold">{influencer.posts.toLocaleString('fa-IR')}</p>
          <p className="text-xs text-foreground-muted">پست</p>
        </div>
      </div>
    </div>
  )
}

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

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

'use client'

import { useState } from 'react'
import {
  Button,
  Drawer,
  DrawerContent,
  DrawerHeader,
  DrawerTitle,
  DrawerFooter,
  DrawerClose,
  SearchInput,
  FilterChip,
} from '@parto-system-design/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">
        {filterOptions.map((filter) => (
          <FilterChip
            key={filter.value}
            active={activeFilters.includes(filter.value)}
            onClick={() =>
              setActiveFilters((prev) =>
                prev.includes(filter.value) ? prev.filter((f) => f !== filter.value) : [...prev, filter.value]
              )
            }
          >
            {filter.label}
          </FilterChip>
        ))}
      </div>

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

          <div className="flex flex-wrap gap-2 p-4">
            {filterOptions.map((filter) => (
              <FilterChip
                key={filter.value}
                active={activeFilters.includes(filter.value)}
                onClick={() =>
                  setActiveFilters((prev) =>
                    prev.includes(filter.value) ? prev.filter((f) => f !== filter.value) : [...prev, filter.value]
                  )
                }
              >
                {filter.label}
              </FilterChip>
            ))}
          </div>

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

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

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

import { PartoLineChart } from '@parto-system-design/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 در سرور undefined برمی‌گرداند. همیشه مقدار را بررسی کنید:

import { useIsMobile } from '@parto-system-design/ui'

function MyComponent() {
  const isMobile = useIsMobile()

  // WRONG — ممکن است در SSR خطا بدهد
  if (isMobile) { ... }

  // CORRECT — undefined را در نظر بگیرید
  if (isMobile === true) { ... }
  if (isMobile !== false) { ... } // شامل undefined می‌شود
}

کلاس‌های 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 در دسکتاپ
  • پروفایل — چیدمان عمودی در موبایل، افقی در دسکتاپ
  • داشبورد — شبکه ۱/۲/۴ ستونه بر اساس breakpoint
  • نمودارها — ارتفاع کمتر در موبایل