پرتوپرتو

الگوهای بارگذاری

راهنمای طراحی حالت‌های loading در پرتو — Skeleton، Spinner، و Progressive Loading

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

حالتابزارمثال
بارگذاری اولیه صفحهSkeletonلیست اینفلوئنسرها هنگام fetch اولیه
عملیات کوتاه (< ۲ ثانیه)Spinner در دکمهذخیره فرم، اعمال فیلتر
بارگذاری نمودارprop isLoadingنمودار روند
بارگذاری تدریجیSkeleton per itemلیست بی‌نهایت

نمونه بصری

بارگذاری اولیه صفحه — Skeleton

بارگذاری لیست

عملیات کوتاه — Spinner در دکمه


Skeleton

کاربرد اصلی

import { Skeleton } from '@partodata/ui'

// Skeleton ساده
<Skeleton className="h-4 w-48" />

// جایگزین کارت
<div className="bg-surface-100 p-4 rounded-lg border border-default space-y-3">
  <div className="flex items-center gap-3">
    <Skeleton className="h-10 w-10 rounded-full" />
    <div className="space-y-2">
      <Skeleton className="h-4 w-32" />
      <Skeleton className="h-3 w-24" />
    </div>
  </div>
  <Skeleton className="h-4 w-full" />
  <Skeleton className="h-4 w-3/4" />
</div>

جایگزین لیست

function InfluencerListSkeleton() {
  return (
    <div className="space-y-3">
      {Array.from({ length: 5 }).map((_, i) => (
        <div key={i} className="flex items-center gap-3 p-4 border border-default rounded-lg">
          <Skeleton className="h-12 w-12 rounded-full" />
          <div className="flex-1 space-y-2">
            <Skeleton className="h-4 w-1/3" />
            <Skeleton className="h-3 w-1/4" />
          </div>
          <Skeleton className="h-8 w-20" />
        </div>
      ))}
    </div>
  )
}

// استفاده
{isLoading ? <InfluencerListSkeleton /> : <InfluencerList data={data} />}

جایگزین MetricCard

function MetricCardSkeleton() {
  return (
    <div className="bg-surface-100 p-4 rounded-lg border border-default space-y-2">
      <Skeleton className="h-3 w-24" />
      <Skeleton className="h-8 w-32" />
      <Skeleton className="h-3 w-16" />
    </div>
  )
}

Spinner در دکمه

برای عملیات‌های کوتاه (submit، ذخیره)، Spinner را داخل دکمه قرار دهید:

import { Button, Spinner } from '@partodata/ui'

<Button disabled={isSubmitting}>
  {isSubmitting && <Spinner className="ms-2 h-4 w-4" />}
  {isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
</Button>

Skeleton نمودار

همه نمودارهای پرتو از isLoading پشتیبانی می‌کنند:

// نمودار باید داخل یک عنصر با ارتفاع مشخص باشد: ResponsiveContainer با
// height="100%" داخل والدِ بی‌ارتفاع، بی‌صدا هیچ‌چیز رندر نمی‌کند.
;< ="h-64 w-full">
  <
    ={}
    ={['نرخ تعامل']}
    ={}
    ="نمودار روند نرخ تعامل"
  />
</>

نام واقعی `PartoLineChart` است

خروجی‌های نمودار همه پیشوند Parto دارند (PartoLineChart، PartoBarChart، …). نسخهٔ پیشین این صفحه LineChart می‌نوشت که وجود ندارد. و ارتفاع را فراموش نکنید — این خرابی هیچ خطایی نمی‌دهد، فقط نمودار غیب می‌شود.


Progressive Loading (بارگذاری تدریجی)

برای لیست‌های بی‌نهایت یا صفحه‌بندی:

const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery(...)

return (
  <div>
    {/* داده‌های بارگذاری شده */}
    {data?.pages.map(page =>
      page.items.map(item => <InfluencerCard key={item.id} data={item} />)
    )}

    {/* بارگذاری آیتم‌های جدید */}
    {isFetchingNextPage && (
      <div className="space-y-3 mt-3">
        {Array.from({ length: 3 }).map((_, i) => (
          <InfluencerCardSkeleton key={i} />
        ))}
      </div>
    )}

    {/* دکمه بارگذاری بیشتر */}
    {hasNextPage && !isFetchingNextPage && (
      <Button variant="outline" className="w-full mt-4" onClick={fetchNextPage}>
        بارگذاری بیشتر
      </Button>
    )}
  </div>
)

اصول Skeleton خوب

  • ابعاد واقعی — Skeleton باید شبیه محتوای واقعی باشد. اگر کارت ۸۰px ارتفاع دارد، Skeleton هم همین ارتفاع داشته باشد
  • بدون پرجزئیاتی — Skeleton نباید هر جزء را شبیه‌سازی کند — فقط ساختار کلی
  • انیمیشن یکنواخت — Skeleton پرتو دو حالت انیمیشن دارد (pulse پیش‌فرض و شیمری با variant="shimmer") که نشان می‌دهد در حال بارگذاری است
  • تعداد مناسب — ۳ تا ۵ item برای لیست، نه ۲۰

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

// اطلاع‌رسانی به screen reader
<div aria-busy={isLoading} aria-label="در حال بارگذاری...">
  {isLoading ? (
    <InfluencerListSkeleton />
  ) : (
    <InfluencerList data={data} />
  )}
</div>

// یا با aria-live
<div aria-live="polite">
  {isLoading && <span className="sr-only">در حال بارگذاری داده‌ها...</span>}
</div>

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

  • الگو را از جدول ابتدای صفحه انتخاب کنید — Skeleton برای بارگذاری اولیه، Spinner فقط برای عملیات کوتاه‌تر از ۲ ثانیه. Spinner تمام‌صفحه برای fetch اولیه، صفحه را خالی و مبهم نشان می‌دهد.
  • هر دو حالت انیمیشن Skeleton (pulse پیش‌فرض و shimmer) ترجیح prefers-reduced-motion کاربر را رعایت می‌کنند — برای placeholder انیمیشن CSS سفارشی ننویسید.
  • حالت‌ها را با هم طراحی کنید، نه یکی‌یکی: ابتدا Skeleton، سپس داده، خالی یا خطا — هرگز دو حالت هم‌زمان. فهرست کامل، به‌همراه حالت ناقص که بیشتر از همه فراموش می‌شود، در مدل واحد حالت‌ها.

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

Skeleton دست‌ساز با رنگ hardcode

اشتباه: ساختن placeholder با div خاکستری مانند bg-gray-200 animate-pulse به جای کامپوننت Skeleton.

چرا اشتباه است: تم پیش‌فرض پرتو تیره است (:root توکن‌های dark را حمل می‌کند) — بلوک bg-gray-200 در تم تیره مثل مستطیل روشن می‌درخشد و قانون no-hardcoded-colors در ESLint سیستم آن را رد می‌کند. نسخه دست‌ساز role="status"، aria-busy و رعایت prefers-reduced-motion کامپوننت اصلی را هم ندارد. Skeleton پرتو با bg-foreground/10 رنگ می‌گیرد و روی هر سطحی در هر دو تم کنتراست یکسان دارد.

الگوی درست:

// ❌ غلط — در تم تیره (پیش‌فرض پرتو) مستطیل روشن می‌درخشد
<div className="bg-gray-200 animate-pulse rounded-md h-4 w-48" />

// ✅ درست — bg-foreground/10 در هر دو تم کار می‌کند + ARIA داخلی دارد
<Skeleton className="h-4 w-48" />

فاصله‌گذاری فیزیکی Spinner در دکمه

اشتباه: جدا کردن Spinner از متن دکمه با mr-2 یا ml-2.

چرا اشتباه است: صفحات پرتو RTL هستند؛ mr-2 در RTL فاصله را در سمت اشتباه می‌گذارد — Spinner به متن می‌چسبد و از لبه دکمه فاصله می‌گیرد. قانون no-physical-css-properties نیز آن را رد می‌کند. Button پرتو خودش gap-2 داخلی دارد، پس معمولاً هیچ margin لازم نیست؛ اگر فاصله بیشتری خواستید فقط Logical Properties (ms-*/me-*) مجاز است — مانند مثال بخش «Spinner در دکمه» همین صفحه.

الگوی درست:

// ❌ غلط — margin فیزیکی در RTL در سمت اشتباه می‌نشیند
<Button disabled={isSubmitting}>
  {isSubmitting && <Spinner className="mr-2" />}
  ذخیره کمپین
</Button>

// ✅ درست — gap-2 داخلی Button فاصله را در هر دو جهت مدیریت می‌کند
<Button disabled={isSubmitting}>
  {isSubmitting && <Spinner />}
  {isSubmitting ? 'در حال ذخیره...' : 'ذخیره کمپین'}
</Button>

جایگزینی کل جدول با Skeleton در هر تغییر صفحه

اشتباه: در DataTable با صفحه‌بندی سمت سرور، بستن prop isLoading به isFetching کوئری — طوری که هر تغییر صفحه یا مرتب‌سازی، کل جدول را با Skeleton جایگزین کند.

چرا اشتباه است: در صفحه‌بندی سرور هر کلیک روی «صفحه بعد» یک fetch است. اگر کل جدول skeleton شود، داده‌ها در هر ناوبری ناپدید می‌شوند، موقعیت اسکرول می‌پرد و layout shift تکرار می‌شود — جدول «چشمک‌زن» به نظر می‌رسد.

الگوی درست: isLoading جدول را فقط به بارگذاری اولیه (isPending) ببندید و هنگام ناوبری، داده صفحه قبل را نگه دارید:

import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { DataTable } from '@partodata/ui'

const { data, isPending, isFetching } = useQuery({
  queryKey: ['influencers', page, sort],
  queryFn: () => fetchInfluencers({ page, sort }),
  placeholderData: keepPreviousData, // داده صفحه قبل تا رسیدن صفحه جدید می‌ماند
})

<DataTable
  columns={columns}
  data={data?.items ?? []}
  isLoading={isPending} // Skeleton فقط برای بارگذاری اولیه
  loadingRows={5}
  // هنگام fetch صفحه بعد، جدول قبلی با کمی شفافیت دیده می‌شود
  className={isFetching && !isPending ? 'opacity-60 transition-opacity' : undefined}
/>

تکرار Skeleton مستقل در حلقه (اسپم screen reader)

اشتباه: رندر ده‌ها <Skeleton /> جداگانه در map برای ساختن placeholder فهرست.

چرا اشتباه است: ریشه هر Skeleton مستقل role="status" و aria-label دارد؛ ۱۵ عدد یعنی ۱۵ اعلان «Loading» پشت‌سرهم برای screen reader. prop count و preset‌های آماده (TableSkeleton، CardSkeleton، AvatarTextSkeleton) دقیقاً برای همین ساخته شده‌اند: یک wrapper واحد با role="status" و عناصر داخلی aria-hidden.

الگوی درست:

// ❌ غلط — ۱۵ اعلان جداگانه برای screen reader
{Array.from({ length: 15 }).map((_, i) => (
  <Skeleton key={i} shape="line" />
))}

// ✅ درست — یک اعلان، با prop count
<Skeleton shape="line" count={15} />

// ✅ درست — چیدمان سفارشی: یک role="status" برای کل فهرست
<div role="status" aria-busy="true" aria-label="در حال بارگذاری فهرست اینفلوئنسرها" className="space-y-3">
  {Array.from({ length: 5 }).map((_, i) => (
    <div key={i} className="flex items-center gap-3 p-4 border border-default rounded-lg">
      <Skeleton shape="circle" size="md" aria-hidden="true" />
      <div className="flex-1 space-y-2">
        <Skeleton shape="line" className="w-1/3" aria-hidden="true" />
        <Skeleton shape="line" className="h-3 w-1/4" aria-hidden="true" />
      </div>
    </div>
  ))}
</div>

دکمه بدون disabled و متن غیررسمی هنگام ارسال

اشتباه: نمایش Spinner در دکمه بدون disabled، یا با متن محاوره‌ای مانند «داره ذخیره می‌شه...».

چرا اشتباه است: بدون disabled کاربر می‌تواند دوباره کلیک کند و عملیات دوبار ثبت شود — مثلاً کمپین تخفیف فصلی دو بار ارسال می‌شود. متن محاوره‌ای نیز قانون فارسی رسمی سیستم طراحی را نقض می‌کند («در حال ذخیره...» نه «داره ذخیره می‌شه...»).

الگوی درست: همان الگوی بخش «Spinner در دکمه» همین صفحه — disabled={isSubmitting} به‌همراه Spinner و متن رسمی:

// ❌ غلط — کلیک دوم عملیات را دوبار ثبت می‌کند + متن غیررسمی
<Button onClick={submitCampaign}>
  {isSubmitting && <Spinner />}
  {isSubmitting ? 'داره ذخیره می‌شه...' : 'ذخیره'}
</Button>

// ✅ درست — disabled جلوی ارسال تکراری را می‌گیرد و متن رسمی است
<Button disabled={isSubmitting} onClick={submitCampaign}>
  {isSubmitting && <Spinner />}
  {isSubmitting ? 'در حال ذخیره...' : 'ذخیره'}
</Button>

صفحات مرتبط