Skip to content

4. Selection & a detail pane

This part makes a row click show its detail and highlight the selection, 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, while selected_id is an int. So selected_id = t.id looks like it should put a string into an int signal without being noticed: 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 read: t.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: 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. It will not guess.

The render-all pattern and its ceiling

This detail pane server-renders every issue's detail and shows one. That is fine here, where a detail is two lines. For rows with heavy payloads (message bodies, documents) this pattern ships everything to be hidden; 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 comes Part 5 — commands, where the database changes.