最下部追従
stimeo--stick-to-bottom
追加された内容にスクロール領域を自動追従 — ただしユーザーが上にスクロール中は追従しない。
stimeo--stick-to-bottom コントローラはスクロール領域(チャットログ・ライブコンソール)を最新の内容に貼り付けます。ただし追従するのはユーザーが既に最下部付近にいるときだけです。末尾からの距離が threshold 以内なら pinned とみなします。content(無ければ要素自身)の子追加を MutationObserver で監視し、pinned 中の追加は最下部へスクロールし、unpinned 中は位置を保持して data-has-new を立て new を発火します。スクロールで pinned を都度算出して data-pinned を反映し、遷移時に pin を発火します。scrollToBottom アクションで最下部へ復帰できます(「新着」ボタン用)。挙動のみで、内容の追加は行わず(Turbo Stream / 利用側の責務)、追従ロジックのみの最小版です(仮想化や入力欄は持たず、それらはプレミアム Chat Base の領分)。スクロール系の軽量メンバーです。状態はスクロール位置から都度導出する(モジュールスコープ状態なし)ため connect で Turbo Stream 追記後も再同期し、 behavior は reduced-motion 下 auto にフォールバックし、自動スクロールはフォーカスを移動せず、observer と passive scroll リスナは disconnect(Turbo 遷移含む)で解放します。
<%# Stick-to-bottom demo: the scrollable log follows new messages while you're at the
bottom, but holds and shows the "new messages" jump button if you've scrolled up. This
catalog has no Action Cable / Turbo Stream, so the Add button appends a <li> — exactly
the mutation a Turbo Stream broadcast would make — and the controller reacts. The jump
button lives inside the log (a descendant, wired with a plain data-action) and CSS
reveals it only while data-has-new. demo.js starts the log scrolled to the bottom. The
library only follows/flags and reflects data-pinned / data-has-new; demo.css owns the look. %>
<div class="stb-demo">
<div
class="stb-demo__log"
data-controller="stimeo--stick-to-bottom"
data-stimeo--stick-to-bottom-behavior-value="smooth">
<ul class="stb-demo__messages" data-stimeo--stick-to-bottom-target="content">
<% (1..8).each do |i| %>
<li><%= t("components.stick_to_bottom.demo.message") %> <%= i %></li>
<% end %>
</ul>
<button
type="button"
class="stb-demo__jump"
data-action="click->stimeo--stick-to-bottom#scrollToBottom">
<%= t("components.stick_to_bottom.demo.jump") %>
</button>
</div>
<button
type="button"
class="demo-trigger"
data-stb-demo-add
data-message-label="<%= t("components.stick_to_bottom.demo.message") %>">
<%= t("components.stick_to_bottom.demo.add") %>
</button>
</div>
/*
* Presentation-only styles for the stick-to-bottom demo. The library follows/flags new
* content and reflects data-pinned / data-has-new; this CSS gives the log a fixed height
* so it scrolls, and reveals the floating jump button only while data-has-new is set.
*/
.stb-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 24rem;
align-items: flex-start;
}
.stb-demo__log {
position: relative;
width: 100%;
height: 9rem;
overflow: auto;
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem;
}
.stb-demo__messages {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stb-demo__messages li {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: var(--surface-subtle);
}
/* The "new messages" button floats at the bottom, shown only while scrolled up. */
.stb-demo__jump {
position: sticky;
bottom: 0;
display: none;
margin-left: auto;
padding: 0.25rem 0.625rem;
border: 0;
border-radius: 999px;
background: var(--color-primary);
color: var(--white);
cursor: pointer;
}
.stb-demo__log[data-has-new] .stb-demo__jump {
display: block;
}
// Stick-to-bottom demo (consumer-side JS).
//
// No Action Cable / Turbo Stream here, so the Add button appends a <li> to the log —
// the same mutation a broadcast would make — and the controller follows it (while pinned)
// or flags it (while scrolled up). We also start the log scrolled to the bottom so the
// follow behavior is visible from the first message.
document.querySelectorAll(".stb-demo").forEach((root) => {
const log = root.querySelector('[data-controller~="stimeo--stick-to-bottom"]');
const list = root.querySelector('[data-stimeo--stick-to-bottom-target="content"]');
const add = root.querySelector("[data-stb-demo-add]");
if (!log || !list || !add) return;
// Start at the bottom (pinned); the scroll fires the controller's pinned recompute.
log.scrollTop = log.scrollHeight;
let count = list.children.length;
const label = add.dataset.messageLabel ?? "Message";
add.addEventListener("click", () => {
const li = document.createElement("li");
li.textContent = `${label} ${(count += 1)}`;
list.appendChild(li);
});
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--stick-to-bottom"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
content
|
追加を監視する要素(無ければスクロール領域自身)。 | data-stimeo--stick-to-bottom-target="content" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
threshold
|
pinned とみなす末尾からの距離(px、既定 80)。 | data-stimeo--stick-to-bottom-threshold-value |
behavior
|
追従のスクロール挙動(auto / smooth。reduced-motion 下は auto に固定)。 |
data-stimeo--stick-to-bottom-behavior-value |
アクション
| 名前 | アクション |
|---|---|
scrollToBottom
|
stimeo--stick-to-bottom#scrollToBottom |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
pin
|
pinned 状態が変化したとき発火。detail.pinned を伴う。 |
stimeo--stick-to-bottom:pin |
new
|
unpinned 中に新着が来たとき発火。detail.count を伴う。 |
stimeo--stick-to-bottom:new |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-pinned |
コントローラ要素 | 最下部追従中に付与(true)。 |
data-has-new |
コントローラ要素 | 上方閲覧中に新着が来たとき付与(true)。 |