在室表示
stimeo--presence
Action Cable の在室ロスター — サーバ側の状態はゼロ。
stimeo--presence は「誰がこのページを見ているか」のロスターを Action Cable チャンネルに束ねます。サーバは在室状態を一切持ちません。各クライアントが heartbeat 間隔で appear ビーコン({ id, name })を broadcast し、timeout ms 聞こえない相手は各クライアントが自分側で期限切れにします。離脱は disconnect と pagehide(タブを閉じる・ハード遷移)で best-effort に通知({ id, leaving: true })し、通知が届かない強制終了は期限切れが拾います。未知の相手のビーコンを聞いたクライアントが自分を再アナウンス(スロットリング付き)するため、後から入った人のロスターも 1 往復で収束します。自分のビーコンは id の比較で無視します。描画は任意かつ宣言的で、count target(data-zero / -one / -other テンプレート)と、list + template のペア(相手 1 人 = clone 1 個。名前は data-presence-name スロットへ)を用意しています。data-present / data-present-count フックと join / leave / change イベントで独自 UI も組めます。サーバ側は typing-indicator と同じ薄い rebroadcast チャンネル(このカタログの PresenceChannel は仕様のサンプルに公開デプロイ用の固定 room allowlist を足したもの)。opt-in サブパス stimeo-ui/cable に同梱で、コアはゼロ依存のままです。
このページをもう 1 つのタブで開くと、ハートビート以内にもう 1 人のゲストがロスターに現れます(自分自身は載りません — 自分のビーコンは無視されます)。そのタブを閉じる(または別ページへ移動する)と離脱通知で消え、通知が届かなかった場合はタイムアウトの期限切れが拾います。
あなたは ゲスト 7027 として他の人に表示されます。
<%# presence: a who's-here roster with zero server state. Each client beacons
{ id, name } through PresenceChannel on a heartbeat; every client expires
silent peers locally and re-announces itself to newcomers, so the roster
converges in one round trip. Your own beacons are ignored (echo
suppression), so open this page in a second tab to see a peer appear — then
close it or navigate it away for the leave notice (sent best-effort on
disconnect and pagehide); a lost notice is caught by the timeout expiry. The id/name
pair is randomized per render, which is fine for a demo; a real app renders
current_user.id / current_user.name. The count templates (data-zero/-one/
-other) keep the text localizable; %{count} is substituted by the
controller. Heartbeat/timeout are shortened so leaving is quick to observe. %>
<% token = SecureRandom.hex(4) %>
<% guest = t("components.presence.demo.guest_name", token: token.first(4)) %>
<div class="presence-demo"
data-controller="stimeo--presence"
data-stimeo--presence-channel-value="PresenceChannel"
data-stimeo--presence-params-value='{"room":"<%= PresenceChannel::ROOM %>"}'
data-stimeo--presence-id-value="<%= token %>"
data-stimeo--presence-name-value="<%= guest %>"
data-stimeo--presence-heartbeat-value="5000"
data-stimeo--presence-timeout-value="12000">
<p class="presence-demo__hint"><%= t("components.presence.demo.hint") %></p>
<p class="presence-demo__you"><%= t("components.presence.demo.you_are", name: guest) %></p>
<p class="presence-demo__count"
data-stimeo--presence-target="count"
data-zero="<%= t("components.presence.demo.count_zero") %>"
data-one="<%= t("components.presence.demo.count_one") %>"
data-other="<%= t("components.presence.demo.count_other") %>"></p>
<ul class="presence-demo__list"
aria-label="<%= t("components.presence.demo.list_label") %>"
data-stimeo--presence-target="list"></ul>
<template data-stimeo--presence-target="template">
<li class="presence-demo__peer">
<span class="presence-demo__dot" aria-hidden="true"></span>
<span data-presence-name></span>
</li>
</template>
</div>
/*
* Presentation-only styles for the presence demo. The library owns the roster
* (count text, cloned list items, data-present/-count hooks); this CSS renders
* the online dots and the list. The dot is decorative color only — the peer's
* name text is the accessible representation (WCAG 1.4.1).
*/
.presence-demo {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 28rem;
}
.presence-demo__hint,
.presence-demo__you {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.presence-demo__count {
margin: 0;
min-height: 1.25rem;
font-weight: 600;
}
.presence-demo__list {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
min-height: 1.5rem;
}
.presence-demo__peer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
font-size: 0.9rem;
}
/* The online dot: a small green disc next to the peer's name (theme-aware token). */
.presence-demo__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--leaf-700);
}
このデモに固有の消費側 JS はありません(挙動はコントローラが担います)。
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--presence"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
count
|
任意の人数テキスト。data-zero / data-one / data-other テンプレート(%{count})でローカライズ。 |
data-stimeo--presence-target="count" |
list
|
任意のロスターコンテナ。相手 1 人につき template の clone を 1 個保持する。 |
data-stimeo--presence-target="list" |
template
|
相手ごとに clone される <template>。名前は data-presence-name スロットへ、clone ルートには data-presence-id が付く。 |
data-stimeo--presence-target="template" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
channel
|
購読する Action Cable チャンネル名。空(既定)なら購読しない。 | data-stimeo--presence-channel-value |
params
|
購読識別子に混ぜる追加パラメータ(例 {"room":"doc_7"})。 |
data-stimeo--presence-params-value |
id
|
このクライアントの識別子。ビーコンに載り、同 id の受信はエコーとして無視。空なら送信しない(観測専用)。 |
data-stimeo--presence-id-value |
name
|
ビーコンに載る表示名(変更は次のビーコンで反映される)。 | data-stimeo--presence-name-value |
heartbeat
|
ビーコンの送信間隔 ms(既定 15000)。 | data-stimeo--presence-heartbeat-value |
timeout
|
この ms 沈黙した相手を自分側で期限切れにする(既定 40000。heartbeat の 2 倍超を推奨)。 |
data-stimeo--presence-timeout-value |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
join
|
未知の相手のビーコンを受けたとき { id, name } と共に発火。 |
stimeo--presence:join |
leave
|
相手の離脱(leaving 通知または期限切れ)で { id } と共に発火。 |
stimeo--presence:leave |
change
|
ロスターが変わるたび(join / leave / 改名)に { users } と共に発火。 |
stimeo--presence:change |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-present |
コントローラ要素 | 自分以外の在室者がいれば "true"、ゼロなら "false"(初期は無し)。 |
data-present-count |
コントローラ要素 | 在室者数(自分を除く)。属性セレクタや CSS カウンタでの出し分けに使う。 |
data-presence-id |
ロスターの clone | 描画された各 clone に相手の id が付く(削除時のキーでもある)。 |