ناوبری

الگوهای ناوبری پرتو — منوی اصلی ProductFrame، ناوبری محلی با Tabs و NavMenu، Breadcrumb، و اصول طراحی ناوبری

ناوبری خوب به کاربران کمک می‌کند بدانند کجا هستند، کجا می‌توانند بروند، و چطور برگردند. این صفحه الگوها، کامپوننت‌ها، و بهترین روش‌های ناوبری در پرتو را توضیح می‌دهد.


نمونه بصری

مسیریاب — Breadcrumb

تب‌ها — Tabs

محتوای منشن‌ها

معماری ناوبری

پرتو از سه سطح ناوبری استفاده می‌کند:

سطحکامپوننتکاربرد
ناوبری اصلیProductFrameدسترسی به بخش‌های اصلی اپلیکیشن
ناوبری محلیNavMenu / Tabsجابجایی بین view های یک بخش
ناوبری مکانیBreadcrumbنمایش موقعیت و مسیر برگشت

ناوبری اصلی: منوی ProductFrame

منوی اصلی هر محصول را ProductFrame از روی داده می‌سازد، یک بار در layout ریشه: گروه‌ها، آیتم‌ها با href، یک سطح زیرمجموعه (children) و نشان (badge). آیتم فعال را خود قاب از pathname پیدا می‌کند (بخش‌های کامل مسیر، طولانی‌ترین href)، پس هیچ صفحه‌ای isActive را دستی حساب نمی‌کند. در RTL منو سمت راست (start) است.

import { ProductFrame, type ProductFrameNavGroup } from '@partodata/ui/product-frame'

const nav: ProductFrameNavGroup[] = [
  {
    id: 'main',
    items: [
      { id: 'dashboard', label: 'داشبورد', icon: <LayoutDashboard />, href: '/' },
      { id: 'influencers', label: 'اینفلوئنسرها', icon: <Users />, href: '/influencers' },
      { id: 'analytics', label: 'تحلیل‌ها', icon: <BarChart2 />, href: '/analytics' },
    ],
  },
  { id: 'system', label: 'سیستم', items: [{ id: 'settings', label: 'تنظیمات', icon: <Settings />, href: '/settings' }] },
]

<ProductFrame product={{ name: 'پایش برند' }} nav={nav} pathname={pathname} linkComponent={Link}>
  {children}
</ProductFrame>

قاب را با NavRail یا نوار دست‌ساز نسازید؛ نسخهٔ کامل App Router (فایل‌های frame.tsx و layout.tsx) در صفحهٔ ProductFrame آمده است.

ناوبری کناری درون یک صفحه

منوی اصلی برنامه را ProductFrame می‌سازد. برای ناوبری کناریِ درون یک صفحه، مثل فهرست بخش‌های یک گزارش بلند، TableOfContents را (با title={null}) در یک PageSection بگذارید: بخش فعال را با اسکرول دنبال می‌کند، روی موبایل بالای محتوا و از lg ستون کناری چسبان در سمت شروع (راست) است.

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>
  )
}

ناوبری محلی زیر سربرگ

برای جابجایی بین view‌های مرتبط در یک بخش — مثل تب‌های یک پروفایل — از Tabs استفاده کنید و آن را داخل PageHeaderNavigationTabs بگذارید تا به لبهٔ پایین سربرگ چسبیده و هم‌تراز شود.

import { , , , ,  } from '@partodata/ui'
;<>
  <>
    < ="overview">
      <>
        < ="overview">خلاصه</>
        < ="posts">پست‌ها</>
        < ="analytics">تحلیل</>
        < ="campaigns">کمپین‌ها</>
      </>
      < ="overview">…</>
    </>
  </>
</>

`NavMenu` وجود ندارد

نسخهٔ پیشین این بخش کامپوننتی به نام NavMenu/NavMenuItem را مستند می‌کرد که هیچ‌گاه در سیستم طراحی وجود نداشته — نه در src/index.ts و نه در مانیفست. هر کسی آن کد را کپی می‌کرد با خطای «Element type is invalid» روبرو می‌شد. برای ناوبری محلی Tabs و برای منوی سلسله‌مراتبی منوی ProductFrame (یک سطح زیرمنو و منوی دوم secondaryNav) یا NavTree را به کار ببرید.


برای نمایش مسیر فعلی و امکان ناوبری به سطوح بالاتر. معمولاً در PageHeaderBreadcrumb قرار می‌گیرد.

import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from '@partodata/ui'
;<Breadcrumb>
  <BreadcrumbItem>
    <BreadcrumbLink href="/campaigns">کمپین‌ها</BreadcrumbLink>
  </BreadcrumbItem>
  <BreadcrumbSeparator />
  <BreadcrumbItem>
    <BreadcrumbLink href="/campaigns/summer-2024">تابستان 1403</BreadcrumbLink>
  </BreadcrumbItem>
  <BreadcrumbSeparator />
  <BreadcrumbItem>
    <BreadcrumbPage>اینفلوئنسرها</BreadcrumbPage>
  </BreadcrumbItem>
</Breadcrumb>

قوانین Breadcrumb

  • صفحه فعلی (BreadcrumbPage) لینک ندارد — کاربر هم‌اکنون آنجاست
  • سطح اول (خانه) را فقط در صورت نیاز نمایش دهید — اگر صفحه در سطح عمیق‌تر از 2 باشد
  • بیش از 3-4 سطح نمایش ندهید — اگر عمق بیشتر بود، سطوح میانی را با ... کوتاه کنید

برای ناوبری ثانویه یا گزینه‌های کاربر در هدر:

import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
} from '@partodata/ui'
import { User, Settings, LogOut } from 'lucide-react'
;<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="ghost" size="sm">
      <Avatar src={user.avatar} />
      <span>{user.name}</span>
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent align="end">
    <DropdownMenuItem asChild>
      <Link href="/profile">
        <User className="me-2 h-4 w-4" />
        پروفایل
      </Link>
    </DropdownMenuItem>
    <DropdownMenuItem asChild>
      <Link href="/settings">
        <Settings className="me-2 h-4 w-4" />
        تنظیمات
      </Link>
    </DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem onClick={handleLogout} className="text-destructive">
      <LogOut className="me-2 h-4 w-4" />
      خروج از حساب
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

Pagination

برای ناوبری بین صفحات نتایج جستجو یا لیست‌های طولانی:

import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from '@partodata/ui'
;<Pagination>
  <PaginationContent>
    <PaginationItem>
      <PaginationPrevious href={`?page=${currentPage - 1}`} />
    </PaginationItem>
    {pages.map((page) => (
      <PaginationItem key={page}>
        <PaginationLink href={`?page=${page}`} isActive={page === currentPage}>
          {page}
        </PaginationLink>
      </PaginationItem>
    ))}
    <PaginationItem>
      <PaginationNext href={`?page=${currentPage + 1}`} />
    </PaginationItem>
  </PaginationContent>
</Pagination>

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

نمایش حالت فعال

همیشه آیتم فعال را مشخص کنید — کاربر باید بداند کجاست. در منوی اصلی pathname را به ProductFrame بدهید تا آیتم فعال را خودش پیدا کند؛ آن را دستی حساب نکنید:

// app/frame.tsx — با pathname از Next.js
const pathname = usePathname()

<ProductFrame product={product} nav={nav} pathname={pathname} linkComponent={Link}>
  {children}
</ProductFrame>

آیکون‌ها در ناوبری

آیکون‌ها خوانایی ناوبری را بهبود می‌دهند — اما باید همیشه همراه متن باشند:

// درست — آیکون + متن
<NavItem href="/dashboard" icon={LayoutDashboard}>
  داشبورد
</NavItem>

// نادرست — آیکون تنها (بدون متن، بدون aria-label)
<a href="/dashboard">
  <LayoutDashboard />
</a>

ناوبری با صفحه‌کلید

  • Tab / Shift+Tab: جابجایی بین آیتم‌های ناوبری
  • Enter / Space: فعال‌سازی لینک
  • ↑ ↓: ناوبری در dropdown menu
  • Escape: بستن dropdown

عمق ناوبری

از ساختار سه‌لایه استفاده کنید — بیشتر از آن گیج‌کننده است:

منوی ProductFrame (اصلی)
  └── NavMenu / Tabs (محلی)
         └── Breadcrumb (مکانی)

دام‌های رایج

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

خواص فیزیکی CSS در آیتم‌های ناوبری

اشتباه: جابه‌جا کردن شمارنده یا نشان به انتهای آیتم سایدبار با ml-auto، یا تورفتگی زیرمنو با pl-4 — معمولاً با کپی‌کردن نمونه‌های LTR.

// نادرست — margin فیزیکی؛ در RTL شمارنده به‌جای لبهٔ انتهایی کنار متن می‌چسبد
<Link href="/campaigns" className="flex items-center gap-2">
  <Megaphone />
  <span>کمپین‌ها</span>
  <Badge className="ml-auto">12</Badge>
</Link>

// درست — Logical Properties در هر دو جهت درست کار می‌کند
<Link href="/campaigns" className="flex items-center gap-2">
  <Megaphone />
  <span>کمپین‌ها</span>
  <Badge className="ms-auto">12</Badge>
</Link>

چرا دردسرساز است: سایدبار پرتو در لبهٔ آغازین (start) صفحه قرار می‌گیرد و چیدمان flex آن از dir سند پیروی می‌کند، اما margin و padding فیزیکی از جهت صفحه پیروی نمی‌کنند — نتیجه، آیتمی است که در پیش‌نمایش LTR درست به نظر می‌رسد و در محصول RTL به سمت اشتباه می‌چسبد. سیستم طراحی RTL-first است و در کد خودش قانون no-physical-css-properties را با ESLint اجبار می‌کند؛ در کد مصرف‌کننده نیز همیشه ms/me/ps/pe را به‌کار ببرید. برای شمارندهٔ یک آیتم منو، badge خود NavItem (و badge هر پیوند منوی ProductFrame) در لبهٔ انتهایی قرار می‌گیرد.

استایل‌دهی دستی آیتم فعال با رنگ hardcode

اشتباه: مشخص‌کردن آیتم فعال با کلاس رنگ مستقیم به‌جای پراپ isActive.

// نادرست — رنگ hardcode؛ در تم تیرهٔ پیش‌فرض کنتراست ندارد و aria-current هم تنظیم نمی‌شود
<NavItem href="/dashboard" icon={LayoutDashboard} className={pathname === '/dashboard' ? 'bg-gray-100 text-blue-600' : ''}>
  داشبورد
</NavItem>

// درست — active هم استایل توکن‌محور می‌دهد و هم aria-current="page"
<NavItem href="/dashboard" icon={LayoutDashboard} active={pathname === '/dashboard'}>
  داشبورد
</NavItem>

چرا دردسرساز است: سیستم طراحی dark-first است — تم پایهٔ :root تیره است و bg-gray-100 روی آن به لکه‌ای روشن و ناسازگار تبدیل می‌شود که با تغییر تم هم اصلاح نمی‌شود. پراپ isActive استایل فعال را از طریق ویژگی data-active و توکن‌های --sidebar-accent اعمال می‌کند که در هر دو تم مقدار درست دارند، و علاوه بر آن aria-current="page" را نیز روی آیتم تنظیم می‌کند تا صفحه‌خوان‌ها موقعیت فعلی کاربر را اعلام کنند — با رنگ دستی هر دو را از دست می‌دهید.

آیکون جهت‌دار دستی به‌جای جداکنندهٔ پیش‌فرض

اشتباه: جایگزین‌کردن جداکنندهٔ پیش‌فرض Breadcrumb با آیکون جهت‌دار ثابت، با این استدلال که «در RTL فلش باید رو به چپ باشد».

// نادرست — جهت آیکون ثابت شده و فقط در یکی از دو جهت درست است
<BreadcrumbSeparator>
  <ChevronLeft />
</BreadcrumbSeparator>

// درست — جداکنندهٔ پیش‌فرض خودش جهت‌آگاه است
<BreadcrumbSeparator />

چرا دردسرساز است: جداکنندهٔ پیش‌فرض جهت را از خود فهرست می‌گیرد (dir روی BreadcrumbList، پیش‌فرض rtl): در راست‌به‌چپ ChevronLeft و در چپ‌به‌راست ChevronRight، پس در هر دو جهت درست است، حتی مسیر انگلیسی داخل صفحهٔ فارسی. هر children که بدهید جایگزین کامل آن می‌شود و این انتخاب را از دست می‌دهد. همین اصل دربارهٔ Pagination نیز برقرار است: PaginationPrevious و PaginationNext آیکون خود را بر اساس جهت سند در جاوااسکریپت جابه‌جا می‌کنند (چون شماره‌های صفحه عمداً dir="ltr" دارند و واریانت‌های rtl: روی آن‌ها اثر ندارند) — آیکون جهت‌دار دستی به آن‌ها اضافه نکنید. اگر جداکنندهٔ متفاوتی لازم دارید، از نویسهٔ بدون جهت مانند «/» استفاده کنید؛ آیکون جهت‌دار سفارشی با data-direction جداکننده بچرخد (className="in-data-[direction=rtl]:rotate-180")، نه با rtl: که جهت سند را می‌خواند.

محاسبهٔ تعداد صفحات از طول آرایهٔ صفحهٔ فعلی

اشتباه: ساختن شماره‌های Pagination از rows.length وقتی rows فقط ردیف‌های صفحهٔ فعلی است که سرور برگردانده.

// نادرست — rows فقط 20 ردیف صفحهٔ فعلی است؛ همیشه «صفحهٔ 1 از 1» محاسبه می‌شود
const totalPages = Math.ceil(rows.length / pageSize)

// درست — تعداد کل را سرور گزارش می‌کند
const totalPages = Math.ceil(data.totalCount / pageSize)

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

لحن محاوره‌ای در برچسب‌های ناوبری

اشتباه: نوشتن برچسب منوها و لینک‌های بازگشت با فارسی محاوره‌ای.

// نادرست — لحن محاوره‌ای و جملهٔ دستوری
<BreadcrumbLink href="/campaigns">برگرد به کمپین‌ها</BreadcrumbLink>

// درست — فارسی رسمی؛ برچسب Breadcrumb نام مقصد است، نه جمله
<BreadcrumbLink href="/campaigns">کمپین‌ها</BreadcrumbLink>

چرا دردسرساز است: برچسب‌های ناوبری پرتکرارترین متن محصول‌اند و کاربر سازمانی در هر جلسه بارها آن‌ها را می‌بیند؛ لحن محاوره‌ای در همین نقطه‌ها بیش از هر جای دیگر به چشم می‌آید و اعتماد را کم می‌کند. زبان سیستم طراحی در همهٔ محصولات پرتو فارسی رسمی است («بازگشت» نه «برگرد»، «مشاهده کنید» نه «ببین»). راهنمای کامل لحن در صفحهٔ محتوا و لحن آمده است.


صفحات مرتبط

  • اشتباهات رایج — فهرست کامل ضدالگوهای سراسری (رنگ hardcode، خواص فیزیکی CSS و …)؛ دام‌های این صفحه نمونه‌های همان ریشه‌ها در این الگو هستند.
  • قاب محصول (ProductFrame) — منوی اصلی، نوار بالا و ناحیهٔ محتوای هر محصول، یک بار در layout ریشه؛ این اجزا را دستی سرهم نکنید.
  • TableOfContents — فهرست بخش‌های یک صفحهٔ بلند، با دنبال‌کردن بخش فعال.
  • Tabs — اگر view ها آدرس مستقل در URL ندارند و فقط محتوای یک صفحه را تقسیم می‌کنند، به‌جای NavMenu از Tabs استفاده کنید.
  • صفحهٔ جدول داده — وقتی مقصد ناوبری یک صفحهٔ لیست است، صفحه‌بندی و مرتب‌سازی سرور-محور را از این الگو بردارید.