フィードバック

非同期コンテンツ状態

読み込み、内容あり、0件、失敗、再試行、更新中、更新失敗を、サンプルデータと回復操作で確認できる状態レシピです。

動きを試す

プレビュー

プレビュー設定

プレビューを準備しています。

必要なファイルを選ぶ

コード

index.html

利用前に変更する箇所

  • 見出し、固定データ、次行動の文言を実装先へ合わせる

<!doctype html>
<html lang="ja" data-theme="light">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>非同期コンテンツ状態</title>
    <link rel="stylesheet" href="./styles.css" />
    <script src="./states.js" defer></script>
  </head>
  <body>
    <main>
      <section class="async-region" aria-labelledby="async-title" tabindex="-1" data-async-root>
        <header class="region-header">
          <p class="eyebrow">読み込み状態の表示例</p>
          <h1 id="async-title">お知らせ</h1>
          <p class="lead">
            読み込み、内容あり、0件、失敗、再試行、更新中、更新失敗を同じ領域で確認できます。
          </p>
        </header>

        <section
          class="state-view loading-view"
          data-state-view="loading"
          aria-labelledby="loading-title"
        >
          <h2 id="loading-title">お知らせを読み込んでいます</h2>
          <p>利用できるお知らせを確認しています。終了時刻を保証せず、現在の処理を説明します。</p>
          <div class="loading-indicator" aria-hidden="true">
            <span></span>
            <span></span>
            <span></span>
          </div>
          <p class="supporting-text">時間がかかる場合は、この領域を閉じずに別の操作を続けられます。</p>
        </section>

        <section class="state-view content-region" data-content-region aria-label="取得済みのお知らせ" hidden>
          <div data-content-message="content">
            <div class="state-heading">
              <div>
                <h2>3件のお知らせがあります</h2>
                <p class="supporting-text">最終更新: 2026年8月22日 19:00</p>
              </div>
              <button type="button" data-action="refresh">更新する</button>
            </div>
          </div>

          <div class="inline-state" data-content-message="refreshing" hidden>
            <div>
              <h2>お知らせを更新しています</h2>
              <p>表示中の3件はそのまま利用できます。</p>
            </div>
            <button type="button" disabled>更新中…</button>
          </div>

          <div class="inline-state inline-state--error" data-content-message="error-with-content" hidden>
            <div>
              <h2>更新できませんでした</h2>
              <p>既に表示している内容は利用できます。必要なときにもう一度更新してください。</p>
            </div>
            <button type="button" data-action="retry-refresh">もう一度更新する</button>
          </div>

          <ul class="notice-list" data-preserved-content>
            <li>
              <strong>入力内容の確認方法を更新しました</strong>
              <span>新しいお知らせです</span>
            </li>
            <li>
              <strong>長い日本語見出しでも状態領域全体が横へ広がらないことを確認するお知らせ</strong>
              <span>識別子: NOTICE-2026-08-22-ALPHA-0001</span>
            </li>
            <li>
              <strong>次回メンテナンスの予定</strong>
              <span>予定日時: 2026年8月29日 02:00</span>
            </li>
          </ul>
        </section>

        <section class="state-view empty-view" data-state-view="empty" aria-labelledby="empty-title" hidden>
          <h2 id="empty-title">該当するお知らせは0件です</h2>
          <p>指定した条件に一致する項目はありません。条件を解除すると全件を確認できます。</p>
          <button type="button" data-action="clear-filters">条件を解除して再読込する</button>
        </section>

        <section class="state-view error-view" data-state-view="error" aria-labelledby="error-title" hidden>
          <h2 id="error-title">お知らせを読み込めませんでした</h2>
          <p>一時的な問題の可能性があります。時間を置くか、再試行してください。</p>
          <button type="button" data-action="retry">再試行する</button>
        </section>

        <section
          class="state-view retrying-view"
          data-state-view="retrying"
          aria-labelledby="retrying-title"
          hidden
        >
          <h2 id="retrying-title">再試行しています</h2>
          <p>お知らせをもう一度確認しています。完了するまで多重操作はできません。</p>
          <button type="button" disabled>再試行中…</button>
        </section>

        <p class="announcement" role="status" aria-live="polite" data-announcement></p>
      </section>
    </main>
  </body>
</html>

styles.css

利用前に変更する箇所

  • 色トークンを実装先のデザインシステムへ接続する

:root {
  color-scheme: light;
  --bg: #f7f8fa;
  --surface: #ffffff;
  --surface-subtle: #f0f3f7;
  --text: #18212f;
  --muted: #5d6b7a;
  --border: #d7dee8;
  --accent: #245fd6;
  --accent-strong: #1849a9;
  --danger: #b42318;
  --danger-surface: #fff1f0;
  --focus: #0077cc;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans JP", sans-serif;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0f141a;
  --surface: #171d26;
  --surface-subtle: #202834;
  --text: #f2f5f8;
  --muted: #aab4c0;
  --border: #354152;
  --accent: #7ea7ff;
  --accent-strong: #a8c2ff;
  --danger: #ff8a80;
  --danger-surface: #321b1d;
  --focus: #65c7ff;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  min-width: 0;
  background: var(--bg);
}

body {
  min-width: 0;
  margin: 0;
  padding: clamp(12px, 4vw, 28px);
  color: var(--text);
  background: var(--bg);
  font-size: 16px;
  line-height: 1.65;
}

main {
  width: min(100%, 760px);
  margin-inline: auto;
}

h1,
h2,
p,
li,
button {
  overflow-wrap: anywhere;
}

h1,
h2 {
  line-height: 1.3;
}

h1 {
  margin: 0;
  font-size: clamp(1.5rem, 5vw, 2.15rem);
}

h2 {
  margin: 0;
  font-size: clamp(1.1rem, 4vw, 1.35rem);
}

p {
  margin-block: 0 1em;
}

.async-region {
  min-width: 0;
  padding: clamp(16px, 4vw, 28px);
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--surface);
}

.async-region:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}

.region-header {
  margin-bottom: 20px;
}

.eyebrow {
  margin: 0 0 4px;
  color: var(--muted);
  font-size: 0.875rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}

.lead {
  margin: 8px 0 0;
  color: var(--muted);
}

.state-view {
  min-width: 0;
  padding: clamp(16px, 4vw, 22px);
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--surface-subtle);
}

[data-state-view][hidden],
[data-content-region][hidden],
[data-content-message][hidden] {
  display: none;
}

.state-view > p:last-of-type {
  margin-bottom: 0;
}

.supporting-text {
  color: var(--muted);
  font-size: 0.9rem;
}

.loading-indicator {
  display: inline-flex;
  min-height: 24px;
  align-items: center;
  gap: 6px;
  margin-block: 8px 12px;
}

.loading-indicator span {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--accent);
  animation: loading-pulse 1.2s ease-in-out infinite;
}

.loading-indicator span:nth-child(2) {
  animation-delay: 0.15s;
}

.loading-indicator span:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes loading-pulse {
  0%,
  100% {
    opacity: 0.35;
    transform: translateY(0);
  }

  50% {
    opacity: 1;
    transform: translateY(-3px);
  }
}

.state-heading,
.inline-state {
  display: flex;
  min-width: 0;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
}

.state-heading > div,
.inline-state > div {
  min-width: 0;
}

.inline-state {
  padding: 14px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
}

.inline-state--error {
  border-color: var(--danger);
  background: var(--danger-surface);
}

.inline-state p,
.state-heading p {
  margin: 4px 0 0;
}

.error-view {
  border-color: var(--danger);
  background: var(--danger-surface);
}

.error-view h2,
.inline-state--error h2 {
  color: var(--danger);
}

button {
  min-height: 44px;
  padding: 9px 14px;
  border: 1px solid var(--accent);
  border-radius: 8px;
  color: #ffffff;
  background: var(--accent);
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}

:root[data-theme="dark"] button {
  color: #0f141a;
}

button:hover:not(:disabled) {
  background: var(--accent-strong);
}

button:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.68;
}

.notice-list {
  display: grid;
  gap: 10px;
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
}

.notice-list li {
  min-width: 0;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface);
}

.notice-list strong,
.notice-list span {
  display: block;
  overflow-wrap: anywhere;
}

.notice-list span {
  margin-top: 2px;
  color: var(--muted);
  font-size: 0.9rem;
}

.announcement {
  min-height: 1.65em;
  margin: 14px 0 0;
  color: var(--muted);
  font-size: 0.9rem;
}

@media (max-width: 480px) {
  .state-heading,
  .inline-state {
    flex-direction: column;
  }

  .state-heading button,
  .inline-state button,
  .state-view > button {
    width: 100%;
  }
}

@media (max-width: 360px) {
  body {
    padding: 10px;
  }

  .async-region,
  .state-view {
    padding: 14px;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  .loading-indicator span {
    animation: none;
    opacity: 1;
    transform: none;
  }

  *,
  *::before,
  *::after {
    transition-duration: 0s;
  }
}

states.js

利用前に変更する箇所

  • 固定タイマーを実サービスの非同期処理へ置き換える

(() => {
  const params = new URLSearchParams(window.location.search);
  const instance = params.get("instance") ?? "missing-instance";
  const validStates = new Set([
    "loading",
    "content",
    "empty",
    "error",
    "retrying",
    "refreshing",
    "error-with-content",
  ]);
  const contentStates = new Set(["content", "refreshing", "error-with-content"]);
  const validThemes = new Set(["light", "dark"]);
  const stateViews = Array.from(document.querySelectorAll("[data-state-view]"));
  const contentRegion = document.querySelector("[data-content-region]");
  const contentMessages = Array.from(document.querySelectorAll("[data-content-message]"));
  const announcement = document.querySelector("[data-announcement]");
  const actionButtons = Array.from(document.querySelectorAll("[data-action]"));
  const root = document.querySelector("[data-async-root]");
  let currentState = validStates.has(params.get("state")) ? params.get("state") : "loading";
  let transitionVersion = 0;
  let activeTimer;
  let transitionPending = false;

  const stateAnnouncements = {
    loading: "お知らせを読み込んでいます。",
    content: "3件のお知らせを表示しました。",
    empty: "該当するお知らせは0件です。",
    error: "お知らせを読み込めませんでした。再試行できます。",
    retrying: "お知らせを再試行しています。",
    refreshing: "表示中の内容を保ったまま更新しています。",
    "error-with-content": "更新に失敗しました。表示中の内容は引き続き利用できます。",
  };

  function announce(message) {
    if (announcement instanceof HTMLElement) {
      announcement.textContent = message;
    }
  }

  function setActionsDisabled(disabled) {
    for (const button of actionButtons) {
      if (button instanceof HTMLButtonElement) {
        button.disabled = disabled;
      }
    }
  }

  function cancelTransition() {
    transitionVersion += 1;
    transitionPending = false;
    if (activeTimer !== undefined) {
      window.clearTimeout(activeTimer);
      activeTimer = undefined;
    }
    setActionsDisabled(false);
  }

  function applyState(nextState, shouldAnnounce = true) {
    if (!nextState || !validStates.has(nextState)) {
      return;
    }

    currentState = nextState;
    const contentVisible = contentStates.has(currentState);

    for (const view of stateViews) {
      if (view instanceof HTMLElement) {
        view.hidden = view.dataset.stateView !== currentState;
      }
    }

    if (contentRegion instanceof HTMLElement) {
      contentRegion.hidden = !contentVisible;
      contentRegion.setAttribute("aria-busy", String(currentState === "refreshing"));
    }

    for (const message of contentMessages) {
      if (message instanceof HTMLElement) {
        message.hidden = message.dataset.contentMessage !== currentState;
      }
    }

    if (root instanceof HTMLElement) {
      root.dataset.state = currentState;
      root.setAttribute(
        "aria-busy",
        String(
          currentState === "loading" ||
            currentState === "retrying" ||
            currentState === "refreshing",
        ),
      );
    }
    document.documentElement.dataset.state = currentState;

    if (shouldAnnounce) {
      announce(stateAnnouncements[currentState] ?? "状態を変更しました。");
    }
  }

  function setState(nextState, shouldAnnounce = true) {
    cancelTransition();
    applyState(nextState, shouldAnnounce);
  }

  function runTransition(intermediateState, completedState) {
    if (transitionPending) {
      return;
    }

    cancelTransition();
    transitionPending = true;
    setActionsDisabled(true);
    announce("");
    if (root instanceof HTMLElement) {
      root.focus({ preventScroll: true });
    }
    applyState(intermediateState, false);
    const expectedVersion = transitionVersion;

    activeTimer = window.setTimeout(() => {
      if (expectedVersion !== transitionVersion) {
        return;
      }
      transitionPending = false;
      activeTimer = undefined;
      setActionsDisabled(false);
      applyState(completedState);
    }, 350);
  }

  function setTheme(theme) {
    if (!theme || !validThemes.has(theme)) {
      return;
    }
    document.documentElement.dataset.theme = theme;
  }

  for (const button of actionButtons) {
    button.addEventListener("click", () => {
      if (!(button instanceof HTMLButtonElement) || button.disabled) {
        return;
      }

      switch (button.dataset.action) {
        case "retry":
          runTransition("retrying", "content");
          break;
        case "refresh":
          runTransition("refreshing", "error-with-content");
          break;
        case "retry-refresh":
          runTransition("refreshing", "content");
          break;
        case "clear-filters":
          runTransition("loading", "content");
          break;
      }
    });
  }

  window.addEventListener("message", (event) => {
    if (event.source !== window.parent || typeof event.data !== "object" || event.data === null) {
      return;
    }

    const data = event.data;
    if (data.source !== "wpl-host" || data.instance !== instance) {
      return;
    }

    if (data.type === "wpl-preview-state") {
      setState(typeof data.value === "string" ? data.value : null);
    }
    if (data.type === "wpl-preview-theme") {
      setTheme(typeof data.value === "string" ? data.value : null);
    }
  });

  setTheme(validThemes.has(params.get("theme")) ? params.get("theme") : "light");
  applyState(currentState, false);

  window.parent.postMessage(
    {
      source: "wpl-preview",
      type: "wpl-preview-ready",
      instance,
    },
    "*",
  );
})();
プレビューで試せる動き
  • 読込中: 初回取得中の対象と現在の処理を文章で示します。
  • 内容あり: 3件の固定データと更新操作を表示します。
  • 0件: 正常取得したが対象がなく、条件解除を案内する状態です。
  • 初回失敗: 初回取得に失敗し、再試行できる状態です。
  • 再試行中: 多重操作を防ぎながら初回取得を再試行する状態です。
  • 更新中: 既存内容を保持したまま更新している状態です。
  • 更新失敗: 更新に失敗しても既存内容を保持し、再更新できる状態です。

使い分けを確認する

このレシピが合う場面

適する場面

  • 一覧・検索結果・詳細情報などを非同期取得する領域
  • 0件を正常結果として扱い、次の行動を案内する領域
  • 初回失敗と更新失敗を区別し、既存内容を保持したい領域

適さない場面

  • 即時表示できる静的コンテンツだけを扱う場合
  • 進捗率や中断操作が必要な長時間処理
  • 複数の独立した通信処理を一つの状態だけで表す場合