پروایدر و دکمهٔ تعویض تم (ThemeProvider / ThemeToggle)

راه‌حل رسمی و یکپارچهٔ پرتو برای سوییچ زندهٔ تاریک/روشن — پیش‌فرض همیشه تاریک

معرفی

پرتو تاریک‌محور است: مقادیر پایهٔ :root خودِ توکن‌ها تاریک هستند (سلکتور بلوک تاریک :root, [data-theme='dark'], .dark است)، پس مصرف‌کننده‌ای که هیچ چیز اضافه نکند همین الان هم تاریک می‌بیند. تم روشن هم به همان اندازه کامل و تولیدی است — یک opt-in صریح زیر [data-theme='light'] / .light.

ThemeToggle دکمه‌ای است که این سوییچ را در اختیار کاربر می‌گذارد، و ThemeProvider زیرساخت لازم برای کارکردنش (پیش‌فرض تاریک، ماندگاریِ انتخاب کاربر، و نوشتنِ هم‌زمانِ کلاس .dark/.light و attribute data-theme که CSS سیستم طراحی هر دو را می‌خواند) را یک‌بار برای همیشه فراهم می‌کند.

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

  • در هر محصولی که کاربر باید بتواند بین تم تاریک و روشن جابه‌جا شود (نه فقط توسعه‌دهنده در build-time).
  • در اپی با ProductFrame خودتان ThemeToggle نمی‌گذارید: کافی است ThemeProvider در ریشه باشد؛ قاب کلید را در یک جای ثابت می‌گذارد (themeToggle="header" پیش‌فرض، آخرین کنترل نوار بالا پیش از منوی حساب؛ یا "user-menu" داخل منوی حساب).
  • خود ThemeToggle فقط در سطحی بدون قاب: نوار بالای یک سایت معرفی یا SiteHeader، یک بار.

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

  • اگر محصول شما قطعاً و برای همیشه فقط یک تم دارد (بدون قصد دادن انتخاب به کاربر)، همان الگوی استاتیک قدیمی (className="dark" data-theme="dark" بدون Provider) هنوز معتبر است — نصب یک peer dependency اضافه برای چیزی که هرگز عوض نمی‌شود لازم نیست.
  • اگر به یک سوییچ متنی دوحالته با لیبل (نه یک دکمهٔ آیکونی) نیاز دارید — مثلاً در یک صفحهٔ تنظیمات — مستقیماً از useTheme() (صادرشدهٔ next-themes) با Switch بسازید؛ ThemeToggle برای جای فشرده مثل هدر طراحی شده.
روی دکمه کلیک کنید — تمِ کل این صفحه واقعاً عوض می‌شود

از subpath وارد کنید، نه barrel اصلی

چون next-themes یک peer dependency اختیاری در @partodata/ui است، این دو کامپوننت فقط از مسیر @partodata/ui/theme-toggle در دسترس‌اند، نه از @partodata/ui اصلی — تا محصولی که اصلاً سوییچ تم نمی‌خواهد مجبور به نصب next-themes یا سنگین‌تر شدن barrel نشود.

راه‌اندازی (یک‌بار، در ریشهٔ اپ)

npm install next-themes
// Next.js — app/layout.tsx
import { ThemeProvider } from '@partodata/ui/theme-toggle'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // این دو marker همچنان لازم‌اند: پیش از اجرای اسکریپت next-themes روی
    // کلاینت، همین‌ها جلوی «فلاش تمِ غلط» را می‌گیرند — و چون پیش‌فرضِ واقعی
    // هم تاریک است، این یک حدسِ درست برای اکثریتِ قریب‌به‌اتفاقِ بازدیدهاست.
    // suppressHydrationWarning: اسکریپت next-themes پیش از hydration هر دو
    // marker را عوض می‌کند (توصیهٔ خودِ next-themes).
    <html lang="fa" dir="rtl" className="dark" data-theme="dark" suppressHydrationWarning>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}
// اپ Vite/CRA غیر-Next — main.tsx
import { ThemeProvider } from '@partodata/ui/theme-toggle'

document.documentElement.classList.add('dark')
document.documentElement.setAttribute('data-theme', 'dark')

createRoot(document.getElementById('root')!).render(
  <ThemeProvider>
    <App />
  </ThemeProvider>
)

next-themes فقط به react/react-dom وابسته است، نه به Next.js — همین provider بدون تغییر در هر دو نوع اپ کار می‌کند.

استفاده

import { ProductFrame } from '@partodata/ui/product-frame'
// ThemeProvider در ریشه؛ قاب کلید را خودش می‌گذارد. ThemeToggle در actions نه.
;<ProductFrame product={{ name: 'پایش برند' }} nav={nav} themeToggle="header">
  {children}
</ProductFrame>

ThemeToggle در actions قاب یا کلید دوم در همان فایل را قاعدهٔ lint parto/theme-toggle-placement گزارش می‌کند؛ قاب هم در محیط توسعه هشدار می‌دهد و فقط همان یکی را نگه می‌دارد.

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

دکمهٔ پیش‌فرض

آیکونی و فشرده — همان نمونهٔ بالا. variant="ghost" پیش‌فرض است و بدون size مربعی 30 پیکسلی روی نردبان کنترل‌هاست (تا 3٫x دکمهٔ 36 پیکسلی icon)، دقیقاً مناسب یک اکشن هدر.

<ThemeToggle />

با variant/size سفارشی

هر prop دیگری که Button می‌پذیرد پاس داده می‌شود:

<ThemeToggle variant="outline" size="md" className="border-border" />

در نوار بالای ProductFrame

کلیدی که قاب ProductFrame می‌گذارد (و هر ThemeToggle بدون size در ردیفی با ControlSizeProvider) دکمه‌ای مربعی به ارتفاع همان ردیف است تا با بقیهٔ دکمه‌های فقط‌آیکون هم‌قد باشد. بیرون از چنین ردیفی مربعی 30 پیکسلی (sm) است؛ تا 3٫x دکمهٔ 36 پیکسلی icon بود.

برچسب سفارشی

نام دسترس‌پذیر دکمه نام همان حالتی است که روشن و خاموش می‌شود؛ aria-pressed می‌گوید روشن است یا نه.

<ThemeToggle label="حالت شب" />

پیش‌فرض = تاریک، همیشه

  • ThemeProvider داخلاً next-themes را با defaultTheme="dark" و enableSystem={false} صدا می‌زند — یعنی پرتو از ترجیح سیستم‌عامل کاربر پیروی نمی‌کند؛ همیشه با تاریک شروع می‌کند و کاربر آگاهانه به روشن سوییچ می‌کند، نه برعکس.
  • انتخاب کاربر با همان مکانیزم استاندارد next-themes (کلید theme در localStorage) بین بازدیدها حفظ می‌شود.
  • پیش از mount شدن روی کلاینت (لحظه‌ای که SSR هنوز نمی‌داند کاربر قبلاً چه انتخابی کرده)، ThemeToggle آیکون حالت تاریک را نشان می‌دهد، نه یک اسکلتون خالی — چون تاریک همان پیش‌فرض واقعی است، این حدس برای اکثریت بازدیدها درست است و از یک فلش/پرش بصری غیرضروری جلوگیری می‌کند.
  • CSS سیستم طراحی هم روی کلاس .dark/.light و هم روی attribute صریح data-theme تعریف شده (بلوک توکن‌های روشن [data-theme='light'], .light است و variant dark: با [data-theme='dark'] * هم فعال می‌شود)، پس این دو نباید حتی یک لحظه با هم ناسازگار باشند. ThemeProvider هر دو را از خودِ next-themes می‌خواهد (attribute={['class', 'data-theme']})، پس هر دو در یک لحظه نوشته می‌شوند: هم در اسکریپت مسدودکنندهٔ next-themes پیش از اولین رنگ‌آمیزی، و هم در هر تعویض تم. لازم نیست خودتان effect جدایی برایش بنویسید.
  • تا پیش از نسخهٔ 4.0 فقط کلاس از next-themes می‌آمد و data-theme در یک effect ری‌اکت کپی می‌شد؛ بعد از بارگذاری دوبارهٔ صفحه در تم روشن، صفحه تا اجرای hydration با class="light" و data-theme="dark" رنگ می‌شد، یعنی نیمه‌روشن. اگر برای این مشکل provider محلی نوشته‌اید، اکنون می‌توانید آن را حذف کنید.

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

بکنید

  • ThemeToggle را زیر ThemeProvider رندر کنید، نه بی‌واسطه - ThemeProvider را فقط یک‌بار، در ریشهٔ اپ قرار دهید - marker های استاتیک className="dark" data-theme="dark" را روی عنصر ریشه نگه دارید، حتی بعد از اضافه‌کردن ThemeProvider — جلوی فلش تمِ غلط را می‌گیرند

نکنید

  • enableSystem را روی true عوض نکنید — پرتو عمداً از ترجیح سیستم‌عامل پیروی نمی‌کند، همیشه با تاریک شروع می‌شود - چند ThemeProvider تودرتو در یک اپ قرار ندهید — یک provider سراسری در ریشه کافی است - ThemeToggle را از @partodata/ui اصلی import نکنید — فقط از subpath @partodata/ui/theme-toggle در دسترس است

Props — ThemeToggle

Prop

Type

هر prop دیگری که Button می‌پذیرد (به‌جز onClick/children، که داخلی مدیریت می‌شوند) هم پاس داده می‌شود — از جمله variant (پیش‌فرض 'ghost')، size (پیش‌فرض 'sm'، مربع 30 پیکسلی؛ داخل ControlSizeProvider مربعی به ارتفاع ردیف) و className.

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

  • عنصر زیرین یک <button> واقعی است (از طریق Button) — با Tab قابل‌دسترسی و با Enter/Space قابل‌فعال‌سازی.
  • یک دکمهٔ دوحالته است: نامش ثابت است («تم تاریک») و aria-pressed می‌گوید تم تاریک روشن است یا نه؛ صفحه‌خوان «تم تاریک، دکمهٔ دوحالته، فشرده» می‌خواند. آیکون تم فعلی را نشان می‌دهد (ماه در تاریک، خورشید در روشن).
  • نامی که عمل بعدی را بگوید («تغییر به تم روشن») با aria-pressed جمع نمی‌شود: صفحه‌خوان هر دو را می‌خواند و حاصل برعکس تم فعلی شنیده می‌شود. برای همین darkLabel/lightLabel (تا 4.x) در 5.0 حذف شدند.

تعامل با کیبورد

  • Tab: انتقال فوکوس به دکمه - Enter یا Space: تعویض تم

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

  • ProductFrame — کلید تم را خودش می‌گذارد (themeToggle: header یا user-menu)؛ هرگز ThemeToggle در actions.
  • Switch — اگر به‌جای دکمهٔ آیکونی یک سوییچ متنی/دوحالته با لیبل می‌خواهید (مثلاً در صفحهٔ تنظیمات به‌جای هدر)، دستی از useTheme() (صادرشدهٔ next-themes) با Switch بسازید.