Search documentation of Plushie

# Scoped IDs

Copy Markdown View Source

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
    end

This 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
    end

## Dynamic 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
    end

Each 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)
    end

Dynamic 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} = event

### Pattern 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)
    end

Events 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 assertion

In 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 window

The `#` 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 internals
- `Plushie.Event.WidgetEvent` - scope field semantics
- `Plushie.Event.target/1` - path reconstruction

← Previous Page Composition Patterns
Next Page → Themes and Styling