Skip to content

4. Selection & a detail pane

Goal: click a row, see its detail — selection highlighted, all client-side.

A selection signal

components/tracker.py (the new line)
search: Local[str] = ""
selected_id: Local[int] = 0   # which row's detail is open (0 = none)
filter: Url[str] = "all"
issues: Server[object]

Make each row clickable and mark the selected one:

components/tracker.html (the rows, updated)
<li data-row="IssueRow" data-id="{{ t.id }}" data-title="{{ t.title }}"
    :show="search.lower() in t.title.lower()"
    :class="selected: selected_id == t.id">
  <button @click="selected_id = t.id">{{ t.title }}</button>
  <span class="status {{ t.status }}">{{ t.status }}</span>
</li>

and add the detail pane below the list, still inside the region:

components/tracker.html (after </ul>)
<div class="detail">
  {% for t in issues %}
  <div data-row="IssueRow" data-id="{{ t.id }}" :show="selected_id == t.id">
    <h2>{{ t.title }}</h2>
    <p>#{{ t.id }} · {{ t.status }}</p>
  </div>
  {% endfor %}
</div>

Three compiled pieces:

  • @click="selected_id = t.id" — an event action assigning a Local from the row's projected id. Click; zero network.
  • :class="selected: selected_id == t.id" — toggles one class on a condition; the highlight follows the selection.
  • :show="selected_id == t.id" on each detail block — only the selected issue's detail is visible.

Why == just works

t.id comes from a data-* attribute, so it arrives as a string: data-id="2" is "2", never 2. And selected_id is an int. So selected_id = t.id looks like it should quietly put a string into an int signal — the classic bug of every stringly-typed boundary.

It doesn't, because you declared the row:

class IssueRow(TypedDict):
    id: int
    title: str

data-row="IssueRow" tells the compiler what those DOM strings are, so it inserts the decode at the readt.id compiles to an int, once, wherever it appears. selected_id therefore actually holds an int, selected_id == t.id is an ordinary int comparison, and the type you wrote is the type you get. You never debug "2" !== 2, and you never debug "2" + 1 === "21" either.

Declare id: str instead and the compiler is equally happy — it just won't decode, because a string already is one. What it will not do is guess.

The render-all pattern and its ceiling

This detail pane server-renders every issue's detail and shows one — fine here, where a detail is two lines. For rows with heavy payloads (message bodies, documents) this pattern ships everything to be hidden, and the right move is to load the open item's body on demand through a server read. The mail demo does exactly that, and the unified idiom turns the mistake into a compile error.

Restart: click rows, watch the highlight and the pane follow, and confirm in the network tab that none of it touches the server.

Next: Part 5 — commands, where the database changes.