Shared UI + its event handlers in one module — for drawers, pickers, and other components used across many pages.
defmodule MyAppWeb.DetailDrawer do
use Dstar.Component
def drawer(assigns) do
~H"""
<div id="detail-drawer">
<input data-on:change={event("change_title:#{@item.id}")} />
</div>
"""
end
def handle_event(conn, "change_title:" <> id, signals) do
# The module allowlist only selects this handler. Authorize
# the record *before* start/1 — after that the response is
# already a 200 SSE stream.
case Items.fetch_for_user(conn.assigns.current_user, id) do
{:ok, item} ->
{:ok, _} = Items.update_title(item, signals["title"])
conn |> start() |> patch_signals(%{saved: true})
:error ->
conn
|> Plug.Conn.send_resp(403, "Forbidden")
|> Plug.Conn.halt()
end
end
endPages embed the UI as plain function components and need no
handle_event clauses for it — events route to this module via
Dstar.Plugs.Dispatch (wire it with Dstar.Router.dstar_components/2).
Unlike Dstar.Page, event/2 here targets the component's dispatch
URL: <base>/<encoded-module>/<event>. The base defaults to /ds and
is read client-side from <body data-ds-base="...">, so it must match
the base you give dstar_components/2. The value must be a trusted local
absolute application path beginning with one /; protocol-relative values,
dot segments, backslashes, queries, fragments, and controls are rejected in
the browser; percent escapes must decode successfully.
Two common setups:
# Router: dstar_components "/ds", [...] (the default — no layout
# attribute needed)
# Custom base and/or app path prefix — declare it once in the root
# layout, including the dispatch segment:
# Router: scope "/:workspace_slug" do dstar_components "/ds", [...] end
<body data-ds-base={workspace_path(@current_scope.workspace.slug) <> "/ds"}>Colocation only: no server-side component state, no lifecycle. State lives in signals, the DOM, and the database.