ビューポート侵入で遅延ロード
stimeo--lazy-frame
<turbo-frame> をビューポート接近時(またはフォーカス到達時)まで遅延ロード。
stimeo--lazy-frame コントローラは <turbo-frame> のロードをビューポート接近時まで遅延させ、初期表示を軽くします。Turbo 標準の loading="lazy" は描画時に発火しビューポート侵入では発火しないため、本パーツは rootMargin を設定できる IntersectionObserver で明示制御し、加えてキーボード/AT 利用者向けにフォーカス到達フォールバックも備えます。URL は url Value に保留し(src には持たせない)、Turbo に先読みさせません。フレームが交差(rootMargin 内)するかフォーカスが入ると、url を src へ書き写してロードを起動し、 data-lazy-loaded を付与して load を発火します。once(既定)はその後監視を解除し、once=false は再侵入でフレームを reload します。挙動のみで、ロードとフレームの中身は Turbo / サーバの責務、読込中の表示は Frame Loading State の領分です。起動は冪等で(data-lazy-loaded が二重起動を防ぎ、Turbo キャッシュ復元の既ロードを尊重)、observer とフォーカスリスナはロード後(once)/disconnect(Turbo 遷移含む)で解放します。
下にスクロールしてください。下のフレームは URL を保留し、ビューポートに入って初めてロードします。
<%# Lazy-frame demo: in production the controller goes on a <turbo-frame> and writes its
held url to src to start the Turbo load. The catalog runs Turbo, so to avoid a real
fetch this demo puts the controller on a plain <div> (writing src to a div is inert)
and demo.js swaps the placeholder on the load event — demonstrating the lazy *trigger*
(it fires only once the frame scrolls into view, thanks to the spacer above it). The
library only writes src and reflects data-lazy-loaded. %>
<div class="lazy-frame-demo">
<p class="lazy-frame-demo__hint"><%= t("components.lazy_frame.demo.hint") %></p>
<div class="lazy-frame-demo__spacer" aria-hidden="true"></div>
<div
class="lazy-frame-demo__frame"
data-controller="stimeo--lazy-frame"
data-stimeo--lazy-frame-url-value="/posts/1/comments"
data-stimeo--lazy-frame-root-margin-value="0px"
data-loaded-label="<%= t("components.lazy_frame.demo.loaded") %>"
data-loading-label="<%= t("components.lazy_frame.demo.loading") %>">
<%= t("components.lazy_frame.demo.placeholder") %>
</div>
</div>
/*
* Presentation-only styles for the lazy-frame demo. The library writes src and reflects
* data-lazy-loaded; this CSS lays out the placeholder frame, pushes it below the fold with
* a spacer so the lazy trigger is visible on scroll, and tints it once loaded.
*/
.lazy-frame-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.lazy-frame-demo__hint {
margin: 0;
color: var(--color-text-muted);
}
/* Pushes the frame below the fold so it loads on scroll, not on initial render. */
.lazy-frame-demo__spacer {
height: 60vh;
}
.lazy-frame-demo__frame {
padding: 1.5rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
text-align: center;
color: var(--color-text-muted);
}
/*
* The visible state is driven by a demo-owned `data-demo-state` (set by demo.js),
* not the library's `data-lazy-loaded`: the library marks the load *triggered* the
* instant the frame enters the viewport, but the demo simulates Turbo's fetch
* latency so the transition is perceptible — loading (amber) then loaded (green).
*/
.lazy-frame-demo__frame[data-demo-state="loading"] {
border-style: solid;
border-color: var(--amber-500);
background: var(--amber-50);
color: var(--amber-500);
}
.lazy-frame-demo__frame[data-demo-state="loaded"] {
border-style: solid;
border-color: var(--leaf-500);
background: var(--leaf-50);
color: var(--leaf-500);
}
// Lazy-frame demo (consumer-side JS).
//
// In production the lazy-frame controller writes its held url to a <turbo-frame>'s src and
// Turbo fetches it. The catalog has no backend for that, so the demo runs the controller
// on a <div> (writing src to a div does nothing) and reflects the load event into the
// placeholder — proving the lazy *trigger* fired only once the frame scrolled into view.
//
// The library marks the load *triggered* the instant the frame enters the viewport, so a
// naive swap would flip to "loaded" before it can be perceived. To mirror real-world fetch
// latency (and keep the lazy transition visible), show a brief "loading" state first and
// only then the "loaded" content.
document.querySelectorAll(".lazy-frame-demo").forEach((root) => {
const frame = root.querySelector('[data-controller~="stimeo--lazy-frame"]');
if (!frame) return;
// Idempotent: Turbo can re-run this inline module on navigation; wire each root once.
if (root.dataset.demoWired) return;
root.dataset.demoWired = "1";
const loadedLabel = frame.dataset.loadedLabel ?? "Loaded:";
const loadingLabel = frame.dataset.loadingLabel ?? "Loading…";
frame.addEventListener("stimeo--lazy-frame:load", (event) => {
// Trigger fired (the library wrote src + set data-lazy-loaded). Simulate Turbo's
// network fetch so the placeholder -> loaded transition is perceptible on scroll.
frame.dataset.demoState = "loading";
frame.textContent = loadingLabel;
window.setTimeout(() => {
frame.dataset.demoState = "loaded";
frame.textContent = `${loadedLabel} ${event.detail.url}`;
}, 800);
});
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--lazy-frame"
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
url
|
ロードする URL。起動時にフレームの src へ書き写す(src ではなくここに保留)。 |
data-stimeo--lazy-frame-url-value |
rootMargin
|
IntersectionObserver のマージン(例 200px)。完全に見える前に早出しロードする。 | data-stimeo--lazy-frame-root-margin-value |
once
|
初回ロード後に監視を解除するか(既定 true)。false は再侵入で再ロード。 |
data-stimeo--lazy-frame-once-value |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
load
|
遅延ロードを起動したとき発火。detail.url を伴う。 |
stimeo--lazy-frame:load |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-lazy-loaded |
フレーム(コントローラ要素) | ロード起動済みのとき付与(true)。 |