Packages

Lightweight Elixir library for Google's A2UI protocol. Serve interactive, agent-driven UI surfaces from any BEAM app via declarative JSON over WebSocket — no Phoenix or LiveView required.

Current section

Files

Jump to
ex_a2ui lib a2ui builder.ex
Raw

lib/a2ui/builder.ex

defmodule A2UI.Builder do
@moduledoc """
Pipe-friendly convenience functions for building A2UI surfaces.
The Builder is the recommended way to construct surfaces. Each function
returns the surface with the component added, enabling clean pipelines:
alias A2UI.Builder, as: UI
UI.surface("dashboard")
|> UI.text("title", "My Dashboard")
|> UI.button("refresh", "Refresh", action: "do_refresh")
|> UI.card("main", children: ["title", "refresh"])
|> UI.root("main")
## Binding to data model
Use the `bind:` option to bind a component property to the data model:
UI.surface("status")
|> UI.text("health", bind: "/system/health")
|> UI.data("/system/health", "operational")
The text component will reactively update when the data at
`/system/health` changes.
"""
alias A2UI.{BoundValue, Action, CheckRule, Component, FunctionCall, TemplateChildList, Surface}
# --- Surface ---
@doc "Creates a new empty surface with the given ID."
@spec surface(String.t()) :: Surface.t()
def surface(id), do: Surface.new(id)
@doc "Sets the root component ID. The renderer starts building from this component."
@spec root(Surface.t(), String.t()) :: Surface.t()
def root(%Surface{} = s, component_id), do: Surface.set_root(s, component_id)
@doc "Sets a data model value on the surface."
@spec data(Surface.t(), String.t(), term()) :: Surface.t()
def data(%Surface{} = s, path, value), do: Surface.put_data(s, path, value)
@doc "Sets the theme on the surface."
@spec theme(Surface.t(), keyword()) :: Surface.t()
def theme(%Surface{} = s, opts) when is_list(opts) do
%{s | theme: A2UI.Theme.new(opts)}
end
@doc "Enables or disables the sendDataModel flag on the surface."
@spec send_data_model(Surface.t(), boolean()) :: Surface.t()
def send_data_model(%Surface{} = s, flag \\ true) when is_boolean(flag) do
%{s | send_data_model: flag}
end
@doc "Sets the catalog ID on the surface."
@spec catalog_id(Surface.t(), String.t() | A2UI.Catalog.t()) :: Surface.t()
def catalog_id(%Surface{} = s, %A2UI.Catalog{id: id}), do: %{s | catalog_id: id}
def catalog_id(%Surface{} = s, id) when is_binary(id), do: %{s | catalog_id: id}
# --- Display components ---
@doc """
Adds a Text component.
## Options
- `bind:` — data model path to bind the text value to
## Examples
UI.text(surface, "greeting", "Hello!")
UI.text(surface, "name", bind: "/user/name")
"""
@spec text(Surface.t(), String.t(), String.t() | FunctionCall.t() | keyword()) :: Surface.t()
def text(%Surface{} = s, id, text) when is_binary(text) do
add(s, id, :text, %{text: BoundValue.literal(text)})
end
def text(%Surface{} = s, id, %FunctionCall{} = fc) do
add(s, id, :text, %{text: fc})
end
def text(%Surface{} = s, id, opts) when is_list(opts) do
value = resolve_bound_value(opts[:bind], opts[:text])
add(s, id, :text, %{text: value})
end
@doc """
Adds an Image component.
## Options
- `alt:` — alt text
- `bind:` — data model path for the image source
"""
@spec image(Surface.t(), String.t(), String.t() | keyword()) :: Surface.t()
def image(%Surface{} = s, id, src) when is_binary(src) do
add(s, id, :image, %{src: BoundValue.literal(src)})
end
def image(%Surface{} = s, id, opts) when is_list(opts) do
props =
%{src: resolve_bound_value(opts[:bind], opts[:src])}
|> maybe_put(:alt, opts[:alt])
add(s, id, :image, props)
end
@doc "Adds a Divider component."
@spec divider(Surface.t(), String.t()) :: Surface.t()
def divider(%Surface{} = s, id), do: add(s, id, :divider, %{})
# --- Interactive components ---
@doc """
Adds a Button component.
## Options
- `action:` — action name (string) triggered on click
- `bind:` — data model path for the button label
## Examples
UI.button(surface, "submit", "Submit", action: "submit_form")
"""
@spec button(Surface.t(), String.t(), String.t(), keyword()) :: Surface.t()
def button(%Surface{} = s, id, label, opts \\ []) do
props = %{label: BoundValue.literal(label)}
props =
case opts[:action] do
nil -> props
name when is_binary(name) -> Map.put(props, :action, Action.new(name))
%Action{} = action -> Map.put(props, :action, action)
end
add(s, id, :button, props)
end
@doc """
Adds a TextField component.
## Options
- `placeholder:` — placeholder text
- `bind:` — data model path for the field value
- `action:` — action name triggered on change/submit
- `checks:` — list of `CheckRule` validation rules
"""
@spec text_field(Surface.t(), String.t(), keyword()) :: Surface.t()
def text_field(%Surface{} = s, id, opts \\ []) do
props = %{}
props =
case opts[:bind] do
nil -> props
path -> Map.put(props, :value, BoundValue.bind(path))
end
props = maybe_put_literal(props, :placeholder, opts[:placeholder])
props =
case opts[:action] do
nil -> props
name -> Map.put(props, :action, Action.new(name))
end
props = maybe_put(props, :checks, opts[:checks])
add(s, id, :text_field, props)
end
@doc """
Adds a CheckBox component.
## Options
- `label:` — checkbox label
- `bind:` — data model path for checked state
- `action:` — action name triggered on toggle
- `checks:` — list of `CheckRule` validation rules
"""
@spec checkbox(Surface.t(), String.t(), keyword()) :: Surface.t()
def checkbox(%Surface{} = s, id, opts \\ []) do
props = %{}
props = maybe_put_literal(props, :label, opts[:label])
props =
case opts[:bind] do
nil -> props
path -> Map.put(props, :checked, BoundValue.bind(path))
end
props =
case opts[:action] do
nil -> props
name -> Map.put(props, :action, Action.new(name))
end
props = maybe_put(props, :checks, opts[:checks])
add(s, id, :checkbox, props)
end
@doc """
Adds a Slider component.
## Options
- `min:` — minimum value
- `max:` — maximum value
- `bind:` — data model path for current value
- `action:` — action name triggered on change
- `checks:` — list of `CheckRule` validation rules
"""
@spec slider(Surface.t(), String.t(), keyword()) :: Surface.t()
def slider(%Surface{} = s, id, opts \\ []) do
props = %{}
props = maybe_put_literal(props, :min, opts[:min])
props = maybe_put_literal(props, :max, opts[:max])
props =
case opts[:bind] do
nil -> props
path -> Map.put(props, :value, BoundValue.bind(path))
end
props =
case opts[:action] do
nil -> props
name -> Map.put(props, :action, Action.new(name))
end
props = maybe_put(props, :checks, opts[:checks])
add(s, id, :slider, props)
end
# --- Container components ---
@doc """
Adds a Card component.
## Options
- `children:` — list of child component IDs or a `TemplateChildList`
- `title:` — card title text
## Examples
UI.card(surface, "main", children: ["title", "body", "actions"])
UI.card(surface, "list", children: UI.template_children("/items", "item-tpl"))
"""
@spec card(Surface.t(), String.t(), keyword()) :: Surface.t()
def card(%Surface{} = s, id, opts \\ []) do
props = %{}
props = maybe_put_children(props, opts[:children])
props = maybe_put_literal(props, :title, opts[:title])
add(s, id, :card, props)
end
@doc """
Adds a Row component (horizontal layout).
## Options
- `children:` — list of child component IDs or a `TemplateChildList`
"""
@spec row(Surface.t(), String.t(), keyword()) :: Surface.t()
def row(%Surface{} = s, id, opts \\ []) do
props = %{}
props = maybe_put_children(props, opts[:children])
add(s, id, :row, props)
end
@doc """
Adds a Column component (vertical layout).
## Options
- `children:` — list of child component IDs or a `TemplateChildList`
"""
@spec column(Surface.t(), String.t(), keyword()) :: Surface.t()
def column(%Surface{} = s, id, opts \\ []) do
props = %{}
props = maybe_put_children(props, opts[:children])
add(s, id, :column, props)
end
@doc """
Adds a Modal component.
## Options
- `children:` — list of child component IDs or a `TemplateChildList`
- `title:` — modal title
"""
@spec modal(Surface.t(), String.t(), keyword()) :: Surface.t()
def modal(%Surface{} = s, id, opts \\ []) do
props = %{}
props = maybe_put_children(props, opts[:children])
props = maybe_put_literal(props, :title, opts[:title])
add(s, id, :modal, props)
end
# --- Custom components ---
@doc """
Adds a custom component with an arbitrary type name.
Use this for domain-specific components registered in the client's catalog.
## Examples
UI.custom(surface, :graph, "arch-graph",
nodes: A2UI.BoundValue.bind("/graph/nodes"),
edges: A2UI.BoundValue.bind("/graph/edges")
)
"""
@spec custom(Surface.t(), atom(), String.t(), keyword()) :: Surface.t()
def custom(%Surface{} = s, type, id, props \\ []) when is_atom(type) do
add(s, id, {:custom, type}, Map.new(props))
end
# --- FunctionCall helpers ---
@doc "Creates a `formatString` FunctionCall for use as a dynamic value."
@spec format_string(String.t()) :: FunctionCall.t()
def format_string(template), do: FunctionCall.format_string(template)
@doc "Creates an `openUrl` FunctionCall for use as a dynamic value."
@spec open_url(String.t()) :: FunctionCall.t()
def open_url(url), do: FunctionCall.open_url(url)
@doc "Creates a `numeric` validation FunctionCall."
@spec numeric(term()) :: FunctionCall.t()
def numeric(value_ref), do: FunctionCall.numeric(value_ref)
@doc "Creates an `email` validation FunctionCall."
@spec email(term()) :: FunctionCall.t()
def email(value_ref), do: FunctionCall.email(value_ref)
@doc "Creates a `formatNumber` FunctionCall."
@spec format_number(term()) :: FunctionCall.t()
def format_number(value_ref), do: FunctionCall.format_number(value_ref)
@doc "Creates a `formatCurrency` FunctionCall."
@spec format_currency(term(), String.t()) :: FunctionCall.t()
def format_currency(value_ref, currency_code),
do: FunctionCall.format_currency(value_ref, currency_code)
@doc "Creates a `formatDate` FunctionCall."
@spec format_date(term(), String.t()) :: FunctionCall.t()
def format_date(value_ref, format), do: FunctionCall.format_date(value_ref, format)
@doc "Creates a `pluralize` FunctionCall."
@spec pluralize(term(), term(), term()) :: FunctionCall.t()
def pluralize(count, singular, plural), do: FunctionCall.pluralize(count, singular, plural)
@doc "Creates an `and` logic FunctionCall."
@spec fn_and(list()) :: FunctionCall.t()
def fn_and(conditions), do: FunctionCall.fn_and(conditions)
@doc "Creates an `or` logic FunctionCall."
@spec fn_or(list()) :: FunctionCall.t()
def fn_or(conditions), do: FunctionCall.fn_or(conditions)
@doc "Creates a `not` logic FunctionCall."
@spec fn_not(term()) :: FunctionCall.t()
def fn_not(condition), do: FunctionCall.fn_not(condition)
# --- TemplateChildList helpers ---
@doc "Creates a `TemplateChildList` for data-driven children."
@spec template_children(String.t(), String.t()) :: TemplateChildList.t()
def template_children(path, component_id),
do: TemplateChildList.new(path, component_id)
# --- CheckRule helpers ---
@doc "Creates a `required` check rule bound to a data model path."
@spec required_check(String.t(), String.t()) :: CheckRule.t()
def required_check(bind_path, message \\ "This field is required") do
CheckRule.required(BoundValue.bind(bind_path), message)
end
@doc "Creates a `max_length` check rule bound to a data model path."
@spec max_length_check(String.t(), integer(), String.t()) :: CheckRule.t()
def max_length_check(bind_path, max, message \\ "Too long") do
CheckRule.max_length(BoundValue.bind(bind_path), max, message)
end
@doc "Creates a `regex` check rule bound to a data model path."
@spec regex_check(String.t(), String.t(), String.t()) :: CheckRule.t()
def regex_check(bind_path, pattern, message) do
CheckRule.regex(BoundValue.bind(bind_path), pattern, message)
end
# --- Internal helpers ---
defp add(%Surface{} = s, id, type, properties) do
component = %Component{id: id, type: type, properties: properties}
Surface.add_component(s, component)
end
defp resolve_bound_value(nil, nil), do: BoundValue.literal("")
defp resolve_bound_value(nil, text), do: BoundValue.literal(text)
defp resolve_bound_value(path, nil), do: BoundValue.bind(path)
defp resolve_bound_value(path, text), do: BoundValue.bind(path, text)
defp maybe_put_children(map, nil), do: map
defp maybe_put_children(map, %TemplateChildList{} = tcl), do: Map.put(map, :children, tcl)
defp maybe_put_children(map, children) when is_list(children),
do: Map.put(map, :children, children)
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp maybe_put_literal(map, _key, nil), do: map
defp maybe_put_literal(map, key, value), do: Map.put(map, key, BoundValue.literal(value))
end