Named containers automatically scope their children's IDs, producing unique hierarchical paths without manual prefixing. This is how you distinguish "the delete button in file A" from "the delete button in file B" because the container's ID becomes part of the path.
Scoping rules
| Node type | Creates scope? | Notes |
|---|---|---|
| Named container (explicit ID) | Yes | ID pushed onto scope chain |
Auto-ID container (auto: prefix) | No | Transparent, no scope effect |
Window node (type: "window") | Yes | Uses # separator instead of / |
| Custom widget | No | Widget IDs are transparent to scoping |
User-provided IDs must not contain / or #. The slash is reserved
for the scope separator and # is reserved for window-qualified paths
(e.g., "main#form/email"). Plushie.Tree.normalize/1 raises
ArgumentError on violation.
ID resolution
During normalisation, the scope chain builds canonical wire IDs.
Window nodes use # as the separator; containers within a window
use /:
main (window) -> "main"
sidebar (container) -> "main#sidebar"
form (container) -> "main#sidebar/form"
email (text_input) -> "main#sidebar/form/email"
save (button) -> "main#sidebar/form/save"The # only appears once (at the window boundary). Deeper nesting
uses /. The canonical format is window#scope/path/id.
Resolution is recursive; nesting depth is unlimited. Internally, the
scope is tracked as a forward-order string (e.g. "main#sidebar/form")
and prepended to each child's ID during the normalisation pass.
Auto-ID containers are transparent
Layout containers with auto-generated IDs (column, row, stack,
etc.) do not create scopes. This means you can wrap content in layout
containers freely without affecting the ID hierarchy:
container "form" do
column spacing: 8 do # auto-ID, no scope effect
text_input("email", "") # scoped as "form/email", not "form/auto:.../email"
button("save", "Save") # scoped as "form/save"
end
endThis is intentional. Intermediate layout containers exist for visual arrangement, not semantic grouping. Only named containers that you give an explicit ID create scope boundaries.
Custom widgets are transparent
Custom widget IDs do not create scopes. When a custom widget's
view/2 or view/3 renders children, those children inherit the
parent's scope, not the widget's:
# If MyWidget has ID "my-widget" and renders a button "save":
container "form" do
MyWidget.new("my-widget", label: "Submit")
end
# The button inside MyWidget gets scoped as "form/save"
# NOT "form/my-widget/save"This means custom widgets are invisible to the scope chain. Events
from widgets inside a custom widget carry the enclosing container's
scope, not the custom widget's ID. The widget's handle_event/2
callback intercepts events before they reach update/2, providing
the encapsulation layer instead.
Duplicate ID detection
Normalisation detects duplicate sibling IDs (two children of the same
parent with the same ID) and raises ArgumentError:
** (ArgumentError) duplicate sibling IDs detected during normalize: ["save"]Detection is sibling-scoped. The same local ID can exist in different scopes safely. The scope prefix ensures global uniqueness:
container "form-a" do
button("save", "Save") # "form-a/save"
end
container "form-b" do
button("save", "Save") # "form-b/save", no conflict
endDynamic IDs
IDs can be any string expression, including dynamic values from your model. This is how you scope list items:
for file <- model.files do
container file do
button("select", file)
button("delete", "x")
end
endEach file becomes a scope. The delete button for "hello.ex" gets
the wire ID "hello.ex/delete". In the event, you extract the
filename from the scope:
def update(model, %WidgetEvent{type: :click, id: "delete", scope: [file | _]}) do
delete_file(file)
endDynamic IDs follow the same rules as static IDs: no / characters,
and no duplicates among siblings.
Event scope field
When the renderer emits a widget event, the wire ID is the
canonical window#scope/path/id (e.g. "main#sidebar/form/save").
The SDK splits it into id (local), scope (reversed ancestor
chain, nearest parent first, window ID last), and window_id:
%WidgetEvent{type: :click, id: "save", scope: ["form", "sidebar", "main"], window_id: "main"}The scope is reversed so you can pattern match on the immediate parent
with [parent | _] without knowing the full ancestry. The window ID
is always the last element in the scope list, giving you the full
hierarchy from innermost container to outermost window.
The window_id field provides direct access to the window context:
# Via scope (window is last element)
%WidgetEvent{scope: [_form, _sidebar, window_id]} = event
# Via dedicated field
%WidgetEvent{window_id: window_id} = eventPattern matching examples
# Local ID only (any scope)
def update(model, %WidgetEvent{type: :click, id: "save"}), do: ...
# Immediate parent match (window_id at end doesn't affect [parent | _])
def update(model, %WidgetEvent{type: :click, id: "save", scope: ["form" | _]}), do: ...
# Bind dynamic parent (list items)
def update(model, %WidgetEvent{type: :toggle, id: "done", scope: [item_id | _]}), do: ...
# Match window via scope
def update(model, %WidgetEvent{id: "save", window_id: "settings"}), do: ...
# Top-level widget (only window in scope)
def update(model, %WidgetEvent{id: "save", scope: [window_id]}), do: ...Only Plushie.Event.WidgetEvent and Plushie.Event.ImeEvent carry
scope. Other subscription events (KeyEvent, ModifiersEvent) are
global and unscoped. Pointer subscription events (mouse/touch) are
delivered as WidgetEvent with id set to the window ID and
scope set to [].
Path reconstruction
Plushie.Event.target/1 reconstructs the full forward-slash path from
an event's id and scope fields. The window ID is automatically
stripped from the scope since it is not part of the container path:
Plushie.Event.target(%WidgetEvent{id: "save", scope: ["form", "sidebar", "main"], window_id: "main"})
# => "sidebar/form/save"Canvas element scoping
Canvas elements participate in the same scoping mechanism. The canvas widget's ID creates a scope, layers create sub-scopes, and interactive element IDs are scoped under them:
main (window) -> "main"
canvas "drawing" -> "main#drawing"
layer "shapes" -> "main#drawing/shapes"
interactive "handle", ... do ... end -> "main#drawing/shapes/handle"Canvas element events are regular widget events. The element's scoped wire ID is used directly:
%WidgetEvent{type: :click, id: "handle", scope: ["shapes", "drawing", "main"], window_id: "main"}Command paths
Commands that target widgets by path use the forward-slash scoped format:
Command.focus("form/email")
Command.scroll_to("sidebar/list", 0.0, 0.0)In multi-window apps, commands can target a specific window using the
window_id#path syntax:
Command.focus("settings#email")
Command.scroll_to("main#sidebar/list", 0.0, 0.0)The # separates the window ID from the widget path. Without a window
qualifier, the command targets whatever window contains the widget.
Multi-window scoping
The window ID is part of the scope chain (always the last element).
Each window creates a separate namespace. The widget handler registry
keys entries by canonical scoped ID, which includes the window prefix
(e.g., "main#form/save"). A widget in window "main" has registry
key "main#form/save", distinct from "settings#form/save" in
window "settings".
Events from a widget in window "main" carry
scope: ["form", "main"], window_id: "main". In multi-window apps,
you can pattern match on the window via window_id:
def update(model, %WidgetEvent{id: "save", window_id: "settings"}) do
save_settings(model)
endEvents from one window never trigger handlers in another.
Test selectors
Test helpers (find/1, click/1, etc.) accept #-prefixed ID
selectors with full scoped paths:
find!("#save") # local ID
click("#sidebar/form/save") # full scoped path
assert_text("#form/email", "") # scoped assertionIn multi-window apps, selectors can include a window qualifier using
the window_id#widget_path syntax:
click("main#save") # "save" in window "main"
find!("settings#form/email") # scoped path in window "settings"
assert_text("main#count", "3") # assertion scoped to a windowThe # separates the window ID from the widget path. Without a window
qualifier (i.e. "#save"), the selector searches all windows. An
ambiguous match across windows raises an error. Use the window
qualifier or the window: option to disambiguate.
The test backend resolves IDs against the normalised tree.
Accessibility cross-references
A11y props (labelled_by, described_by, error_message) reference
widget IDs. Bare IDs are resolved relative to the current scope during
normalisation. An ID already containing / passes through unchanged:
text_input("email", model.email,
a11y: %{labelled_by: "email-label"} # resolves to "form/email-label" inside "form"
)See also
- Lists and Inputs guide -- dynamic list scoping in practice
- Custom Widgets reference - widget event interception and scope transparency
- Layout reference - which containers support auto-IDs
Plushie.Tree- normalisation and scope resolution internalsPlushie.Event.WidgetEvent- scope field semanticsPlushie.Event.target/1- path reconstruction