スクロール位置の保持
stimeo--scroll-restore
内部スクロール領域の位置を Turbo 遷移をまたいで保存・復元する。
stimeo--scroll-restore コントローラに APG ウィジェットパターンはなく、純粋な状態保持ユーティリティである。Turbo は遷移ごとに body を差し替えるため、内部のスクロール領域は scrollTop が 0 に戻る。これを各アプリで手書きせずに済むよう、スクロール量を安定したキーで sessionStorage に保存し connect 時に復元する。これにより Turbo Drive 遷移でも、タブセッション内のフルリロードでも復元される。key(無ければ要素の id)で名前空間を分けるため複数インスタンスでも衝突しない。scroll リスナは内部・passive で requestAnimationFrame によりまとめられ、disconnect(Turbo 遷移含む)で同期保存(flush)する。axis は vertical / horizontal / both を選べる。ARIA/CSS は操作せず、フォーカスも移動しない。
両方のリストをスクロールしてから「再構築」を押す。左(stimeo--scroll-restore あり)は元の位置に戻り、右(なし)は先頭にリセットされる——Turbo 遷移で内部スクロール領域に起きることと同じ。実際のページ遷移やブラウザのリロード(同一タブセッション内)でも位置は保持される。
Scroll Restore あり
- 行 1
- 行 2
- 行 3
- 行 4
- 行 5
- 行 6
- 行 7
- 行 8
- 行 9
- 行 10
- 行 11
- 行 12
- 行 13
- 行 14
- 行 15
- 行 16
- 行 17
- 行 18
- 行 19
- 行 20
なし(素のまま)
- 行 1
- 行 2
- 行 3
- 行 4
- 行 5
- 行 6
- 行 7
- 行 8
- 行 9
- 行 10
- 行 11
- 行 12
- 行 13
- 行 14
- 行 15
- 行 16
- 行 17
- 行 18
- 行 19
- 行 20
<%# Markup for the scroll-restore (scroll position persistence) demo.
The behavior shines when the DOM is torn down and rebuilt — exactly what Turbo
does on navigation. To make that observable without leaving the page, the
"rebuild" button removes the two scroll regions and re-inserts them (demo.js).
The left region uses stimeo--scroll-restore (its offset is saved to
sessionStorage and restored on reconnect); the right one has no controller and
snaps back to the top, so the difference is obvious. It is behavior only — the
library sets no ARIA/CSS and never moves focus; the box look is demo.css's. %>
<div class="scroll-restore-demo" data-scroll-restore-demo>
<p><%= t("components.scroll_restore.demo.hint") %></p>
<div class="scroll-restore-demo__toolbar">
<button type="button" class="scroll-restore-demo__rebuild" data-scroll-restore-remount>
<%= t("components.scroll_restore.demo.rebuild") %>
</button>
</div>
<%# demo.js captures this slot's HTML and swaps it out/in to fire the controller's
disconnect (save) → connect (restore) cycle, mirroring a Turbo navigation. %>
<div class="scroll-restore-demo__cols" data-scroll-restore-slot>
<div class="scroll-restore-demo__col">
<p class="scroll-restore-demo__label"><%= t(
"components.scroll_restore.demo.with_label"
) %></p>
<div
class="scroll-restore-demo__box"
data-controller="stimeo--scroll-restore"
data-stimeo--scroll-restore-key-value="catalog-demo">
<ol class="scroll-restore-demo__list">
<% (1..20).each do |n| %>
<li class="scroll-restore-demo__row"><%= t(
"components.scroll_restore.demo.row", number: n
) %></li>
<% end %>
</ol>
</div>
</div>
<div class="scroll-restore-demo__col">
<p class="scroll-restore-demo__label"><%= t(
"components.scroll_restore.demo.without_label"
) %></p>
<div class="scroll-restore-demo__box">
<ol class="scroll-restore-demo__list">
<% (1..20).each do |n| %>
<li class="scroll-restore-demo__row"><%= t(
"components.scroll_restore.demo.row", number: n
) %></li>
<% end %>
</ol>
</div>
</div>
</div>
</div>
/* Presentation CSS for scroll-restore. The library only saves/restores each box's
scroll offset (sessionStorage); the toolbar, columns, and list look live here. */
.scroll-restore-demo {
color: var(--color-text-muted);
}
.scroll-restore-demo__toolbar {
margin: 0.75rem 0;
}
.scroll-restore-demo__rebuild {
padding: 0.45rem 0.9rem;
font: inherit;
cursor: pointer;
border: 1px solid var(--border-interactive);
border-radius: 0.375rem;
background: var(--surface-card);
}
.scroll-restore-demo__rebuild:hover {
background: var(--surface-subtle);
}
.scroll-restore-demo__rebuild:focus-visible {
outline: 2px solid var(--color-primary-hover);
outline-offset: 2px;
}
.scroll-restore-demo__cols {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.scroll-restore-demo__col {
flex: 1 1 12rem;
min-width: 0;
}
.scroll-restore-demo__label {
margin: 0 0 0.35rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
}
.scroll-restore-demo__box {
height: 11rem;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
background: var(--surface-card);
}
.scroll-restore-demo__list {
margin: 0;
padding: 0;
list-style: none;
}
.scroll-restore-demo__row {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border-default);
}
.scroll-restore-demo__row:last-child {
border-bottom: none;
}
// Scroll Restore demo driver.
//
// The library's value — keeping an inner scroll region's position when the DOM is
// rebuilt — is otherwise only visible by navigating away and back (or reloading).
// To make it operable in place, the "rebuild" button tears the two scroll regions
// out of the DOM and re-inserts them, which fires the controller's disconnect
// (save) → connect (restore) cycle, mirroring exactly what Turbo does on a visit.
// The left region (stimeo--scroll-restore) returns to its offset; the plain right
// region snaps back to the top.
const stage = document.querySelector("[data-scroll-restore-demo]");
if (stage) {
const slot = stage.querySelector("[data-scroll-restore-slot]");
const rebuild = stage.querySelector("[data-scroll-restore-remount]");
// Snapshot the initial markup so we can re-insert identical regions.
const template = slot.innerHTML;
rebuild.addEventListener("click", () => {
// Remove the regions → Stimulus disconnect (the controller flushes its offset).
slot.innerHTML = "";
// Re-insert on the next frame so the removal (disconnect) is processed before
// the insertion (connect); a synchronous swap could be coalesced into one
// mutation batch, leaving the save/restore ordering ambiguous.
requestAnimationFrame(() => {
slot.innerHTML = template;
});
});
}
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--scroll-restore"
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
key
|
保存位置のsessionStorageキー。未指定時は要素 id を使い、いずれも無ければ永続化を無効化する。 |
data-stimeo--scroll-restore-key-value |
axis
|
永続化する軸(vertical/horizontal/both、既定vertical)。 |
data-stimeo--scroll-restore-axis-value |