楽観的 UI
stimeo--optimistic
押した瞬間に見た目を進め、失敗したぶんだけ正確に戻します。
Turbo のフォームで、押した瞬間に結果を先取りして見せます。成功すればそのまま残り、最終的な画面はサーバの応答が決めます。失敗したときは、この部品が変えたところだけを元に戻します。もともと表示されていた要素を巻き添えで隠してしまうことはありません。送信中であることも読み上げに伝わります。Turbo が元から出している送信の合図に乗るだけなので、通信の仕組みを差し替えたり、常時つなぎっぱなしにしたりはしません。二重送信の防止と、全員で共有する数の更新は、それぞれ別の部品の役目です。
どちらのボタンも押した瞬間に「いいね済み」へ変わります。1 つ目は送信が成功するのでそのまま残ります。2 つ目はサーバがわざと拒否するので、変わった表示が元へ戻る様子を見られます。
キーボード操作
このコンポーネント自体はキーボード操作を持ちません。
<%# optimistic: wraps a real Turbo form POST. On turbo:submit-start the hidden
"Liked" face shows and the "Like" face hides IMMEDIATELY (each toggled
target is marked); on success the toggle is kept (commit — the final DOM
belongs to the server's Turbo Stream response, which updates the status
line below), on failure exactly the marked toggles are rolled back. The
second form posts fail=1, which the demo endpoint rejects with 422, so you
can watch the rollback. The hidden authenticity_token is Rails' CSRF token
(form_with renders it automatically in a real app). %>
<div class="optimistic-demo">
<p class="optimistic-demo__hint"><%= t("components.optimistic.demo.hint") %></p>
<div class="optimistic-demo__row">
<form class="optimistic-demo__form" method="post"
action="<%= optimistic_demo_likes_path %>"
data-controller="stimeo--optimistic">
<%= hidden_field_tag :authenticity_token, form_authenticity_token, id: nil %>
<button type="submit" class="demo-trigger optimistic-demo__button">
<span class="optimistic-demo__face" data-stimeo--optimistic-target="hide">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.like") %>
</span>
<span class="optimistic-demo__face" hidden data-stimeo--optimistic-target="show">
<svg class="demo-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.liked") %>
</span>
</button>
</form>
<%# Same wiring, but the server rejects it (fail=1 → 422): the optimistic
toggle appears for the request's duration, then rolls back. %>
<form class="optimistic-demo__form" method="post"
action="<%= optimistic_demo_likes_path %>"
data-controller="stimeo--optimistic">
<%= hidden_field_tag :authenticity_token, form_authenticity_token, id: nil %>
<input type="hidden" name="fail" value="1">
<button type="submit" class="demo-trigger optimistic-demo__button">
<span class="optimistic-demo__face" data-stimeo--optimistic-target="hide">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.like_fail") %>
</span>
<span class="optimistic-demo__face" hidden data-stimeo--optimistic-target="show">
<svg class="demo-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"
aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.optimistic.demo.liked") %>
</span>
</button>
</form>
</div>
<%# The server's Turbo Stream response updates this line (success and 422 alike). %>
<p id="optimistic-demo-status" class="optimistic-demo__status" role="status"></p>
</div>
/*
* Presentation-only styles for the optimistic demo. The library toggles the
* hidden attribute on the show/hide faces and flips data-optimistic/aria-busy
* on the form; this CSS lays the two forms out and dims the form while a
* submission is in flight.
*/
.optimistic-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 32rem;
}
.optimistic-demo__hint {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.optimistic-demo__row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
/* While the optimistic state is applied (request in flight), dim the form. */
.optimistic-demo__form[data-optimistic="true"] {
opacity: 0.7;
}
.optimistic-demo__button {
display: inline-flex;
align-items: center;
}
.optimistic-demo__face {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.optimistic-demo__face .demo-icon {
width: 1rem;
height: 1rem;
}
/* The committed "Liked" face reads in the accent color. */
.optimistic-demo__face[data-stimeo--optimistic-target="show"] {
color: var(--accent-700);
}
.optimistic-demo__status {
margin: 0;
min-height: 1.25rem;
font-size: 0.9rem;
color: var(--muted);
}
このデモに固有の消費側 JS はありません(挙動はコントローラが担います)。
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--optimistic"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
show
|
送信中の楽観状態で表示(hidden を除去)。成功なら維持、失敗なら復元。任意・複数可。 |
data-stimeo--optimistic-target="show" |
hide
|
送信中の楽観状態で非表示。成功なら維持、失敗なら復元。任意・複数可。 | data-stimeo--optimistic-target="hide" |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
commit
|
楽観状態を所有する送信が成功したときに発火 — 状態が確定(最終 DOM はサーバ応答が所有)。他の送信の終端は無視する。 | stimeo--optimistic:commit |
rollback
|
楽観状態を所有する送信が失敗したときに発火 — authored なマークアップを正確に戻し済み。Turbo が中止した送信も失敗として扱う。 | stimeo--optimistic:rollback |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-optimistic |
フォーム(コントローラ要素) | 楽観状態が適用されている間(送信中)"true"。成功・失敗のどちらでも除去。 |
aria-busy |
フォーム(コントローラ要素) | 送信中 "true"。AT に処理中を伝える(WCAG 4.1.2)。フォーム自身では Turbo も送信のたびに同じ属性を書くため、作者が書いた値は本コントローラと無関係にそこで失われる。 |
data-optimistic-toggled |
切り替えた show / hide ターゲット | 戻すべき authored な hidden の値を持つ記録 — "absent"、または "value:" に続く authored 値。本コントローラが書いたターゲットにだけ付く。 |
data-optimistic-busy |
フォーム(コントローラ要素) | aria-busy について同じ記録を置き、ロールバックで作者の値を戻す。 |