طراحی وب

طراحی Loading States و Skeleton Screens

تفاوت Skeleton، Spinner و Progress، مدیریت success و empty و error و cancelled، aria-busy و نمونه مستقل HTML با کاهش حرکت.

1404/12/04 5 دقیقه مطالعه 1,735 بازدید

کاربر پس از انتخاب «نمایش سفارش‌ها» باید بفهمد درخواست شروع شده، نتیجه هنوز نرسیده و چه کاری می‌تواند انجام دهد. یک کادر خاکستری متحرک به تنهایی این اطلاعات را کامل نمی‌دهد. Loading State قرارداد میان عمل کاربر و نتیجه سیستم است. ظاهر آن باید با نوع انتظار هماهنگ باشد و در پایان به محتوای معتبر، حالت خالی یا خطا تبدیل شود.

Skeleton همیشه از Spinner بهتر نیست و ادعای «دو برابر شدن حس سرعت» بدون مطالعه مشخص قابل اتکا نیست. Skeleton ساختار احتمالی محتوا را نشان می‌دهد؛ Spinner می‌تواند انتظار کوتاه و نامعین را معرفی کند؛ Progress مقدار پیشرفت قابل اندازه‌گیری را بیان می‌کند. انتخاب از داده‌ای که درباره عملیات دارید می‌آید. هیچ‌کدام زمان واقعی شبکه یا پردازش را خودکار کم نمی‌کنند.

نوع انتظار را قبل از ظاهر مشخص کنید

بارگیری اولیه صفحه، به‌روزرسانی یک بخش و ارسال عملی حساس حالت یکسانی ندارند. در بارگیری اولیه شاید هنوز محتوایی وجود نداشته باشد. در Refresh می‌توانید داده قبلی را نگه دارید و وضعیت تازه شدن را نشان دهید. در پرداخت، باید جلوی برداشت نتیجه قطعی پیش از تأیید معتبر را بگیرید. استفاده از یک overlay تمام‌صفحه برای همه این حالت‌ها، کارهای نامرتبط را هم متوقف می‌کند.

محدوده انتظار را مشخص کنید. اگر فقط فهرست جست‌وجو تغییر می‌کند، عنوان، ورودی و راه بازگشت می‌توانند قابل استفاده بمانند. پنهان کردن کل صفحه به خاطر یک درخواست کوچک، زمینه کاربر را از بین می‌برد. از طرف دیگر، نگه داشتن دکمه عملی که با داده قدیمی خطر دارد باید با قرارداد تازه بودن داده هماهنگ باشد. این تصمیم فراتر از انتخاب animation است.

داده قدیمی را با داده تازه یکسان نمایش ندهید اگر تفاوت برای تصمیم مهم است. زمان آخرین به‌روزرسانی یا پیام «در حال تازه کردن» می‌تواند زمینه بدهد. شکست Refresh لزوماً به معنی حذف نتیجه قبلی نیست. باید روشن شود محتوای موجود مربوط به چه زمانی است و آیا کاربر می‌تواند بر اساس آن ادامه دهد.

Skeleton باید به محتوای مورد انتظار مربوط باشد

برای فهرست متنی، چند سطر خنثی می‌توانند محل عنوان و توضیح را نشان دهند. تعداد و ارتفاع باید تقریبی و معقول باشند؛ شکل یک صفحه متفاوت، انتظار اشتباه می‌سازد. Skeleton متن واقعی نیست و نباید برای Screen Reader به عنوان چند رکورد خوانده شود. آن را به عنوان نمایش دیداری جدا از پیام وضعیت نگه دارید.

فضای رزروشده می‌تواند تغییر ناگهانی layout را محدود کند، اما تضمین حذف تمام Layout Shift نیست. طول متن، تعداد رکورد و فایل‌های دیگر هنوز اثر دارند. اگر بارگذاری بسیار کوتاه است، نمایش و حذف سریع Skeleton ممکن است چشمک‌زدن ایجاد کند. زمان‌بندی را با رفتار واقعی محصول بررسی کنید و برای دیده شدن animation، نتیجه آماده را عمداً معطل نکنید.

رنگ خاکستری ثابت مثل #E5E7EB برای همه Themeها مناسب نیست. از نقش رنگی سطح و Placeholder استفاده کنید و در Dark Mode ترکیب را بررسی کنید. Skeleton نباید از محتوای اصلی پررنگ‌تر باشد یا شبیه کنترل قابل انتخاب دیده شود. مقاله Dark Mode به تعریف رنگ‌ها بر اساس نقش می‌پردازد؛ حالت انتظار نیز باید از همان قرارداد پیروی کند.

Progress فقط مقدار واقعی را نشان دهد

طبق راهنمای progress در MDN، مقدار value بخش انجام‌شده و max کل کار را مشخص می‌کند. در نبود مقدار معلوم، عنصر می‌تواند indeterminate باشد. جلو بردن نوار با timer تا ۹۰ درصد و نگه داشتن آن، درصد واقعی تولید نمی‌کند. اگر فقط می‌دانید درخواست در جریان است، همان را صریح نمایش دهید.

در upload، ارسال تمام byteها به معنی پایان پردازش سرور نیست. ممکن است پس از انتقال، اعتبارسنجی یا تبدیل فایل ادامه داشته باشد. مرحله‌ها را از هم جدا کنید تا رسیدن نوار انتقال به پایان، پیام موفقیت نهایی نسازد. در عملیات دارای چند کار، نسبت تعداد کارهای تمام‌شده هم الزاماً نسبت زمان باقی‌مانده نیست؛ وزن متفاوت آن‌ها را در توضیح در نظر بگیرید.

Progress باید نام قابل دسترس داشته باشد. متن داخل تگ به تنهایی label قابل اتکا برای آن نیست؛ از label یا ارتباط ARIA مناسب استفاده کنید. برای تغییرهای پیوسته، لازم نیست هر مقدار کوچک با اعلان فوری خوانده شود. اطلاعات باید قابل دریافت باشد بدون اینکه مسیر شنیدن محتوای دیگر را دائماً قطع کند.

نمونه مستقل برای حالت‌های اصلی

نمونه زیر یک فهرست متنی فرضی است و درخواست شبکه ندارد. برای آموزش، عملیات async با تأخیر مشخص شبیه‌سازی می‌شود؛ این زمان اندازه‌گیری عملکرد واقعی نیست. فایل را با نام index.html ذخیره و در مرورگر باز کنید. هیچ package یا دارایی تصویری لازم ندارد. حالت نتیجه را انتخاب کنید و بارگذاری، لغو، نتیجه خالی یا خطا را مشاهده کنید.

<!doctype html>
<html lang="fa" dir="rtl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>نمونه Loading State</title>
  <style>
    * { box-sizing:border-box; }
    body { margin:0; color:#18212e; background:#f4f6f9; font:1rem/1.9 system-ui,sans-serif; }
    main { max-inline-size:42rem; margin-inline:auto; padding:2rem 1rem; }
    section { padding:1rem; border:1px solid #697787; border-radius:.5rem; background:#fff; }
    button,select { font:inherit; padding:.4rem .8rem; }
    button { color:#fff; background:#164ca4; border:1px solid #164ca4; border-radius:.3rem; }
    button:disabled { opacity:.6; }
    select { color:#18212e; background:#fff; border:1px solid #697787; }
    :focus-visible { outline:3px solid #164ca4; outline-offset:3px; }
    .controls { display:flex; flex-wrap:wrap; gap:.7rem; align-items:center; margin-block:1rem; }
    .skeleton { display:grid; gap:.9rem; padding-block:1rem; }
    .line { block-size:1.2rem; border-radius:.2rem; background:#ccd3dc; animation:pulse 1.2s ease-in-out infinite alternate; }
    .line:nth-child(even) { inline-size:72%; }
    @keyframes pulse { from { opacity:.55; } to { opacity:1; } }
    @media (prefers-reduced-motion:reduce) { .line { animation:none; } }
    [hidden] { display:none; }
  </style>
</head>
<body>
  <main>
    <h1>نمایش حالت‌های فهرست</h1>
    <p>این نمونه درخواست شبکه ندارد. تأخیر دو ثانیه‌ای فقط برای نمایش حالت انتظار است.</p>
    <noscript><p>برای اجرای این نمونه تعاملی، JavaScript را فعال کنید.</p></noscript>
    <div class="controls">
      <label for="outcome">نتیجه شبیه‌سازی</label>
      <select id="outcome"><option value="success">چند نتیجه</option><option value="empty">فهرست خالی</option><option value="error">خطا</option></select>
      <button id="load" type="button" disabled>بارگذاری نمونه</button>
      <button id="cancel" type="button" hidden>لغو بارگذاری</button>
    </div>
    <p id="status" role="status">آماده شروع نمونه.</p>
    <section id="results" aria-labelledby="results-title" aria-busy="false">
      <h2 id="results-title">نتیجه</h2>
      <div id="skeleton" class="skeleton" aria-hidden="true" hidden>
        <div class="line"></div><div class="line"></div><div class="line"></div><div class="line"></div>
      </div>
      <div id="content"><p>برای دیدن حالت‌ها، بارگذاری نمونه را انتخاب کنید.</p></div>
    </section>
  </main>
  <script>
    const load = document.getElementById('load');
    const cancel = document.getElementById('cancel');
    const outcome = document.getElementById('outcome');
    const results = document.getElementById('results');
    const skeleton = document.getElementById('skeleton');
    const content = document.getElementById('content');
    const status = document.getElementById('status');
    let controller = null;

    function simulate(mode, signal) {
      return new Promise((resolve, reject) => {
        const onAbort = () => {
          clearTimeout(timer);
          reject(new DOMException('Cancelled', 'AbortError'));
        };
        const timer = setTimeout(() => {
          signal.removeEventListener('abort', onAbort);
          if (mode === 'error') reject(new Error('Demo failure'));
          else resolve(mode === 'empty' ? [] : ['مقاله نمونه درباره فرم', 'مقاله نمونه درباره رنگ', 'مقاله نمونه درباره ناوبری']);
        }, 2000);
        signal.addEventListener('abort', onAbort, { once:true });
      });
    }
    function showMessage(text) {
      const paragraph = document.createElement('p');
      paragraph.textContent = text;
      content.replaceChildren(paragraph);
    }
    function setBusy(busy) {
      results.setAttribute('aria-busy', String(busy));
      skeleton.hidden = !busy;
      content.hidden = busy;
      load.disabled = busy;
      outcome.disabled = busy;
      cancel.hidden = !busy;
    }
    load.addEventListener('click', async () => {
      if (controller) return;
      controller = new AbortController();
      setBusy(true);
      status.textContent = 'نمونه در حال بارگذاری است؛ امکان لغو دارید.';
      try {
        const items = await simulate(outcome.value, controller.signal);
        if (items.length === 0) {
          showMessage('نتیجه معتبر دریافت شد؛ فهرست نمونه خالی است.');
          status.textContent = 'بارگذاری تمام شد؛ نتیجه‌ای وجود ندارد.';
        } else {
          const list = document.createElement('ul');
          for (const item of items) {
            const entry = document.createElement('li');
            entry.textContent = item;
            list.append(entry);
          }
          content.replaceChildren(list);
          status.textContent = `${items.length} نتیجه نمونه دریافت شد.`;
        }
      } catch (error) {
        if (error.name === 'AbortError') {
          showMessage('بارگذاری نمونه لغو شد؛ می‌توانید دوباره شروع کنید.');
          status.textContent = 'بارگذاری لغو شد.';
        } else {
          showMessage('خطای شبیه‌سازی‌شده رخ داد؛ نتیجه معتبری دریافت نشده است. دوباره تلاش کنید.');
          status.textContent = 'دریافت نتیجه ناموفق بود.';
        }
      } finally {
        controller = null;
        const cancelHadFocus = document.activeElement === cancel;
        setBusy(false);
        if (cancelHadFocus) load.focus();
      }
    });
    cancel.addEventListener('click', () => controller?.abort());
    load.disabled = false;
  </script>
</body>
</html>

در کد، انتخاب نتیجه فقط برای نمایش آموزشی است. در محصول، نتیجه باید از پاسخ معتبر منبع داده بیاید، نه انتخاب کاربر یا عدد تصادفی. تابع شبیه‌سازی را می‌توان با درخواست واقعی جایگزین کرد، ولی Schema پاسخ، اعتبارسنجی و خطای شبکه باید برای API همان پروژه تعریف شوند. این مثال بدون دانستن آن قرارداد، ادعای آماده بودن کل جریان Production ندارد.

دکمه بارگذاری از ابتدا وجود دارد و با آماده شدن script فعال می‌شود. پیام noscript توضیح می‌دهد مثال تعاملی به JavaScript نیاز دارد. نتیجه با textContent و ساختن عناصر DOM نمایش داده می‌شود؛ داده در innerHTML قرار نمی‌گیرد. محتوای فهرست متن ثابت نمونه است، اما همین روش مانع تبدیل تصادفی متن به markup در این بخش می‌شود.

وضعیت برنامه‌ای را همراه نمایش تغییر دهید

aria-busy به فناوری کمکی اعلام می‌کند یک ناحیه در حال تغییر است. مقدار آن باید پس از پایان یا خطا اصلاح شود. باقی ماندن true بعد از پایان، وضعیت نامعتبر می‌سازد. این attribute به خودی خود متن دیداری، کنترل لغو یا زمان باقی‌مانده تولید نمی‌کند؛ هر کدام باید با نیاز واقعی طراحی شوند.

پیام status در مثال بیرون از ناحیه busy قرار دارد تا اعلان انتظار به همان ناحیه وابسته نشود. معیار Status Messages توضیح می‌دهد پیام نتیجه یا انتظار چگونه بدون گرفتن Focus قابل تشخیص باشد. موفقیت ساده معمولاً به جابه‌جایی Focus نیاز ندارد. اگر محتوای تازه وظیفه جدیدی آغاز می‌کند، قرارداد تغییر زمینه جداگانه لازم است.

Skeleton با aria-hidden از اطلاعات معنایی کنار گذاشته شده است. کاربر Screen Reader پیام انتظار و سپس نتیجه را دریافت می‌کند، نه نام چند خط خالی. کل فهرست را live region نکنید اگر باعث خواندن ناخواسته تمام رکوردها می‌شود. تعداد نتیجه یا وضعیت کوتاه می‌تواند اعلام شود و خود محتوا با ساختار معمول قابل مرور بماند.

لغو و تلاش دوباره قرارداد مشخص دارند

در مثال، AbortController عملیات شبیه‌سازی‌شده را متوقف می‌کند و timer پاک می‌شود. لغو با شکست متفاوت است؛ پیام باید بگوید بارگذاری لغو شد، نه اینکه خطایی ناشناخته رخ داد. پس از آن، کنترل شروع دوباره در دسترس قرار می‌گیرد. این مسیر برای کاربر قابل پیش‌بینی است و درخواست قبلی نتیجه دیررس را روی صفحه نمی‌نویسد.

در درخواست واقعی، توقف دریافت سمت کلاینت تضمین نمی‌کند هر عمل سمت سرور متوقف شده باشد. برای عملیات فقط‌خواندنی و عملیات دارای اثر، قرارداد متفاوت است. لغو ساخت سفارش یا پرداخت نیاز به وضعیت معتبر سرور دارد. دکمه «لغو» را بدون روشن کردن این اثر ارائه نکنید؛ ممکن است تنها انتظار محلی را متوقف کند و عمل همچنان ادامه یابد.

اگر چند Query هم‌زمان ممکن‌اند، پاسخ قدیمی نباید نتیجه جدید را بازنویسی کند. می‌توانید درخواست قبلی را لغو و شناسه درخواست جاری را بررسی کنید. کنترل این رقابت به قرارداد داده مربوط است و با پنهان کردن Spinner حل نمی‌شود. در مثال فقط یک عملیات در هر زمان مجاز است تا مسئله آموزشی محدود و رفتار کنترل‌ها روشن بماند.

خطا و حالت خالی بعد از Loading جدا هستند

فهرست خالی یعنی دریافت معتبر انجام شده و موردی مطابق شرایط وجود ندارد. خطا یعنی نتیجه قابل اعتماد دریافت نشده است. نمایش پیام «موردی ندارید» در خطای سرویس، کاربر را درباره داده گمراه می‌کند. برای فیلتر فعال، راه تغییر فیلتر را بدهید؛ برای شکست دریافت، راه تلاش دوباره یا پیگیری را بیان کنید. قدم بعدی از علت می‌آید.

در خطا، اطلاعات معتبر قبلی را بدون دلیل پاک نکنید. اگر فقط تلاش تازه ناموفق بوده، می‌توانید داده قبلی را با وضعیت مشخص حفظ کنید. در بارگیری اولیه چنین داده‌ای ندارید و پیام باید همین محدودیت را روشن کند. مقاله UX Writing برای متن loading، خطا و نتیجه ادامه مرتبط است؛ نام state باید با واقعیت سیستم هماهنگ باشد.

Retry خودکار را بی‌حد ادامه ندهید. برای کاربر، انتظار بی‌پایان بدون راه خروج مشکل است. در سطح محصول باید timeout، امکان تلاش دوباره و وضعیت قطع ارتباط مشخص باشند. مثال timer محدود دارد، اما برای API واقعی مدت مناسب را از قرارداد سرویس تعیین کنید. زمان ساختگی مانند «تا سه ثانیه دیگر» را بدون مبنا اعلام نکنید.

حرکت و نگهداری را در قرارداد component ثبت کنید

animation برای فهم وجود انتظار همیشه لازم نیست. prefers-reduced-motion ترجیح کاربر برای کاهش حرکت را نشان می‌دهد. در مثال، pulse در این حالت حذف می‌شود و شکل ثابت همراه متن همچنان وضعیت را توضیح می‌دهد. کاهش حرکت نباید پیام انتظار را حذف کند؛ اطلاعات لازم باید با نشانه غیرمتحرک باقی بماند.

در تحویل component، stateهای idle، loading، success، empty، error و cancelled و مسیرهای گذار را ثبت کنید. CTA برای کنترل شروع و دسترسی‌پذیری برای Focus و اعلان‌ها ادامه‌های مرتبط‌اند. این قرارداد کمک می‌کند هنگام تغییر منبع داده، یکی از حالت‌ها بی‌صدا حذف نشود و Skeleton پس از شکست روی صفحه باقی نماند.

واکنش به این مقاله

اشتراک‌گذاری این مقاله:
دیدگاه‌ها 0
۰ / ۲۰۰۰

هنوز دیدگاهی ثبت نشده

اولین نفری باشید که نظر می‌دهد!