قاب محصول (ProductFrame)

قاب واحد همهٔ برنامه‌های داده‌ای پرتو — نوار بالا، منوی داده‌محور و ناحیهٔ محتوا؛ یک بار، در layout بخش واردشدهٔ برنامه

معرفی

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

تصمیم: هر محصول ProductFrame را دقیقاً یک بار، در layout بخش واردشدهٔ برنامه به کار می‌برد

همهٔ صفحه‌هایی که کاربرِ واردشده در آن‌ها کار می‌کند زیر همین یک قاب رندر می‌شوند و هیچ صفحه‌ای قاب خودش را نمی‌سازد. در Next.js این همان layout ریشه است؛ اگر مسیرهایی بیرون از قاب دارید (ورود، منبع PDF، اسلاید)، layout گروه مسیری است که صفحه‌های برنامه در آن‌اند (مسیرهای بیرون از قاب). قاب دست‌ساز از NavRail نسازید (AppShell، Sidebar و AppBar در 5.0 حذف شدند).

قاعده‌هایی که قاب خودش اجرا می‌کند، و محصول هیچ‌کدام را نمی‌نویسد:

  • یک ارتفاع برای نوار بالا (--layout-header-height، 48 پیکسل). نوار بالا هیچ‌وقت عنوان صفحه را نشان نمی‌دهد؛ عنوان، h1 خود صفحه است که قالب صفحه‌اش آن را می‌سازد، و هر صفحه فقط یکی دارد.
  • انتهای نوار بالا یک ترتیب ثابت دارد: اقدام‌های سراسری (actions)، کلید تم و در آخر منوی حساب (user)، همه هم‌قد. کلید تم یک جای ثابت دارد و قاب آن را می‌گذارد (themeToggle).
  • منو در سمت شروع خط (راست): به‌طور پیش‌فرض نوار آیکونی 48 پیکسلی (navMode="rail") که با اشاره‌گر یا فوکوس صفحه‌کلید روی صفحه باز می‌شود و صفحه جابه‌جا نمی‌شود، یا منوی برچسب‌دار 256 پیکسلی که باز شروع می‌شود (navMode="menu")، یا بدون منو و فقط نوار بالا (navMode="none"). دکمهٔ سنجاق پایین نوار («نوار ناوبری همیشه باز») آن را باز نگه می‌دارد و انتخاب کاربر در مرورگر می‌ماند. روی موبایل منو پنلی است که از سمت شروع (راست) باز می‌شود.
  • همهٔ مقصدها لینک واقعی‌اند؛ مقصد فعال از pathname پیدا می‌شود و aria-current="page" می‌گیرد.
  • مقصدهای پرتعداد را در گروه‌های نام‌دارِ منوی اصلی مرتب کنید و ستون دوم را برای فیلترهای صفحه آزاد بگذارید؛ زیرصفحه‌های دارای جریان کار مستقل می‌توانند ناوبری ثانویه داشته باشند. نقش ستون دوم می‌تواند بین صفحه‌های یک محصول عوض شود: یک صفحه ناوبری و صفحه‌ای دیگر فیلتر. در همان صفحه ترکیب ناوبری بالا و فیلتر پایین ممنوع است؛ با ناوبری، فیلترها نوار افقی بالای محتوا و در عرض کم Sheet هستند؛ بدون آن، ستون آزاد می‌تواند فیلتر بگیرد. وجود فیلتر دلیل افزودن زیرمنو نیست.
  • ناحیهٔ محتوا هیچ padding و هیچ سقف عرضی ندارد. عرض و حاشیه را قالب صفحه یک بار می‌دهد، پس حاشیهٔ دوبل دیگر ممکن نیست.
  • پس‌زمینهٔ محتوا نقش canvas دارد؛ سطح روشن‌ترِ جدول، کارت و نمودار از آن جداست. منو و سربرگ رنگِ ناوبری خود را حفظ می‌کنند. در روشن، بوم خاکستری ملایم و سطوح محصور سفیدند؛ رنگ‌های تیره تغییر نمی‌کنند.
  • لندمارک‌های header، nav و main (و banner به‌صورت ناحیهٔ نام‌دار)، و پیوند «پرش به محتوای اصلی» به‌عنوان اولین توقف Tab؛ همه به زبان locale قاب (فارسی، عربی یا انگلیسی).
  • چاپ: فقط خود صفحه، روان در هر چند برگی که لازم دارد، با پالت روشن، زیر سربرگ printHeader و بالای خط footer در هر برگ.

چه زمانی استفاده کنیم:

  • در هر برنامهٔ داده‌ای پرتو، یک بار، دور همهٔ صفحه‌هایی که کاربرِ واردشده در آن‌ها کار می‌کند.

چه زمانی استفاده نکنیم:

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

استفاده

ProductFrame فقط از مسیر جدای خودش وارد می‌شود. در Next.js App Router، layout یک Server Component است و نمی‌تواند usePathname() را صدا بزند؛ پس layout یک جزء کلاینت کوچک را دور children رندر می‌کند:

// app/frame.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ProductFrame, type ProductFrameNavGroup } from '@partodata/ui/product-frame'
import { Icons } from '@partodata/ui/icons'

const nav: ProductFrameNavGroup[] = [
  {
    id: 'monitoring',
    items: [
      { id: 'overview', label: 'نمای کلی', icon: <Icons.home />, href: '/' },
      { id: 'mentions', label: 'منشن‌ها', icon: <Icons.messageCircle />, href: '/mentions' },
      { id: 'reports', label: 'گزارش‌ها', icon: <Icons.fileText />, href: '/reports' },
    ],
  },
  {
    id: 'admin',
    label: 'مدیریت',
    items: [
      {
        id: 'settings',
        label: 'تنظیمات',
        icon: <Icons.settings />,
        href: '/settings',
        children: [{ id: 'team', label: 'اعضا', href: '/settings/team' }],
      },
    ],
  },
]

export function Frame({ children }: { children: React.ReactNode }) {
  const pathname = usePathname()
  return (
    <ProductFrame product={{ name: 'پایش برند' }} nav={nav} pathname={pathname} linkComponent={Link}>
      {children}
    </ProductFrame>
  )
}
// app/layout.tsx
import { Frame } from './frame'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="fa" dir="rtl" className="dark" data-theme="dark" suppressHydrationWarning>
      <body>
        <Frame>{children}</Frame>
      </body>
    </html>
  )
}

هر صفحه یک قالب صفحه از @partodata/ui/templates است: قالب عرض و حاشیه، عنوان (تنها h1، 48 پیکسل زیر نوار بالا)، ریتم، جای اقدام‌ها و حالت‌ها را تعیین می‌کند و صفحه فقط جایگاه‌هایش را پر می‌کند:

// app/mentions/page.tsx
'use client'
import { DataTable } from '@partodata/ui'
import { ListPage } from '@partodata/ui/templates'

const columns = [{ id: 'author', header: 'نویسنده', cell: (row: { author: string }) => row.author }]

export default function MentionsPage() {
  return (
    <ListPage title="منشن‌ها" description="همهٔ منشن‌های برند در شبکه‌های پایش‌شده" state={{ status: 'ready' }}>
      <DataTable columns={columns} data={[{ author: 'نگار' }]} />
    </ListPage>
  )
}

نمونهٔ کامل صفحهٔ فهرست (جست‌وجو، فیلترها، حالت‌ها و صفحه‌بندی) در ListPage است.

حالت‌ها و انواع

منوی برچسب‌دار یا نوار آیکونی

navMode="rail" (پیش‌فرض) نوار آیکونی 48 پیکسلی است که با اشاره‌گر، روی صفحه و بدون جابه‌جا کردن آن، باز می‌شود؛ ورود فوکوس صفحه‌کلید هم همین کار را می‌کند. اشاره‌گر و فوکوس دو دلیل جدا هستند: نوار وقتی جمع می‌شود که هیچ‌کدام داخلش نباشد. با خروج اشاره‌گر، نوار کمی بعد (150 میلی‌ثانیه) جمع می‌شود تا رفت‌وبرگشت سریع چشمک نزند؛ اگر فوکوس صفحه‌کلید داخلش باشد باز می‌ماند. فوکوسی که کلیک موس روی یک مقصد جا می‌گذارد نوار را باز نگه نمی‌دارد، ولی Tab بعدی دوباره بازش می‌کند. کاربر با دکمهٔ سنجاق پایین نوار («نوار ناوبری همیشه باز») می‌تواند آن را باز نگه دارد؛ آن‌وقت منو عرض خودش را از صفحه می‌گیرد. انتخاب در مرورگر می‌ماند (اگر مرورگر ذخیره را ببندد، فقط فراموش می‌شود). navMode="menu" راه انصراف است: منوی برچسب‌دار 256 پیکسلی که باز شروع می‌شود، برای محصولی که کاربرانش برچسب‌ها را همیشه روی صفحه لازم دارند؛ همان سنجاق آن را به نوار تبدیل می‌کند.

navMode تنها راه تعیین حالت پیش‌فرض است. navBehavior فقط همراه onNavBehaviorChange پذیرفته می‌شود و منو را کنترل‌شده می‌کند (برای نمونه‌ها و embed؛ چیزی ذخیره نمی‌شود). onNavBehaviorChange به‌تنهایی فقط تغییر را گزارش می‌دهد (مثلاً برای آمار) و انتخاب کاربر همچنان اعمال و ذخیره می‌شود.

منوی دوم (secondaryNav)

محصولی که هر بخشش مقصدهای خودش را دارد (صفحه‌های یک پروژه، بخش‌های یک گزارش) آن‌ها را در secondaryNav می‌دهد: پنلی 240 پیکسلی بین منوی اصلی و صفحه، در سمت شروع، با لندمارک nav جدا به نام secondaryNavLabel (پیش‌فرض «ناوبری بخش»). محتوا بخش‌های SecondaryNav (SecondaryNavTitle، SecondaryNavItem، …) یا لینک است. نوار اصلی وقتی باز می‌شود روی این پنل هم می‌آید و چیزی جابه‌جا نمی‌شود. روی موبایل منوی دوم داخل پنل منو، زیر مقصدهای اصلی می‌آید. چاپ نمی‌شود و با chrome="none" نیست.

<ProductFrame
  product={{ name: 'پایش برند' }}
  nav={nav}
  pathname={pathname}
  secondaryNavLabel="کمپین تخفیف فصلی"
  secondaryNav={
    <>
      <SecondaryNavTitle>کمپین تخفیف فصلی</SecondaryNavTitle>
      <SecondaryNavItem asChild isActive>
        <Link href="/campaigns/12">نمای کلی</Link>
      </SecondaryNavItem>
      <SecondaryNavItem asChild>
        <Link href="/campaigns/12/mentions">منشن‌ها</Link>
      </SecondaryNavItem>
    </>
  }
>
  {children}
</ProductFrame>

سایدبار فیلتر در ستون دوم

نقش ستون دوم برای صفحهٔ فعلی تعیین می‌شود؛ لازم نیست در همهٔ صفحه‌های یک پنل ثابت باشد. بخش‌های پرتعداد ممکن است این ستون را برای زیرصفحه‌ها داشته باشند و صفحه‌های کم‌ازدحام همان محصول برای فیلترها. در نبود secondaryNav همان صفحه، ستون سایدبار فیلتر ListPage، DashboardPage یا CustomPage است (filterPanel؛ از 7.13 جای پیش‌فرض همهٔ صفحه‌ها) و همه‌چیزِ فیلترها داخل آن است: شمار و «پاک کردن همه»، چیپ‌های فعال و نشانگر هر بخش؛ نوار ابزار صفحه دکمهٔ «فیلترها» ندارد. ناوبری همان صفحه اولویت دارد: با secondaryNav دارای محتوا، فیلترها نوار افقی بالای محتوا با Popover بومی و Sheet موبایل هستند؛ زیر ناوبری یا در ستون عمودی سوم قرار نمی‌گیرند. Fragment خالی ستون را رزرو نمی‌کند.

عرض پیش‌فرض ستون فیلتر 15rem است؛ برای ستون پهن‌تر، روی قاب filterSidebarWidth="wide" بدهید: 18rem، یعنی 288 پیکسل با ریشهٔ 16 پیکسلی، که از منوی اصلی بازِ 16rem پهن‌تر است و نزدیک ستون فیلتر صفحهٔ Logs در Supabase (265 پیکسل). Prototype همین عرض را دارد. این گزینه عرض 15rem منوی ثانویه را تغییر نمی‌دهد.

اگر صفحه filterPanel={{ …, collapsible: true }} بدهد (اختیاری؛ در Prototype خاموش)، قاب ستون را برای کاربر جمع‌شدنی می‌کند: دکمهٔ «بستن فیلترها» در سر پنل و Ctrl+B (⌘B) آن را می‌بندند. ستون بسته یک نوار 40 پیکسلی (product-frame-page-sidebar-rail) جای خودش می‌گذارد که صفحه زبانهٔ «فیلترها» را با شمار فیلتر فعال در آن می‌گذارد؛ ستون از همان زبانه برمی‌گردد، هرگز از دکمه‌ای در نوار ابزار. انتخاب کاربر در localStorage قاب می‌ماند و فقط برای صفحه‌هایی اثر دارد که خودشان collapsible خواسته‌اند؛ صفحهٔ بعدی بدون آن ستونش را دارد. عرض ستون (15rem یا 18rem) عوض نمی‌شود.

قاب فقط جا را قرض می‌دهد و وضعیت فیلترها در صفحه می‌ماند. محاسبهٔ جاگیری از عرض انتخاب‌شده استفاده می‌کند؛ روی موبایل، بدون قاب یا وقتی ستون کمتر از 40rem برای محتوا می‌گذارد، فیلترها نوار افقی بالای محتوا و زیر 36rem، Sheet بومی می‌شوند. با رفتن صفحه ستونِ اختصاصی فیلتر آزاد می‌شود.

فقط نوار بالا (navMode="none")

برای محصولی تک‌بخشی که کاربرانش در یک جا کار می‌کنند (یک فهرست، موارد آن و یک جریان ساخت): نه منو، نه دکمهٔ منو و نه کنترل حالت منو؛ فقط نوار بالا و صفحه، و صفحه تمام عرض را می‌گیرد. چند مقصد دیگر این محصول (مدیریت کاربران، هزینه‌ها) مورد‌های منوی حساب (UserMenu در user) هستند که با مسیریاب جابه‌جا می‌شوند: onSelect: () => router.push('/users') (useRouter از next/navigation) یا navigate('/users') (useNavigate در react-router). این مورد‌ها پیوند نیستند: نه در زبانهٔ تازه باز می‌شوند و نه نشانی‌شان کپی می‌شود (href برای مورد منوی حساب، از راه linkComponent قاب، کار بعدی است). قاعدهٔ تصمیم: محصولی که دو بخش یا بیشتر از خودش دارد، یا مقصد دیگری دارد ولی منوی حساب ندارد، منو (menu) می‌گیرد؛ این تصمیمِ خود محصول است، نه کاربر به کاربر. none هیچ nav، navFooter، activeId یا جفت حالت منو نمی‌پذیرد و menu و rail بدون nav پذیرفته نمی‌شوند (نوع هر دو را اجبار می‌کند).

const router = useRouter() // next/navigation

<ProductFrame
  product={{ name: 'پایش پست' }}
  navMode="none"
  pathname={pathname}
  linkComponent={Link}
  user={
    <UserMenu
      user={user}
      items={[
        { label: 'مدیریت کاربران', onSelect: () => router.push('/users') },
        { type: 'separator' },
        { label: 'خروج', onSelect: signOut, destructive: true },
      ]}
    />
  }
>
  {children}
</ProductFrame>

منو از روی داده

  • گروه‌ها (nav): هر گروه یک id، یک label اختیاری (عنوان گروه در منوی باز، و جداکننده روی نوار آیکونی) و items دارد.
  • مقصدها: id، label، icon، href، و اختیاری badge (عدد یا وضعیت کوتاه؛ روی نوار آیکونی یک نقطه) و disabled.
  • یک سطح زیرمنو (children): زیر مقصد والد، وقتی بخشِ آن فعال است و منو باز است، فهرست می‌شود. بخشی که صفحهٔ خودش را ندارد یک گروه است، نه یک مقصد با زیرمنو.
  • پایین منو (navFooter): مقصدهایی مثل تنظیمات و راهنما، بالای کنترل حالت منو.

عنوان گروه‌ها و ردیف‌های زیرمنوی بخش فعال، در نوار بسته نیز فضای خود را نگه می‌دارند؛ عنوان با جداکننده جایگزین می‌شود و زیرمنو دیده نمی‌شود و در ترتیب Tab نیست. بنابراین هنگام بازشدن با اشاره‌گر، آیکون مقصد زیر دست جابه‌جا نمی‌شود. فاصلهٔ خالیِ زیر بخش فعال روی نوار، جای همان زیرمنو در منوی باز است.

مقصد فعال

pathname را بدهید (مثلاً usePathname()). مقصد فعال آن است که hrefش با مسیر، با بخش‌های کامل مسیر، جور باشد و بلندترین href برنده است: برای /reports/42 مقصد /reports فعال است، نه / و نه /reports-archive. پرس‌وجو (?…)، # و / انتهایی نادیده گرفته می‌شوند. وقتی یک زیرمنو فعال است، والدش برجسته و زیرمنو باز است. activeId صریح بر pathname برتری دارد.

اقدام‌های سراسری و اعلان

actions کنترل‌های سراسری انتهای نوار بالاست: جست‌وجو، اعلان‌ها و راهنما. همه یک اندازه (sm، 30 پیکسل) می‌گیرند و دکمهٔ فقط‌آیکون مربعی هم‌قد ردیف است. کلید تم در actions نمی‌رود: قاب آن را در یک جای ثابت می‌گذارد، با themeToggle: 'header' (پیش‌فرض) آخرین کنترل نوار بالا درست پیش از منوی حساب، 'user-menu' موردی به نام «تم تاریک» داخل منوی حساب (بدون user به نوار بالا برمی‌گردد)، یا false. کلید فقط زیر ThemeProvider از @partodata/ui/theme-toggle رندر می‌شود؛ محصولی با یک تم ثابت هیچ کلیدی نمی‌بیند. منوی حساب در actions نمی‌رود: user آن را همیشه آخرین کنترل نوار بالا می‌گذارد، و UserMenu بدون size آن‌جا دایره‌ای هم‌قد همین ردیف است. اقدام اصلی یک صفحه («ساخت گزارش»، «هشدار جدید») هرگز در actions قاب نمی‌آید: مال همان صفحه است (primaryAction نوارابزار یا سربرگ صفحه)، و دکمهٔ بدون variant در actions به default رندر می‌شود. banner اعلانی تمام‌عرض بالای نوار بالاست (نگه‌داری، اختلال، ارتقا) و ناحیه‌ای با نام «اطلاعیه» است.

«آخرین تغییرات» در actions یک برچسب آرام نسخه است، نه زنگولهٔ دوم: <WhatsNewBell variant="version" /> پیش از زنگولهٔ اعلان‌ها، که با کلیک پنل تغییرات را باز می‌کند و وقتی ریلیز دیده‌نشده هست یک نقطه دارد (آخرین تغییرات).

<ProductFrame
  product={{ name: 'پایش برند' }}
  nav={nav}
  pathname={pathname}
  linkComponent={Link}
  actions={
    <>
      <WhatsNewBell variant="version" version="2.4" unseenCount={feed.announcedCount} onClick={openWhatsNew} />
      <NotificationsButton />
    </>
  }
  themeToggle="header"
  user={<UserMenu user={{ name: 'سارا احمدی' }} items={accountItems} />}
>
  {children}
</ProductFrame>

ناحیهٔ محتوا

ناحیهٔ محتوا یک main با اسکرول خودش است، بدون padding و بدون سقف عرض. جزئی که بخواهد بداند صفحهٔ قاب است، می‌تواند از useInProductFrame() بپرسد: فقط داخل صفحه (children) true است؛ در actions، user و banner، و داخل هر پنجره یا پنل رویی (Dialog، Sheet، Drawer، Popover، منوها) که از صفحه باز شود، false است. برای همین PageHeader داخل یک Sheet فاصلهٔ 48 پیکسلی صفحه را نمی‌گیرد.

عنوان سند، فوکوس و اسکرول در تغییر مسیر

از 7.9 قاب خودش کاری را می‌کند که یک برنامهٔ تک‌صفحه‌ای معمولاً فراموش می‌کند (WCAG 2.4.2 سطح A، 2.4.3، 4.1.3) و محصول هیچ کدی برایش نمی‌نویسد:

  • عنوان تب: document.title همیشه {عنوان صفحه}{ · titleContext} | {نام محصول} است. عنوان صفحه از h1 همان قالب می‌آید و اگر بعداً بارگذاری شود (نام یک مسئله) خودش به‌روز می‌شود؛ بخش صفحه حداکثر 60 نویسه است. titleContext مکان را می‌گوید وقتی عنوان صفحه نمی‌گوید: منبع انتخاب‌شده («اینستاگرام»)، پروژه. فقط متن ساده و ارقام ASCII.
  • فوکوس: پس از تغییر مسیر (نه query: فیلتر فوکوس را نمی‌پراند) فوکوس به h1 صفحهٔ تازه می‌رود، تا کاربر صفحه‌خوان از عنوان صفحهٔ تازه شروع کند نه از پیوندی که دیگر نیست. اگر فوکوس روی چیزی داخل محتوا باشد که می‌ماند (پیوند یک تب، یک فیلد)، همان‌جا می‌ماند و یک ناحیهٔ aria-live="polite" عنوان تازه را اعلام می‌کند. بار نخست فوکوس جابه‌جا نمی‌شود.
  • اسکرول: ناحیهٔ محتوا اسکرول می‌شود، نه پنجره. صفحهٔ تازه از بالا شروع می‌شود، «برگشت» مرورگر به جای قبلی برمی‌گردد (در حافظه، به‌ازای هر نشانی)، و نشانی با #hash به همان عنصر می‌رود و فوکوسش می‌کند. useFrameScroll() ظرف اسکرول را بدون data-slot می‌دهد؛ TableOfContents هم همان را دنبال می‌کند.
<ProductFrame
  product={{ name: 'پرتو' }}
  nav={nav}
  pathname={pathname}
  titleContext={source.label}                 // «مسائل رصدشده · اینستاگرام | پرتو»
  scroll={{ restore: true, key: location.key }} // key: شناسهٔ ورودی تاریخچه (react-router)؛ پیش‌فرض نشانی
>

همه پیش‌فرض روشن‌اند (پیش از 7.9 هیچ قابی عنوان نمی‌گذاشت): documentTitle={false} برای محصولی که عنوان را خودش می‌گذارد (فراداده‌ی فریم‌ورک)، routeFocus={false} برای فوکوس، و scroll={false} برای اسکرولی که محصول خودش می‌کند.

یک خط آرام در انتهای هر صفحه، زیر محتوای خود صفحه: نسخه یا شناسهٔ ساخت برنامه، متن مجوز یا حق نشر. فقط وقتی شرح کار آن را بخواهد، و هرگز نسخه، شناسهٔ ساخت، سازمان یا تاریخی ساختگی: مقدارش از ساخت خود محصول می‌آید. در صفحهٔ کوتاه به پایین ناحیهٔ محتوا می‌چسبد و در چاپ پایین هر برگ تکرار می‌شود. فقط متن و پیوند کوچک؛ مقصدهای ناوبری جایشان navFooter است و دکمه هیچ‌وقت این‌جا نمی‌آید (قاعدهٔ lint parto/page-primary-action آن را می‌گیرد، و دکمه‌ای که برسد هرگز اصلی رندر نمی‌شود). با chrome="none" رندر نمی‌شود. خط پایانی بعد از محتوای صفحه است، پس محصولی که فهرست‌های اصلی‌اش بی‌پایان اسکرول می‌شوند، نسخه را در منوی حساب هم نشان می‌دهد.

<ProductFrame
  product={{ name: 'پایش برند' }}
  nav={nav}
  // the product's own version, from its build (next.config: env.NEXT_PUBLIC_APP_VERSION)
  footer={<span>نسخهٔ {process.env.NEXT_PUBLIC_APP_VERSION}</span>}
>
  {children}
</ProductFrame>

زبان (locale)

locale زبان صفحه است و یک بار، روی قاب داده می‌شود: fa (پیش‌فرض)، ar یا en. رشته‌هایی که خود قاب می‌سازد — پیوند پرش، نام لندمارک‌ها و ناحیهٔ اطلاعیه، دکمه و پنل منوی موبایل، و کنترل حالت منو — و رشته‌های همهٔ قالب‌های صفحه (پیوند بازگشت، زبانه‌ها، حالت‌های بارگذاری و خطا، صفحه‌بندی، نوار ابزار) به همین زبان‌اند: قالبی که locale خودش را ندارد، زبان قاب را می‌گیرد. جزءهایی که برنامه در قاب، در جای‌های نوار بالا و در صفحه می‌گذارد هم همین را می‌گیرند، و پنجره‌ها، پنل‌ها و منوهایی هم که از آن‌ها باز می‌شوند: هر جزئی که locale خودش را دارد (DataTable، SearchInput، FilterBar، PeriodSelector، DatePicker، DateRangePicker، ConfirmDialog، UserMenu در user، SentimentBadge، نمودارها …) و برچسب پنهان دکمهٔ بستن Dialog و Sheet. قاب زبان را با زمینهٔ مشترک page-locale@1 به همهٔ آن‌ها می‌دهد؛ پس locale را روی هیچ قالب یا جزئی تکرار نکنید. locale خود یک جزء همچنان برنده است (نقل‌قولی فارسی در صفحهٔ انگلیسی). DateRangePicker در ar و en تقویم میلادی دارد. <Toaster /> که در layout ریشه کنار قاب است، زبان را از lang عنصر ریشه می‌خواند. جهت را locale تعیین نمی‌کند: عنصر ریشه هر دو را دارد، <html lang={locale} dir={locale === 'en' ? 'ltr' : 'rtl'}>، و منو همیشه در سمت شروع خط است. برچسب مقصدهای منو دادهٔ خود محصول است و به زبان همان صفحه نوشته می‌شود؛ badge عددی با رقم لاتین نوشته می‌شود، پس محصول عربی یا انگلیسی عدد نشان را رشته‌ای می‌دهد که خودش قالب‌بندی کرده است.

// app/[locale]/frame.tsx — a product with Persian and Arabic routes
<ProductFrame
  product={{ name: 'رصد العلامة', href: `/${locale}` }}
  nav={nav[locale]}
  locale={locale}
  pathname={pathname}
  linkComponent={Link}
  user={<UserMenu user={user} items={accountItems[locale]} />}
>
  {children}
</ProductFrame>

وقتی صفحهٔ اصلی محصول پیشوند زبان دارد (product.href برابر /fa)، همهٔ مسیرها زیر آن‌اند؛ برای همین مقصدی که hrefش همان صفحهٔ اصلی است فقط روی خود آن مسیر فعال است، نه روی هر مسیری که مقصد دیگری آن را نپوشانده است.

بدون نوار و منو (chrome="none")

برای همان مسیرِ یک صفحهٔ پایش وقتی روی نمایشگری بی‌کاربر دیده می‌شود (دیوار عملیات، تلویزیون، کیوسک): نه نوار بالا، نه منو، نه پیوند پرش و نه خط پایانی. صفحه همان قالب خودش است، با همان عرض و حاشیه. banner می‌ماند، چون اطلاعیه برای کسی است که نمایشگر را می‌بیند.

یک سازوکار آن را روشن می‌کند: chrome={useKioskChrome()} در جزئی که قاب را رندر می‌کند، و نشانی ?kiosk=1 روی نمایشگر (/live?kiosk=1). این قلاب نشانی را یک بار، وقتی قاب در مرورگر mount می‌شود، می‌خواند و تا زبانهٔ مرورگر در برنامه است نگه می‌دارد (پیوندی که داخل نمای کیوسک دنبال شود نوار را برنمی‌گرداند). سرور و اولین render مرورگر full می‌دهند، پس hydration جور است، و قاب بی‌آنکه صفحه دوباره mount شود به none می‌رود. قلاب هیچ hook مسیریابی به کار نمی‌برد، پس در layout ریشهٔ ایستای Next.js (جایی که useSearchParams بی‌مرز Suspense، next build را می‌شکند) و در layout route در react-router یکسان کار می‌کند. نام پرس‌وجوی دیگر، مسیر جدا، متغیر محیطی یا ذخیرهٔ مرورگر برای آن نسازید، و chrome="none" را هرگز ثابت ننویسید (قاعدهٔ lint parto/page-template آن را می‌گیرد).

قاعدهٔ تصمیم: مسیری که هیچ‌وقت نوار و منوی قاب را ندارد (ورود، اسلاید، منبع PDF) اصلاً در قاب نیست؛ chrome برای مسیری است که همان صفحهٔ برنامه است و گاهی بی‌کاربر نمایش داده می‌شود.

// app/frame.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { ProductFrame, useKioskChrome } from '@partodata/ui/product-frame'

export function Frame({ children }: { children: React.ReactNode }) {
  return (
    <ProductFrame product={product} nav={nav} chrome={useKioskChrome()} pathname={usePathname()} linkComponent={Link}>
      {children}
    </ProductFrame>
  )
}

چاپ

چاپ یک صفحه از برنامه، خود صفحه را چاپ می‌کند. قاب در چاپ:

  • نوار بالا، منو، banner و پیوند پرش را چاپ نمی‌کند؛
  • جعبهٔ ثابت‌ارتفاع و اسکرول ناحیهٔ محتوا را برمی‌دارد، پس صفحه در هر چند برگی که لازم دارد جاری می‌شود و به یک صفحهٔ نمایش بریده نمی‌شود؛
  • داخل صفحه هیچ جزء چسبان یا ثابتی (sticky، fixed) روی محتوا نمی‌نشیند و جعبهٔ اسکرول درونی جدول (stickyHeader) محتوایش را نمی‌بُرد؛
  • هیچ چیز را پهن‌تر از برگ نمی‌گذارد: خانه‌های جدول در چاپ میان واژه‌ها می‌شکنند (هیچ واژه یا عددی دو تکه نمی‌شود)، پس ستون‌های آخر یک جدول پهن بیرون از برگ نمی‌مانند، و نموداری که روی صفحه پهن‌تر از برگ کشیده شده با همان تناسب به عرض برگ کوچک می‌شود (متنش هم کوچک‌تر) و کارتش هم‌پای آن کوتاه می‌شود؛
  • برگ را وسط یک کارت، نمودار، ردیف جدول یا تصویر، و درست بعد از یک عنوان نمی‌شکند؛
  • همیشه با پالت روشن چاپ می‌کند (متن تیره روی کاغذ سفید، هر تمی که روی صفحه است) و رنگ‌ها را همان‌طور که دیده می‌شوند چاپ می‌کند؛ متن محورها، خطوط شبکه و برچسب برش‌های نمودار دایره‌ای هم پالت روشن می‌گیرند؛
  • printHeader را، اگر داده باشید، بالای هر برگ و footer را پایین هر برگ تکرار می‌کند.

روی کاغذ، صفحه آنچه را دارد و دامنه‌اش را نشان می‌دهد، نه راه تغییر آن را:

چاپ می‌شودچاپ نمی‌شود
عنوان، توضیح، meta و محتوای صفحهدکمه‌های جایگاه‌های اقدام: سرِ صفحه، نوار ابزار، بخش‌ها و کارت‌های نمودار
بازهٔ زمانی (period)، فقط گزینهٔ انتخاب‌شدهگزینه‌های دیگر بازه
جست‌وجویی که عبارت دارد، فیلتری که مقداری دارد، تراشه‌های فیلتر فعال (activeFilters)جست‌وجوی خالی، فیلتر بی‌مقدار (بازهٔ تاریخ خالی هم) و دکمهٔ پاک کردن فیلترها
خط بازهٔ ردیف‌ها («1 تا 25 از 60»)صفحه‌شمار، انتخاب اندازهٔ صفحه و نوار انتخاب ردیف‌ها
زبانهٔ فعلی: نام بخشی که برگ نشان می‌دهدزبانه‌های دیگر و پیوند بازگشت

کنترلی که همهٔ این‌ها را خودتان حذف کنید وجود ندارد: کنترل‌های جایگاه‌های قالب را قاب خودش کنار می‌گذارد. کنترلی که صفحه بیرون از این جایگاه‌ها خودش ساخته باشد (مثلاً در محتوای یک CustomPage) print:hidden می‌گیرد؛ ناحیهٔ چاپ جدا هرگز ساخته نمی‌شود.

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

اقدام «چاپ» فقط روی صفحه‌ای است که برای کاغذ معنا دارد: گزارشی (یک DetailPage یا DashboardPage) که جدول‌هایش همهٔ ردیف‌ها را دارند — بی‌صفحه‌بندی و بی‌virtualize. فهرستی که صفحه‌بندی دارد فقط صفحهٔ فعلی‌اش را دارد و اقدام «چاپ» نمی‌گیرد. اقدام «چاپ» یک اقدام ثانوی خود صفحه است (secondaryActions) که window.print() را صدا می‌زند؛ همان دکمه خروجی PDF هم هست، چون پنجرهٔ چاپ مرورگر «ذخیره به‌صورت PDF» دارد.

<ProductFrame
  product={product}
  nav={nav}
  // the brief's letterhead: the product's name, and a classification line at the end — never today's date
  printHeader={
    <>
      <span>پایش برند</span>
      <span>ویژهٔ استفادهٔ داخلی</span>
    </>
  }
>
  {children}
</ProductFrame>

// in the page: its period in the page itself, and its print action, secondary
<DetailPage
  title="گزارش ماهانهٔ منشن‌ها"
  meta={<Badge variant="secondary">مهر 1405</Badge>}
  secondaryActions={
    <Button variant="default" onClick={() => window.print()}>
      چاپ
    </Button>
  }
>
  …
</DetailPage>

دو مرز چاپ از مرورگر:

  • نمودارها با JavaScript از تم صفحه رنگ می‌گیرند و به عرض صفحهٔ نمایش کشیده می‌شوند. در چاپ، متن محورها و شبکه پالت روشن را می‌گیرد و نمودار پهن به عرض برگ کوچک می‌شود، اما رنگ سری‌ها همان رنگی است که روی صفحه بود (از تم تیره، کمی روشن‌تر) و نمودار با عرض برگ از نو کشیده نمی‌شود. نمودار حرارتی، ابر واژه و نمودار شبکه هم همین‌طور کوچک می‌شوند و کارت نمودار هم‌پای آن کوتاه می‌شود. چون کوچک‌شدن متن را هم ریز می‌کند (نمودار تمام‌عرضی که روی صفحهٔ 1920 پیکسلی کشیده شده با متنی حدود نصف اندازه چاپ می‌شود)، نموداری که برای کاغذ است در DashboardChart نیم‌عرض می‌نشیند (در برگ A4 تقریباً به اندازهٔ خودش چاپ می‌شود)، و محصولی که چاپ‌هایش نمودار یا جدول پهن دارند در برگ افقی چاپ می‌کند (بند بعد). رنگ واژه‌های ابر واژه هم مثل رنگ سری‌ها همان رنگ صفحه است.
  • جدول بسیار پهن: خانه‌های جدول فقط میان واژه‌ها شکسته می‌شوند (هیچ واژه یا عددی دو تکه نمی‌شود)؛ جدولی که واژه‌هایش کنار هم در برگ عمودی جا نمی‌شوند، در برگ افقی چاپ می‌شود. این تصمیمِ محصول برای همهٔ چاپ‌هایش است: یک خط در CSS سراسری محصول، @page { size: A4 landscape; }.

PDFی که سرور می‌سازد (سرویس تبدیل HTML به PDF) از صفحهٔ جدای بیرون از قاب ساخته می‌شود (مسیرهای بیرون از قاب).

مسیریاب‌ها: Next.js و react-router

linkComponent لینکی است که href می‌گیرد، و همهٔ لینک‌های قاب و قالب‌های صفحه از آن رد می‌شوند: منو، صفحهٔ اصلی محصول، پیوند بازگشت و زبانه‌های قالب‌ها. Link در Next.js همین را می‌گیرد و همان‌طور که هست داده می‌شود. Link در react-router به‌جای href، to می‌گیرد؛ آن را با createRouterLink یک بار، در سطح ماژول، تطبیق دهید (مؤلفهٔ تازه در هر render همهٔ لینک‌ها را از نو mount می‌کند). Link react-router که مستقیم داده شود خطای نوع است (همهٔ لینک‌هایش به صفحهٔ فعلی اشاره می‌کردند). نشانی‌ای که scheme دارد (https://…) لینک ساده می‌ماند.

// src/frame.tsx — Vite + react-router
import { Link, Outlet, useLocation } from 'react-router-dom'
import { ProductFrame, createRouterLink } from '@partodata/ui/product-frame'

const FrameLink = createRouterLink(Link)

export function Frame() {
  const { pathname } = useLocation()
  return (
    <ProductFrame product={{ name: 'پایش برند' }} nav={nav} pathname={pathname} linkComponent={FrameLink}>
      <Outlet />
    </ProductFrame>
  )
}

مسیرهای بیرون از قاب

قاب مال صفحه‌هایی است که کاربرِ واردشده در آن‌ها کار می‌کند. این مسیرها بیرون از آن‌اند و layout خودشان را دارند:

مسیرچرا بیرون از قابچطور
ورود، ثبت‌نام، بازیابی رمز، پذیرفتن دعوتکاربر هنوز وارد نشده و هیچ مقصد منو برایش باز نیستگروه مسیر جدا، بدون قاب
منبع PDF که سرور می‌سازد (سرویس تبدیل HTML به PDF)سندی برای کاغذ است، نه صفحه‌ای برای کارlayout جدا با پالت روشن (data-theme="light" روی <html>)؛ کامپوننت‌هایی که سند لازم دارد (نمودار، جدول، BulletinViewer)، بدون قالب صفحه
اسلاید یا ارائهٔ تمام‌صفحهبوم ثابت 16:9 است و هیچ ناوبری برنامه در آن نیستگروه مسیر جدا
ویجت جاسازی‌شده، مینی‌اپ پیام‌رسانمیزبان (صفحهٔ دیگر یا پیام‌رسان) خودش قاب را داردگروه مسیر جدا؛ فقط صفحه
صفحهٔ اشتراک عمومی یک گزارشخواننده کاربر برنامه نیستگروه مسیر جدا

چاپ صفحه‌ای که کاربر در برنامه می‌بیند بیرون از قاب نیست: همان صفحه است و قاب خودش آن را برای چاپ آماده می‌کند (چاپ). صفحه‌ای که همان مسیر برنامه است و گاهی روی نمایشگر بی‌کاربر دیده می‌شود هم بیرون از قاب نیست: chrome={useKioskChrome()}.

در Next.js App Router هر گروه، layout خودش را دارد و فقط گروه برنامه قاب را رندر می‌کند:

app/
├── layout.tsx            ← <html>، <body>، ThemeProvider و Toaster؛ بدون قاب
├── (app)/
│   ├── layout.tsx        ← <Frame>{children}</Frame>: ProductFrame، یک بار
│   ├── page.tsx
│   └── mentions/page.tsx
├── (auth)/login/page.tsx ← بدون قاب
└── (share)/r/[id]/page.tsx

با react-router هم همین است: قاب در یک layout route است (<Route element={<Frame />}>) و مسیرهای بیرون از قاب کنار آن تعریف می‌شوند، نه زیر آن.

راهنمای استفاده

بکنید

  • ProductFrame را یک بار در layout بخش واردشدهٔ برنامه بگذارید و pathname و linkComponent مسیریاب را به آن بدهید (Link در Next.js همان‌طور که هست؛ Link در react-router با createRouterLink).
  • همهٔ hrefها را از ریشه بنویسید (/reports) و به هر مقصد، در nav، زیرمنوها و navFooter، شناسهٔ یکتا بدهید؛ در محیط توسعه، شناسهٔ تکراری، href نسبی و activeId ناشناخته هشدار می‌دهند.
  • هر صفحه را یک قالب از @partodata/ui/templates کنید (جدول انتخاب قالب صفحه)؛ اقدام اصلی صفحه در primaryAction همان قالب است و قالب جایش را تعیین می‌کند.
  • مقصدها را به ترتیب کاربرد مرتب کنید و بخش‌های مدیریتی را در یک گروه با عنوان جدا کنید.
  • navMode را یک بار برای محصول انتخاب کنید: پیش‌فرض rail است؛ menu فقط وقتی برچسب‌ها باید همیشه دیده شوند، و none وقتی شرح کار بگوید محصول تک‌بخشی است و منو ندارد.
  • ThemeToggle را در actions یا جای دیگری از قاب نگذارید؛ جای کلید تم را themeToggle تعیین می‌کند (قاعدهٔ lint parto/theme-toggle-placement).
  • locale را یک بار روی قاب بدهید؛ قالب‌های صفحه و همهٔ جزءهای داخل قاب و صفحه از آن پیروی می‌کنند. عنصر ریشه lang={locale} و dir={locale === 'en' ? 'ltr' : 'rtl'} دارد (Toaster زبان را از آن می‌خواند).
  • footer، printHeader، اقدام «چاپ» و chrome را فقط وقتی بگذارید که شرح کار آن‌ها را بخواهد؛ chrome همیشه chrome={useKioskChrome()} است.

نکنید

  • عنوان صفحه را در نوار بالا نگذارید؛ قاب جایی برای آن ندارد و هر صفحه فقط یک h1 دارد.
  • منوی حساب را در actions نگذارید؛ جای آن user است.
  • اقدام اصلی یک صفحه را در actions قاب نگذارید؛ جای آن primaryAction همان صفحه است.
  • به ناحیهٔ محتوا padding یا max-w-* اضافه نکنید و صفحه را با div و p-6 نپوشانید؛ قالب صفحه همین را یک بار انجام می‌دهد. PageContainer و PageHeader را هم خودتان در صفحه نگذارید: اجزایی‌اند که قالب‌ها رویشان ساخته شده‌اند.
  • منوی دوم (پنل کناری ثانویه) نسازید؛ یک سطح زیرمنو (children) یا گروه‌ها کافی است.
  • برای قاب برنامه NavRail دست‌ساز به کار نبرید.
  • صفحهٔ ورود، اسلاید یا منبع PDF سرور را داخل قاب نگذارید و قاب را برای آن‌ها پنهان نکنید؛ آن‌ها بیرون از layout قاب‌اند.
  • برای چاپ، قاب را با CSS خودتان پنهان نکنید و ناحیهٔ چاپ جدا نسازید؛ قاب خودش صفحه را برای چاپ آماده می‌کند.
  • عنوان صفحه یا دکمه را در printHeader یا footer نگذارید، و نسخه، شناسهٔ ساخت، سازمان یا تاریخی را که شرح کار نداده در آن‌ها ننویسید.
  • chrome="none" را ثابت ننویسید و برای نمای کیوسک پرس‌وجو، مسیر، متغیر محیطی یا prop خودتان را نسازید؛ useKioskChrome() (قاعدهٔ parto/page-template هر دو را نشان می‌دهد).
  • locale را روی قالب‌ها و جزءهای داخل صفحه تکرار نکنید؛ فقط صفحه یا جزئی که واقعاً زبان دیگری دارد locale خودش را می‌گیرد.
  • جدول صفحه را در CSS یا آزمون خود با main table نگیرید: صفحه داخل جدول نمایشی قاب است؛ [data-slot="table"] یا نقش جدول را بگیرید.

مهاجرت از AppShell

AppShell در 5.0 حذف شد. محصولی که هنوز رویش است، پیش از ارتقا به ^5 این جدول را دنبال کند. جای هر ویژگی آن:

AppShellProductFrame
nav (فهرست تخت، href اختیاری)nav: گروه‌ها { id, label?, items }؛ هر مقصد href لازم دارد و می‌تواند badge و children داشته باشد
activeIdpathname (ترجیحی) یا activeId
onNavigate (نماهای وابسته به state)ندارد: صفحه‌ها لینک‌اند؛ مسیریاب به کار ببرید
linkComponent، banner، containedهمان‌ها
brand، headerStartproduct ({ name, logo, href })
header.actions، headerEndactions؛ منوی حساب در user؛ اقدام اصلی یک صفحه به primaryAction همان صفحه می‌رود
header.title، header.subtitle، header.breadcrumbsندارد: عنوان و راه بازگشت صفحه (title، back) در قالب خود صفحه‌اند
pageHeaderندارد: سرِ صفحه را قالب هر صفحه می‌سازد
navFooter (هر ReactNode)navFooter: مقصدها (همان شکل items)
secondaryPanel، secondaryResizableزیرمنوی children؛ محتوای آزاد به خود صفحه یا یک Sheet می‌رود
contentWidthعرض قالب صفحه (پیش‌فرض قالب، یا width با نام)
navBehavior، onNavBehaviorChangenavMode (rail پیش‌فرض، رفتار AppShell؛ menu انصراف)؛ جفت کنترل‌شده فقط برای نمونه‌ها و embed
headerSize، navSide، hideNavBehaviorToggle، classNameندارد: ارتفاع، سمت منو و کنترل حالت منو ثابت‌اند

در صفحه‌ها: <PageContainer inset="none" size="full"> و PageHeaderی که زیر AppShell لازم بود را با قالب همان صفحه عوض کنید (ListPage، DetailPage، FormPage…؛ انتخاب قالب صفحه).

Props

ProductFrame

Prop

Type

ProductFrameNavGroup

Prop

Type

ProductFrameNavItem

ProductFrameNavItem همهٔ ویژگی‌های ProductFrameNavLink را دارد، به‌علاوهٔ:

Prop

Type

Prop

Type

ProductFrameProduct

Prop

Type

function createRouterLink(
  Link: React.ElementType<{ to: string }>
): React.ForwardRefExoticComponent<ProductFrameLinkProps & React.RefAttributes<HTMLAnchorElement>>

لینکی را که to می‌گیرد (Link در react-router) به linkComponent قاب تبدیل می‌کند، که href می‌گیرد (ProductFrameLinkProps: href و ویژگی‌های لینک). یک بار، در سطح ماژول، بسازید. نشانی با scheme لینک ساده می‌ماند. خروجی مؤلفه‌ای با forwardRef است که بقیهٔ ویژگی‌ها (aria-*، onClick، className) را به Link می‌دهد، پس کلیک روی مقصد غیرفعال منو هنوز مسیر را عوض نمی‌کند.

useKioskChrome

function useKioskChrome(): 'full' | 'none'

تنها راه تعیین chrome: وقتی نشانی‌ای که زبانهٔ مرورگر برنامه را با آن باز کرده kiosk=1 دارد (/live?kiosk=1)، none و در غیر این صورت full. یک بار، وقتی قاب در مرورگر mount می‌شود، خوانده می‌شود و تا زبانه در برنامه است می‌ماند. روی سرور و در اولین render مرورگر full است، پس hydration جور است و قاب بی‌آنکه صفحه دوباره mount شود جابه‌جا می‌شود. hook مسیریابی به کار نمی‌برد و در layout ریشهٔ ایستای Next.js و layout route در react-router کار می‌کند.

useFrameScroll

function useFrameScroll(): { ref: RefObject<HTMLElement | null>; scrollTo: (target: number | ScrollToOptions) => void }

ظرفی که صفحهٔ قاب در آن اسکرول می‌شود (main قاب؛ پنجره هرگز اسکرول نمی‌شود، پس window.scrollTo، window.scrollY و شنونده‌ی scroll روی window در قاب کاری نمی‌کنند). ref فقط خواندنی است (به عنصری وصل نکنید). scrollTo فوری اسکرول می‌کند مگر behavior: 'smooth' بدهید؛ بیرون از قاب پنجره را اسکرول می‌کند.

useInProductFrame

useInProductFrame(): boolean — در صفحه‌ای که ProductFrame رندر می‌کند (children آن) true است؛ در actions، user، banner، footer، printHeader، داخل پنجره‌ها و پنل‌های رویی و بیرون از قاب false است.

دسترسی‌پذیری

  • اولین توقف Tab پیوند «پرش به محتوای اصلی» است؛ با فوکوس دیده می‌شود و فوکوس را به main می‌برد. همهٔ نام‌ها و رشته‌های قاب به locale آن‌اند. با chrome="none" نه نوار و منو هست و نه پیوند پرش؛ فقط main (و banner، اگر داده باشید).
  • عنوان تب با هر صفحه عوض می‌شود (2.4.2) و پس از تغییر مسیر فوکوس به h1 می‌رود یا عنوان در یک ناحیهٔ aria-live="polite" اعلام می‌شود (2.4.3، 4.1.3)؛ حلقهٔ فوکوس روی h1 رسم نمی‌شود چون فوکوس برنامه‌ای است و tabindex هنگام خروج فوکوس برداشته می‌شود
  • لندمارک‌ها: header برای نوار بالا، nav با نام «ناوبری اصلی» برای منو (روی موبایل هم، داخل پنل)، main برای محتوا، و banner ناحیه‌ای با نام «اطلاعیه». گروه‌های عنوان‌دار منو role="group" با همان عنوان دارند.
  • مقصد فعال aria-current="page" دارد. روی نوار آیکونی جمع‌شده، برچسب هر مقصد نام دسترس‌پذیر و tooltip آن است، و badge در نام لینک هم می‌آید. وقتی منو باز است tooltip باز نمی‌شود، پس برچسب دو بار خوانده نمی‌شود.
  • در حالت rail، فوکوس صفحه‌کلید (:focus-visible) نوار را مثل اشاره‌گر باز می‌کند، پس برچسب‌ها و زیرمنو بدون موس هم در دسترس‌اند؛ تا فوکوس داخل نوار است، خروج اشاره‌گر آن را نمی‌بندد. سنجاق «نوار ناوبری همیشه باز» دکمهٔ تغییر وضعیت با aria-pressed است، و کلید تم هم («تم تاریک»، فشرده در تم تاریک).
  • نوار بالا هیچ عنوانی (h1 تا h6) ندارد؛ h1 هر صفحه عنوان قالب همان صفحه است.
  • footer داخل main و بعد از صفحه است. سربرگ چاپ روی صفحه نمایش داده نمی‌شود و صفحه‌خوان آن را نمی‌خواند. صفحه، سربرگ و خط پایانی در یک جدول نمایشی (role="presentation") قرار دارند که روی صفحه هیچ جعبه‌ای نمی‌سازد (display: contents) و فقط در چاپ جدول است، تا سربرگ و خط پایانی در هر برگ تکرار شوند. این جدول همیشه هست (با یا بی footer و printHeader)، تا تغییر chrome یا رسیدن خط پایانی صفحه را دوباره mount نکند؛ در درخت دسترس‌پذیری نیست، اما در CSS و آزمون‌ها جدول صفحه را با [data-slot="table"] یا نقش جدول بگیرید، نه main table.
  • روی موبایل منو یک پنل کناری با عنوان دسترس‌پذیر است. دکمهٔ «منوی ناوبری» aria-expanded دارد؛ با باز شدن پنل فوکوس روی مقصد فعال (یا اولین مقصد) می‌رود، و با Escape یا دنبال کردن یک مقصد، پنل بسته می‌شود و فوکوس به همان دکمه برمی‌گردد. کنترل حالت منو فقط روی دسکتاپ است.

کامپوننت‌های مرتبط

  • انتخاب قالب صفحه — هر صفحه داخل قاب یک قالب است (ListPage، DetailPage، FormPage، SettingsPage، DashboardPage، UtilityPage، CustomPage).
  • PageContainer و PageSection، PageHeader و PageToolbar — اجزایی که قالب‌ها رویشان ساخته شده‌اند؛ مستقیم فقط داخل محتوای یک CustomPage.
  • هندسهٔ صفحه — ارتفاع نوار، عرض منو و حاشیه‌ها از همین توکن‌ها می‌آیند.
  • دامنهٔ سیستم طراحی — قاب و قالب‌ها لایهٔ برنامه‌اند، فقط برای برنامه‌های داده‌ای؛ سایت بازاریابی و مسیرهای بیرون از قاب (منبع چاپ یا PDF، اسلاید) لایهٔ پایه و کامپوننت‌ها را به کار می‌برند؛ صفحهٔ ورود AuthPage است و نسخهٔ منشعب فقط لایهٔ پایه را.
  • UserMenu — در قاب بدون منو (navMode="none")، مقصدهای دیگر محصول مورد‌های همین منو‌اند (onSelect با مسیریاب جابه‌جا می‌شود).