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

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

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

هر حالت یک پاسخ دارد، و در صفحه‌های اپلیکیشن آن را قالب صفحه می‌کشد:

حالتابزارمثال
بارگذاری اول صفحه یا یک بلوکstate={pageState(…)} قالب: اسکلتی به شکل محتوا جای آنفهرست اینفلوئنسرها هنگام fetch اولیه
بارگذاری صفحهٔ بعد، جست‌وجو یا فیلترهمان state: ردیف‌ها کم‌رنگ سر جایشان می‌مانندرفتن به صفحهٔ 2 فهرست
بارگذاری یک نمودار داشبوردstate همان DashboardChartنمودار روند
عملیات کوتاه (ذخیره، ارسال)isLoading خود دکمه؛ در فرم قالب submittingذخیره فرم
بارگذاری تدریجیSkeleton per itemلیست بی‌نهایت

در صفحه: state قالب

بارگذاری صفحه را خودتان نمی‌کشید: pageState از فیلدهای useAsync حالت را می‌سازد و قالب اسکلتی به شکل محتوا را جای آن می‌گذارد؛ سربرگ صفحه و نوارابزار سر جایشان می‌مانند. وقتی ردیف‌ها روی صفحه‌اند و صفحهٔ بعد، جست‌وجو یا فیلتر بارگذاری می‌شود، ردیف‌ها کم‌رنگ می‌مانند و ناحیهٔ وضعیت «در حال بارگذاری» را اعلام می‌کند — نه اسکلت دوباره، نه Spinner، نه isLoading جدول:

import { DataTable, useAsync } from '@partodata/ui'
import { ListPage, pageState } from '@partodata/ui/templates'

const { data, isLoading, error, run } = useAsync<Paged<Influencer>>()

;<ListPage
  title="اینفلوئنسرها"
  skeleton="table"
  state={pageState({
    data: data?.items,
    isLoading,
    error,
    onRetry: load,
    emptyCopy: { title: 'هنوز اینفلوئنسری ثبت نشده است' },
  })}
>
  <DataTable columns={columns} data={data?.items ?? []} />
</ListPage>

شکل اسکلت را skeleton می‌گوید: table، list (فید، نظرها)، cards؛ در بلوک‌ها kpis، form، sections و chart. بلوکی که جدا بارگذاری می‌شود state خودش را دارد: DetailSection، DashboardChart یا PageState. در App Router اگر loading.tsx می‌نویسید، همان قالب صفحه را با state={{ status: 'loading' }} در آن رندر کنید.


نمونه بصری


Skeleton

در صفحه اسکلت را قالب می‌کشد (بالا). Skeleton و presetهایش را خودتان فقط جایی بنویسید که قالبی حالت را نمی‌کشد: یک Sheet، یک پنل، محتوای یک CustomPage.

کاربرد اصلی

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

بارگذاری در دکمه

برای عملیات‌های کوتاه (ذخیره، ارسال)، isLoading را به خود دکمه بدهید: دکمه Spinner خودش را نشان می‌دهد، غیرفعال می‌شود و aria-busy می‌گیرد. در فرم قالب (FormPage، SettingsSection) این کار submitting قالب است و دکمهٔ ذخیره را خودتان نمی‌نویسید:

import { Button } from '@partodata/ui'

;<Button variant="primary" isLoading={isSubmitting} onClick={submitCampaign}>
  ذخیره
</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 باید شبیه محتوای واقعی باشد. اگر کارت 80px ارتفاع دارد، Skeleton هم همین ارتفاع داشته باشد
  • بدون پرجزئیاتی — Skeleton نباید هر جزء را شبیه‌سازی کند — فقط ساختار کلی
  • انیمیشن یکنواخت — Skeleton پرتو دو حالت انیمیشن دارد (pulse پیش‌فرض و شیمری با variant="shimmer") که نشان می‌دهد در حال بارگذاری است
  • تعداد مناسب — 3 تا 5 item برای لیست، نه 20

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

قالب‌ها و PageState این را خودشان رعایت می‌کنند: یک ناحیهٔ role="status" که همیشه در صفحه است بارگذاری (و بارگذاری دوباره) را اعلام می‌کند و اسکلت aria-busy دارد. الگوی زیر برای بارگذاری‌ای است که بیرون از قالب خودتان نمایش می‌دهید:

// اطلاع‌رسانی به 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>

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

  • الگو را از جدول ابتدای صفحه انتخاب کنید — در صفحه state قالب برای بارگذاری، isLoading دکمه فقط برای عملیات کوتاه. 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، یا اصلاً ساختن Spinner و متن «در حال ذخیره» خودتان.

چرا اشتباه است: صفحات پرتو RTL هستند؛ mr-2 در RTL فاصله را در سمت اشتباه می‌گذارد و قانون no-physical-css-properties آن را رد می‌کند. هر صفحه هم Spinner را جای دیگری می‌گذارد. isLoading خود Button Spinner را در جای آیکون می‌گذارد، دکمه را غیرفعال می‌کند و aria-busy می‌دهد.

الگوی درست:

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

// ✅ درست — دکمه بارگذاری خودش را نشان می‌دهد
<Button variant="primary" isLoading={isSubmitting} onClick={submitCampaign}>
  ذخیره کمپین
</Button>

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

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

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

الگوی درست: حالت را با pageState به قالب بدهید و به جدول هیچ حالتی ندهید. تا چیزی روی صفحه نیست، اسکلت؛ وقتی ردیف‌ها روی صفحه‌اند و صفحهٔ بعد بارگذاری می‌شود، قالب همان ردیف‌ها را کم‌رنگ نگه می‌دارد (با 150 میلی‌ثانیه تأخیر، تا پاسخ سریع چشمک نزند) و ناحیهٔ وضعیت «در حال بارگذاری» را اعلام می‌کند:

import { DataTable, useAsync } from '@partodata/ui'
import { ListPage, pageState } from '@partodata/ui/templates'

const { data, isLoading, error, run } = useAsync<Paged<Influencer>>()

<ListPage
  title="اینفلوئنسرها"
  state={pageState({ data: data?.items, isLoading, error, onRetry: load, emptyCopy: { title: 'هنوز اینفلوئنسری ثبت نشده است' } })}
  pagination={{ currentPage: page, totalPages, onPageChange: setPage, totalRows, pageSize: 25 }}
>
  <DataTable columns={columns} data={data?.items ?? []} />
</ListPage>

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

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

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

الگوی درست:

// ❌ غلط — 15 اعلان جداگانه برای 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 کاربر می‌تواند دوباره کلیک کند و عملیات دوبار ثبت شود — مثلاً کمپین تخفیف فصلی دو بار ارسال می‌شود. متن محاوره‌ای نیز قانون فارسی رسمی سیستم طراحی را نقض می‌کند.

الگوی درست: همان الگوی بخش «بارگذاری در دکمه» همین صفحه — isLoading دکمه را غیرفعال می‌کند و برچسب رسمی خودش می‌ماند:

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

// ✅ درست — isLoading جلوی ارسال تکراری را می‌گیرد
<Button variant="primary" isLoading={isSubmitting} onClick={submitCampaign}>
  ذخیره
</Button>

صفحات مرتبط