JS Commands
Some actions are better left to the browser. Here is an adventurer's backpack. Its tabs, item menus and examine windows open in the browser, with nothing sent to the server. Only actions that changes state, like dropping or wielding an item, tells the server.
Your backpack
Carrying 10.8 kg10 of 28 spaces
- Bronze pickaxe 2.3 kg
- Tinderbox 0.1 kg
- Logs 2.0 kg
- Iron dagger 0.5 kg
- Cabbage 0.5 kg
- Rope 1.0 kg
- Wooden shield 3.0 kg
- Jug of ale 1.0 kg
- Leather boots 0.4 kg
- Gnome's map 0.0 kg
- Weapon Nothing
- Shield Nothing
- Feet Nothing
On the ground
Nothing. Drop something and it lands here.
# frozen_string_literal: true
# The backpack. The channel hears only the moves that change what is
# carried: wield or wear, remove, drop and pick up. The tabs, the menus, the
# marks and the examine windows are commands the browser runs by itself, so
# none of them is an action here.
class Recipes::Commands::BackpackChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Pack = Recipes::Commands::Pack
VIEWS = "app/recipes/commands"
def build_graph
@pack = Hibiki::State.new(Pack.start)
carried = Hibiki::Derived.new { @pack.value.carried }
worn = Hibiki::Derived.new { @pack.value.worn }
ground = Hibiki::Derived.new { @pack.value.ground }
transmit_value(:backpack_count) { carried.value.size }
transmit_value(:backpack_weight) { @pack.value.weight }
# Each list renders only when it changes. A render puts the list back as
# the server wrote it, which closes its menus and clears its marks.
Hibiki::Effect.new do
broadcast_morph target: "backpack_carried", partial: "#{VIEWS}/carried", locals: { items: carried.value }
end
Hibiki::Effect.new do
broadcast_morph target: "backpack_worn", partial: "#{VIEWS}/worn", locals: { worn: worn.value }
end
Hibiki::Effect.new do
broadcast_morph target: "backpack_ground", partial: "#{VIEWS}/ground", locals: { items: ground.value }
end
end
def wear(data) = @pack.value = @pack.value.wear(data["key"].to_s)
def remove(data) = @pack.value = @pack.value.remove(data["slot"].to_s)
def drop(data) = @pack.value = @pack.value.drop(data["key"].to_s)
def pick_up(data) = @pack.value = @pack.value.pick_up(data["key"].to_s)
end
# frozen_string_literal: true
# What one adventurer carries, wears and has dropped. It is a value: every
# move answers with another pack, and a move that cannot happen answers
# with the same one.
class Recipes::Commands::Pack < Data.define(:carried, :worn, :ground)
Item = Recipes::Commands::Item
SPACES = 28
SLOTS = { weapon: "Weapon", shield: "Shield", feet: "Feet" }.freeze
def self.start = new(carried: Item::ALL.values, worn: {}, ground: [])
# The item takes the slot, and whatever was in it goes back into the
# pack where the new item was.
def wear(key)
item = carried.find { it.key == key }
return self unless item&.slot
with(carried: carried.map { it == item ? worn[item.slot] : it }.compact, worn: worn.merge(item.slot => item))
end
def remove(name)
slot = SLOTS.keys.find { it.to_s == name }
return self unless worn[slot]
with(carried: [ *carried, worn[slot] ], worn: worn.except(slot))
end
def drop(key)
item = carried.find { it.key == key }
return self unless item
with(carried: carried - [ item ], ground: [ item, *ground ])
end
def pick_up(key)
item = ground.find { it.key == key }
return self unless item
with(carried: [ *carried, item ], ground: ground - [ item ])
end
# What is carried and worn. The ground weighs nothing.
def weight = format("%.1f kg", (carried + worn.values).sum(&:weight))
end
# frozen_string_literal: true
# One thing an adventurer can carry. The list is a constant: the backpack
# only moves these between the pack, the body and the ground.
class Recipes::Commands::Item < Data.define(:key, :name, :weight, :slot, :examine)
ALL = [
[ "pickaxe", "Bronze pickaxe", 2.3, :weapon, "Made for rocks. Used on most other things too." ],
[ "tinderbox", "Tinderbox", 0.1, nil, "Flint and steel in a tin. Every campfire starts with one." ],
[ "logs", "Logs", 2.0, nil, "Dry enough to burn. The fire on the front page would take them." ],
[ "dagger", "Iron dagger", 0.5, :weapon, "Short, sharp, and a little rusty at the hilt." ],
[ "cabbage", "Cabbage", 0.5, nil, "From the patch by the castle wall. Nobody saw you take it." ],
[ "rope", "Rope", 1.0, nil, "Thirty feet of it, give or take a knot." ],
[ "shield", "Wooden shield", 3.0, :shield, "Planks and a strap. It will stop one arrow." ],
[ "ale", "Jug of ale", 1.0, nil, "Half full, which is better than half empty." ],
[ "boots", "Leather boots", 0.4, :feet, "Broken in by somebody else, which is the best way." ],
[ "map", "Gnome's map", 0.0, nil, "Drawn on the back of a mining permit. The X has been rubbed out." ]
].to_h do |key, name, weight, slot, examine|
[ key, new(key:, name:, weight:, slot:, examine:) ]
end.freeze
def self.find(key) = ALL[key]
# Weapons and shields are wielded, boots are worn.
def verb = slot == :feet ? "Wear" : "Wield"
def to_s = name
end
<%# The page renders the pack the channel starts from. The channel renders
only the three lists. The tabs, the panels and the examine windows are
outside them, so what commands do to those lasts. %>
<% pack = Recipes::Commands::Pack.start %>
<%= island Recipes::Commands::BackpackChannel, class: "panel mx-auto w-full max-w-md p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">Your backpack</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
<span class="ml-auto text-sm tabular-nums opacity-70">Carrying <%= reactive :backpack_weight, pack.weight %></span>
</header>
<%# Each tab hides both panels, shows its own and moves the highlight. %>
<div role="tablist" class="tabs tabs-box mt-3">
<% { carried: "Backpack", worn: "Worn" }.each do |name, label| %>
<%= tag.button label, type: "button", role: "tab", id: "backpack_tab_#{name}",
class: [ "tab backpack-tab", ("tab-active" if name == :carried) ], "aria-selected": name == :carried,
**on(js.hide(".backpack-panel").show("#backpack_panel_#{name}")
.remove_class("tab-active", to: ".backpack-tab").add_class("tab-active")
.set_attribute("aria-selected", "false", to: ".backpack-tab").set_attribute("aria-selected", "true")) %>
<% end %>
</div>
<div id="backpack_panel_carried" role="tabpanel" class="backpack-panel mt-3">
<p class="mb-1 text-xs tabular-nums opacity-70"><%= reactive :backpack_count, pack.carried.size %> of <%= Recipes::Commands::Pack::SPACES %> spaces</p>
<%= render "app/recipes/commands/carried", items: pack.carried %>
</div>
<div id="backpack_panel_worn" role="tabpanel" class="backpack-panel mt-3" hidden>
<%= render "app/recipes/commands/worn", worn: pack.worn %>
</div>
<section class="mt-4 border-t-2 border-base-300 pt-3">
<h3 class="text-sm font-semibold">On the ground</h3>
<%= render "app/recipes/commands/ground", items: pack.ground %>
</section>
<%# One window for each item, opened by the browser with no round trip. %>
<% Recipes::Commands::Item::ALL.each_value do |item| %>
<dialog id="backpack_examine_<%= item.key %>" class="panel m-auto w-full max-w-xs p-5 backdrop:bg-black/50">
<h3 class="font-bold"><%= item.name %></h3>
<p class="mt-1"><%= item.examine %></p>
<%= tag.button "Close", type: "button", class: "btn btn-sm mt-4", **on(js.close_dialog) %>
</dialog>
<% end %>
<% end %>
<%# locals: (items:) -%>
<%# What is carried, each item with its menu. The channel morphs this list
when it changes, which puts every menu and mark back as written here. %>
<ul id="backpack_carried" class="grid gap-1">
<% items.each do |item| %>
<% options = "backpack_options_#{item.key}" %>
<% menu = "backpack_menu_#{item.key}" %>
<li id="backpack_item_<%= item.key %>" class="backpack-item relative flex items-center gap-2 rounded-box px-2 py-1">
<span class="grow"><%= item.name %></span>
<span class="text-xs tabular-nums opacity-60"><%= item.weight %> kg</span>
<%# Close any other menu, open or close this one, and put the cursor in it. %>
<%= tag.button "Options", type: "button", id: options, class: "btn btn-xs",
**on(js.hide(".backpack-menu:not(##{menu})").toggle("##{menu}").focus_first("##{menu}")) %>
<%# Escape closes the menu and gives the cursor back to its button. %>
<%= tag.ul id: menu, hidden: true,
class: "backpack-menu menu menu-sm absolute top-0 right-20 z-10 w-36 panel p-1",
**on(js.hide.focus("##{options}"), event: "keydown.esc") do %>
<% if item.slot %>
<li><%= tag.button item.verb, type: "button", **on(js.hide("##{menu}").push(:wear, with: { key: item.key })) %></li>
<% end %>
<li><%= tag.button "Mark", type: "button",
**on(js.toggle_class("bg-accent/30", to: { closest: ".backpack-item" }).hide("##{menu}").focus("##{options}")) %></li>
<li><%= tag.button "Drop", type: "button",
**on(js.hide({ closest: ".backpack-item" }).push(:drop, with: { key: item.key })) %></li>
<li><%= tag.button "Examine", type: "button",
**on(js.hide("##{menu}").focus("##{options}").show_modal("#backpack_examine_#{item.key}")) %></li>
<% end %>
</li>
<% end %>
</ul>
<%# locals: (worn:) -%>
<%# What is worn. Remove is an ordinary action: only the server knows where
the item goes. %>
<ul id="backpack_worn" class="grid gap-1">
<% Recipes::Commands::Pack::SLOTS.each do |slot, label| %>
<li id="backpack_slot_<%= slot %>" class="flex items-center gap-2 px-2 py-1">
<span class="w-16 text-sm opacity-70"><%= label %></span>
<span class="grow"><%= worn[slot]&.name || "Nothing" %></span>
<%= tag.button "Remove", type: "button", class: "btn btn-xs", disabled: worn[slot].nil?,
**on(:remove, with: { slot: }) %>
</li>
<% end %>
</ul>
<%# locals: (items:) -%>
<%# What was dropped, newest first. Picking an item up hides it at once,
then asks the server to put it back in the pack. %>
<div id="backpack_ground" class="mt-1">
<% if items.empty? %>
<p class="text-sm opacity-70">Nothing. Drop something and it lands here.</p>
<% else %>
<ul class="flex flex-wrap gap-1">
<% items.each do |item| %>
<li id="backpack_dropped_<%= item.key %>">
<%= tag.button "Take #{item.name.downcase_first}", type: "button", class: "btn btn-sm",
**on(js.hide({ closest: "li" }).push(:pick_up, with: { key: item.key })) %>
</li>
<% end %>
</ul>
<% end %>
</div>
The backpack above has two tabs and an Options button on every row. User interactions such as switching tabs, pressing Options, or picking Examine from the menu it opens all happen in the browser, and nothing is sent to the server. Wield, Drop and Remove do ask the server, since they change the pack. The clicks that stay in the browser are a short list of commands, written in the view beside the button.
A click that stays in the browser
On a Hibiki page a click usually goes to the server, and the server answers with HTML. That is right when the click changes something the server keeps, such as what is in the pack. Opening a menu changes nothing the server keeps. Sending it anyway costs a round trip, and a state in the channel for every menu on the page.
A command is a step the browser takes by itself when a control
is used: show or hide an element, add or remove a class, set an
attribute, move the cursor, or open a dialog. We write these with the js helper,
and the on helper takes the list we wrote.
Here are the Options and Drop buttons from one row:
<%# app/views/app/recipes/commands/_carried.html.erb %>
<%= tag.button "Options", type: "button", id: options,
**on(js.hide(".backpack-menu:not(##{menu})").toggle("##{menu}").focus_first("##{menu}")) %>
<%= tag.button "Drop", type: "button",
**on(js.hide({ closest: ".backpack-item" }).push(:drop, with: { key: item.key })) %>
Options hides every other menu, shows or hides its own, and puts the cursor in it. The browser runs the steps in order and does not wait between them.
A step has to know which element it acts on, and we tell it by giving it a
target. Options names its targets with CSS selectors, which the browser
looks up in the whole page: first every menu but this one, then this
menu. Drop's { closest: ".backpack-item" } is the nearest
ancestor of the button with that class, the row the button sits in. A
step that names nothing acts on the control itself, except
close_dialog, which closes the window the control sits in.
That is how the Close button in an examine window works.
Both in one click
Drop needs the server as well, since the item has to leave the pack.
push puts a channel action in the list. The pack lives in a
channel, the server side of an Action Cable connection, which
stays alive while the page is open. Its public methods,
wear, drop and so on, are the actions
the page may call. When Drop is clicked:
- The browser hides the row at once.
-
dropreaches the channel, which moves the item from the pack to the ground. - The channel renders the pack list and the ground again and sends both. The row is gone for good, the item appears on the ground, and the weight and the count of spaces follow.
Hiding the row at once takes for granted that the server will agree to the drop. The browser can't know that yet, since the server hasn't answered. In this pack the server always agrees, because any item can be dropped. If it could refuse, say for a cursed item that won't leave the pack, the row would vanish and then come back when the server rendered the list again. So hide a row early only when the action behind it cannot fail.
Why the marks disappear
Mark two items, then drop a third. The marks go. When the pack changes,
the channel morphs the list, and a morph sets every element's attributes
back to what the server wrote, classes and hidden included.
An open menu in the list closes for the same reason.
So a command's change lasts until the server next renders that element. The tabs, the panels and the examine windows sit outside the three lists the channel renders, so the Worn tab stays open after Remove. A mark that had to last would be state on the server, rendered as part of the list.
A window the server never sees
Examine opens a <dialog> with
show_modal, and its Close button runs
close_dialog. The browser does the rest: it keeps the cursor
inside the window, closes it on Escape, and gives the cursor
back to Options. The server never learns the window was open.
JS Commands is a small Hibiki module
During install, bin/rails g hibiki:rails:install adds this
line to your JS setup.
import "hibiki-rails/commands"
Using JS Commands is optional, and you can safely remove the import line if you do not wish to use it.
The backpack here asks the server only to move an item, and does the rest in the browser. A command acts at once; for an element that should slide or fade as it comes and goes, see Leave and Enter Transitions.