フレーム読込状態
stimeo--frame-loading
Turbo フレームの読み込み中に、待ち表示を出して二度押しを防ぎます。
Turbo のフレームが読み込んでいる間の見せ方をまとめます。読み込みが始まると待ち表示に切り替わり、読み込み中であることが読み上げにも伝わります。その間は中身を触れなくするので、同じ操作を二度押ししてしまいません。古くなる中身にフォーカスがあったときは外へ逃がし、読み終わったら戻せます。ごく短い読み込みでも待ち表示は最低限の時間だけ残るので、ちらつきません。失敗しても待ち状態のまま固まりません。待ち表示のマークアップと見た目は利用側のものです。
実行中
読み込み中…
読み込み済みの内容。
キーボード操作
このコンポーネント自体はキーボード操作を持ちません。
<%# Frame-loading demo: the controller subscribes to a turbo-frame's fetch lifecycle
and toggles aria-busy / data-frame-loading, the skeleton, content inert, and focus.
The skeleton is revealed at the moment of the change, which is not reliably read, so
the loading / ready wording goes to the shared stimeo--announcer your app seats once,
in its layout. This catalog has no Turbo backend, so demo.js fires the
turbo:before-fetch-request → turbo:frame-load pair to reproduce a frame fetch. The
library only toggles state and hidden; demo.css owns the skeleton bars and the dimmed
content. %>
<div class="frame-demo">
<button type="button" class="demo-trigger" data-frame-demo-reload>
<%= t("components.frame_loading.demo.reload") %>
</button>
<div
class="frame-demo__frame"
data-controller="stimeo--frame-loading"
data-stimeo--frame-loading-min-duration-value="600"
data-stimeo--frame-loading-announce-text-value="<%=
t("components.frame_loading.demo.announce_loading") %>"
data-stimeo--frame-loading-announce-ready-text-value="<%=
t("components.frame_loading.demo.announce_ready") %>">
<div class="frame-demo__skeleton" data-stimeo--frame-loading-target="skeleton" hidden>
<span class="visually-hidden"><%= t("components.frame_loading.demo.loading") %></span>
<span class="frame-demo__bar"></span>
<span class="frame-demo__bar"></span>
<span class="frame-demo__bar"></span>
</div>
<div class="frame-demo__content" data-stimeo--frame-loading-target="content">
<p><%= t("components.frame_loading.demo.content") %></p>
<button
type="button"
class="demo-trigger"
data-frame-demo-action
data-result-template="<%= t("components.frame_loading.demo.action_result") %>">
<%= t("components.frame_loading.demo.action") %>
</button>
<span class="frame-demo__result" data-frame-demo-result role="status"></span>
</div>
</div>
</div>
/*
* Presentation-only styles for the frame-loading demo. The library toggles aria-busy /
* data-frame-loading, the skeleton's hidden, and inert on the content; this CSS owns the
* skeleton bars and dims the (inert) content while loading.
*/
.frame-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
align-items: flex-start;
}
.frame-demo__frame {
position: relative;
width: 100%;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.frame-demo__skeleton {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.frame-demo__bar {
height: 0.75rem;
border-radius: 0.25rem;
background: linear-gradient(
90deg,
var(--surface-subtle) 25%,
var(--border-strong) 50%,
var(--surface-subtle) 75%
);
background-size: 200% 100%;
animation: frame-demo-shimmer 1.2s ease-in-out infinite;
}
.frame-demo__bar:nth-child(3) {
width: 80%;
}
.frame-demo__bar:nth-child(4) {
width: 60%;
}
/* While loading, dim the stale (inert) content so the skeleton reads as the live state. */
.frame-demo__frame[data-frame-loading] .frame-demo__content {
opacity: 0.4;
}
.frame-demo__result {
margin-left: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
@keyframes frame-demo-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.frame-demo__bar {
animation: none;
}
}
// Frame-loading demo (consumer-side JS).
//
// The catalog has no Turbo backend, so the reload button fires the same fetch events a
// real <turbo-frame> would (turbo:before-fetch-request to start, turbo:frame-load to
// finish ~1.2s later). The controller reacts exactly as it would in production: it sets
// aria-busy, shows the skeleton, inerts the content, and retreats/restores focus.
document.querySelectorAll(".frame-demo").forEach((root) => {
const frame = root.querySelector('[data-controller="stimeo--frame-loading"]');
const reload = root.querySelector("[data-frame-demo-reload]");
if (!frame || !reload) return;
// Idempotent: Turbo can re-run this inline module on navigation; wire each root once. The
// marker is a property, not an attribute: Turbo copies attributes into its page snapshot,
// so an attribute one comes back set on a restored page whose elements carry no listeners.
if (root.demoWired) return;
root.demoWired = true;
reload.addEventListener("click", () => {
// before-fetch-request must carry a detail (fetchOptions/url): Turbo's prefetch
// observer reads event.detail.fetchOptions.method in the capture phase, so a bare
// Event (detail === undefined) would throw there. GET with no prefetch cache is a
// no-op for that observer.
frame.dispatchEvent(
new CustomEvent("turbo:before-fetch-request", {
bubbles: true,
cancelable: true,
detail: {
fetchOptions: { method: "GET", headers: {} },
url: new URL(window.location.href),
resume: () => {},
},
}),
);
setTimeout(() => {
frame.dispatchEvent(new CustomEvent("turbo:frame-load", { bubbles: true, detail: {} }));
}, 1200);
});
// The in-content action button gives visible feedback so its inert-while-loading
// state is observable: it responds when the frame is idle, but not while loading
// (the controller marks the content inert, which swallows the click).
const action = root.querySelector("[data-frame-demo-action]");
const result = root.querySelector("[data-frame-demo-result]");
if (action && result) {
const template = action.dataset.resultTemplate ?? "Ran the action ({n}×)";
let runs = 0;
action.addEventListener("click", () => {
runs += 1;
result.textContent = template.replace("{n}", String(runs));
});
}
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--frame-loading"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
content
|
現在の内容。読込中は inert 化し、フォーカス復帰の起点にもなる。 |
data-stimeo--frame-loading-target="content" |
skeleton
|
読込中に表示する任意のプレースホルダ(hidden を切替)。 |
data-stimeo--frame-loading-target="skeleton" |
overlay
|
読込中に表示する任意のオーバーレイ(hidden を切替)。 |
data-stimeo--frame-loading-target="overlay" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
announceText
|
読み込みに入ったときに読み上げる文言。空なら告知しない。 | data-stimeo--frame-loading-announce-text-value |
announceReadyText
|
読み込みが終わったときに読み上げる文言。空なら告知しない。 | data-stimeo--frame-loading-announce-ready-text-value |
minDuration
|
skeleton を表示し続ける最小ミリ秒(チラつき防止。既定 0)。 | data-stimeo--frame-loading-min-duration-value |
restoreFocus
|
読込完了後に元の位置へフォーカスを戻すか(既定 true)。 |
data-stimeo--frame-loading-restore-focus-value |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
start
|
読込開始時に発火。 | stimeo--frame-loading:start |
end
|
読込完了時に発火。 | stimeo--frame-loading:end |
reconcile
|
Turbo キャッシュの巻き戻しが未完了の読み込みを終わらせたとき発火。detail は {}。 |
stimeo--frame-loading:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
aria-busy |
フレーム(ルート) | 読込中 true。 |
data-frame-loading |
フレーム(ルート) | 読込中 true。CSS で skeleton / overlay を制御するため。 |