Current section
Files
Jump to
Current section
Files
docs/scoped-ids.md
# Scoped Widget IDs
Named containers automatically scope the IDs of their children. This
gives every widget a unique, hierarchical identity that reflects where
it lives in the tree, without requiring the developer to manually
construct long prefixed strings.
## How it works
When `Tree.normalize/1` processes a node with an explicit (non-auto) ID,
it pushes that ID onto a scope chain. All descendant nodes have their
IDs prefixed with the scope path, joined by `/`.
```
sidebar (container) -> id: "sidebar"
form (container) -> id: "sidebar/form"
email (text_input) -> id: "sidebar/form/email"
save (button) -> id: "sidebar/form/save"
```
### What creates scope
- **Named containers** -- any node with an explicit ID (not starting
with `auto:`) pushes its ID onto the scope chain.
### What does NOT create scope
- **Auto-ID containers** -- IDs starting with `auto:` (generated by
the builder layer) pass through without adding scope.
- **Window nodes** -- `type: "window"` nodes never scope, since windows
are logical boundaries, not containment boundaries.
### Slash validation
User-provided IDs cannot contain `/`. Attempting to normalize a tree
with a manually slashed ID raises an `ArgumentError`. The `/` separator
is reserved for the scoping mechanism.
## Events
When the renderer emits an event for a widget, the wire ID is the full
scoped path (e.g. `"sidebar/form/save"`). The protocol decode layer
splits it into a local `id` and a `scope` list:
```elixir
%Widget{type: :click, id: "save", scope: ["form", "sidebar"]}
```
The `scope` list is in **reverse order** -- nearest parent first. This
design optimises the common case of matching on the immediate parent:
```elixir
# Match on local ID only (ignores scope entirely)
def update(model, %Widget{type: :click, id: "save"}), do: ...
# Match on ID + immediate parent
def update(model, %Widget{type: :click, id: "save", scope: ["form" | _]}), do: ...
# Bind the parent for dynamic lists
def update(model, %Widget{type: :toggle, id: "done", scope: [item_id | _]}) do
toggle_item(model, item_id)
end
```
### Reconstructing the full path
Use `Toddy.Event.target/1` to get the full forward-order path:
```elixir
event = %Widget{type: :click, id: "save", scope: ["form", "sidebar"]}
Toddy.Event.target(event)
# => "sidebar/form/save"
```
## Which event structs carry scope
- `Toddy.Event.Widget`
- `Toddy.Event.Canvas`
- `Toddy.Event.MouseArea`
- `Toddy.Event.Pane`
- `Toddy.Event.Sensor`
Subscription events (Key, Mouse, Touch, IME, Modifiers) are global and
do not carry scope.
## Tree search
`Tree.find/2` and `Tree.exists?/2` support both full scoped paths and
local IDs:
```elixir
# Exact match on scoped path
Tree.find(tree, "sidebar/form/save")
# Falls back to local ID match (last segment)
Tree.find(tree, "save")
```
Exact matches take priority. Local ID fallback only triggers when the
target doesn't contain `/` and no exact match was found.
## Test selectors
Test helpers (`find/1`, `click/1`, etc.) accept both local and scoped
selectors:
```elixir
# Local ID -- resolved against the current tree
find("#save")
click("#save")
# Full scoped path
find("#sidebar/form/save")
```
The backends resolve local IDs to their full scoped path using the
Elixir-side tree before sending queries to the renderer.
## Dynamic lists
When rendering a list of items, wrap each item in a named container
(row, column, container) using the item's ID. The container creates
a scope for the item's children, giving each instance unique IDs
without manual prefixing.
```elixir
# View
column "todo_list" do
for item <- model.items do
row item.id do
checkbox("done", item.completed)
button("delete", "X")
end
end
end
```
This produces IDs like `"todo_list/item_1/done"` and
`"todo_list/item_2/delete"`. In `update/2`, bind the item ID
from the scope:
```elixir
def update(model, %Widget{type: :toggle, id: "done", scope: [item_id | _]}) do
toggle_item(model, item_id)
end
def update(model, %Widget{type: :click, id: "delete", scope: [item_id | _]}) do
delete_item(model, item_id)
end
```
The `| _` ignores the `"todo_list"` scope above, so the same
pattern works if the list is moved to a different part of the tree.
## Pattern matching tips
The reversed scope list is designed for ergonomic pattern matching:
```elixir
# Depth-agnostic: works whether "search" is at root or deeply nested
def update(model, %Widget{id: "query", scope: ["search" | _]}), do: ...
# Exact depth: only matches if "search" is the only scope ancestor
def update(model, %Widget{id: "query", scope: ["search"]}), do: ...
# No scope: only matches unscoped widgets
def update(model, %Widget{id: "save", scope: []}), do: ...
```
## Accessibility cross-references
The a11y props `labelled_by`, `described_by`, and `error_message`
reference other widgets by ID. During normalization, these references
are automatically resolved relative to the current scope:
```elixir
container "form" do
text("name_label", "Name:")
text_input("name", model.name, a11y: %{labelled_by: "name_label"})
end
```
On the wire, `labelled_by` becomes `"form/name_label"`, matching
the scoped ID of the label widget. References that already contain
`/` (full paths) pass through unchanged.
## Inspect output
All scoped event structs implement the `Inspect` protocol to show
the full path in debug output:
```
#Widget<:click "sidebar/form/save">
#Widget<:input "email" value="test@example.com">
#Canvas<:press "panel/drawing" x=42 y=100>
#Sensor<:resize "content/measure" 800x600>
```
Use `Toddy.Event.target/1` to get the same string programmatically.