Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol lib raxol ui components patterns compound.ex
Raw

lib/raxol/ui/components/patterns/compound.ex

defmodule Raxol.UI.Components.Patterns.Compound do
@moduledoc """
Compound Components pattern for Raxol UI.
Components designed to work together with shared implicit state and flexible
composition. Parent manages shared state, children handle individual concerns.
## Usage
# Accordion example
%{
type: :accordion,
children: [
%{
type: :accordion_item,
children: [
%{type: :accordion_trigger, children: [text("Section 1")]},
%{type: :accordion_content, children: [text("Content 1")]}
]
}
]
}
"""
alias Raxol.UI.State.{Context, Hooks}
## Accordion Compound Component
@doc """
Accordion root component that manages expanded state and provides context to children.
## Props
- `:type` - :single (only one item open) or :multiple (multiple items can be open)
- `:default_value` - Default expanded item(s)
- `:collapsible` - Whether items can be collapsed (for :single type)
- `:on_value_change` - Callback when expanded items change
## Context Provided
- `:expanded_items` - Set of currently expanded item values
- `:toggle_item` - Function to toggle an item's expanded state
- `:accordion_type` - The accordion type (:single or :multiple)
"""
def accordion do
%{
type: :compound_component,
render_fn: fn props, _context ->
accordion_type = Map.get(props, :type, :single)
default_value =
Map.get(
props,
:default_value,
case accordion_type do
:single -> nil
_ -> []
end
)
collapsible = Map.get(props, :collapsible, true)
on_value_change = Map.get(props, :on_value_change)
children = Map.get(props, :children, [])
# Initialize expanded state
{expanded_items, set_expanded_items} =
case accordion_type do
:single ->
Hooks.use_state(default_value)
:multiple ->
default_set =
case is_list(default_value) do
true -> MapSet.new(default_value)
false -> MapSet.new()
end
Hooks.use_state(default_set)
end
# Toggle item function
toggle_item =
Hooks.use_callback(
fn item_value ->
case accordion_type do
:single ->
new_value =
case expanded_items == item_value do
true ->
case collapsible do
true -> nil
false -> item_value
end
false ->
item_value
end
set_expanded_items.(new_value)
case on_value_change do
nil -> nil
callback -> callback.(new_value)
end
:multiple ->
new_set =
case MapSet.member?(expanded_items, item_value) do
true -> MapSet.delete(expanded_items, item_value)
false -> MapSet.put(expanded_items, item_value)
end
set_expanded_items.(new_set)
case on_value_change do
nil -> nil
callback -> callback.(MapSet.to_list(new_set))
end
end
end,
[accordion_type, expanded_items, collapsible, on_value_change]
)
# Create accordion context
accordion_context = %{
expanded_items: expanded_items,
toggle_item: toggle_item,
accordion_type: accordion_type
}
# Provide context to children
Context.create_provider(
Context.create_context(accordion_context, :accordion_context),
accordion_context,
children
)
end
}
end
@doc """
Accordion item that represents a single collapsible section.
## Props
- `:value` - Unique identifier for this item (required)
- `:disabled` - Whether this item is disabled
"""
def accordion_item do
%{
type: :compound_component,
render_fn: fn props, _context ->
value = Map.get(props, :value)
disabled = Map.get(props, :disabled, false)
children = Map.get(props, :children, [])
case value do
nil ->
%{
type: :text,
attrs: %{content: "Error: accordion_item requires :value prop"}
}
_ ->
# Get accordion context
accordion_context = Hooks.use_context(:accordion_context) || %{}
# Determine if this item is expanded
is_expanded =
case Map.get(accordion_context, :accordion_type) do
:single ->
Map.get(accordion_context, :expanded_items) == value
:multiple ->
MapSet.member?(
Map.get(accordion_context, :expanded_items, MapSet.new()),
value
)
end
# Create item context
item_context = %{
value: value,
is_expanded: is_expanded,
disabled: disabled,
toggle:
case disabled do
true ->
fn -> :ok end
false ->
fn ->
Map.get(accordion_context, :toggle_item, fn _ -> nil end).(
value
)
end
end
}
# Provide item context to children
Context.create_provider(
Context.create_context(item_context, :accordion_item_context),
item_context,
children
)
end
end
}
end
@doc """
Accordion trigger that toggles the item's expanded state when clicked.
"""
def accordion_trigger do
%{
type: :compound_component,
render_fn: fn props, _context ->
children = Map.get(props, :children, [])
# Get item context
item_context = Hooks.use_context(:accordion_item_context) || %{}
%{
type: :button,
attrs: %{
disabled: Map.get(item_context, :disabled),
on_click: Map.get(item_context, :toggle),
aria_expanded: Map.get(item_context, :is_expanded),
aria_controls: "accordion-content-#{Map.get(item_context, :value)}",
style: %{
display: :flex,
align_items: :center,
justify_content: :space_between,
padding: 10,
background:
case Map.get(item_context, :is_expanded) do
true -> :primary_light
false -> :transparent
end,
border: :none,
cursor:
case Map.get(item_context, :disabled) do
true -> :not_allowed
_ -> :pointer
end
}
},
children:
children ++
[
%{
type: :text,
attrs: %{
content:
case Map.get(item_context, :is_expanded) do
true -> "−"
false -> "+"
end,
style: %{font_weight: :bold}
}
}
]
}
end
}
end
@doc """
Accordion content that is shown/hidden based on the item's expanded state.
"""
def accordion_content do
%{
type: :compound_component,
render_fn: fn props, _context ->
children = Map.get(props, :children, [])
# Get item context
item_context = Hooks.use_context(:accordion_item_context) || %{}
render_accordion_content(
Map.get(item_context, :is_expanded),
Map.get(item_context, :value),
children
)
end
}
end
## Dropdown Compound Component
@doc """
Dropdown root component that manages open state and provides context.
## Props
- `:open` - Controlled open state
- `:default_open` - Default open state (uncontrolled)
- `:on_open_change` - Callback when open state changes
- `:placement` - Dropdown placement (:bottom, :top, :left, :right)
"""
def dropdown do
%{
type: :compound_component,
render_fn: fn props, _context ->
controlled_open = Map.get(props, :open)
default_open = Map.get(props, :default_open, false)
on_open_change = Map.get(props, :on_open_change)
placement = Map.get(props, :placement, :bottom)
children = Map.get(props, :children, [])
# Manage open state (controlled vs uncontrolled)
{is_open, set_is_open} =
manage_dropdown_state(controlled_open, on_open_change, default_open)
# Close dropdown when clicking outside (placeholder implementation)
Hooks.use_effect(
fn -> setup_outside_click_handler(is_open, set_is_open) end,
[is_open]
)
# Dropdown context
dropdown_context = %{
is_open: is_open,
set_open: set_is_open,
placement: placement,
close: fn -> set_is_open.(false) end
}
Context.create_provider(
Context.create_context(dropdown_context, :dropdown_context),
dropdown_context,
children
)
end
}
end
@doc """
Dropdown trigger that opens/closes the dropdown when clicked.
"""
def dropdown_trigger do
%{
type: :compound_component,
render_fn: fn props, _context ->
children = Map.get(props, :children, [])
# Get dropdown context
dropdown_context = Hooks.use_context(:dropdown_context) || %{}
%{
type: :button,
attrs: %{
on_click: fn ->
Map.get(dropdown_context, :set_open, fn _ -> nil end).(
not Map.get(dropdown_context, :is_open, false)
)
end,
aria_expanded: Map.get(dropdown_context, :is_open),
aria_haspopup: true,
style: %{
position: :relative,
cursor: :pointer
}
},
children: children
}
end
}
end
@doc """
Dropdown content that contains the dropdown menu items.
"""
def dropdown_content do
%{
type: :compound_component,
render_fn: fn props, _context ->
children = Map.get(props, :children, [])
# Get dropdown context
dropdown_context = Hooks.use_context(:dropdown_context) || %{}
render_dropdown_content(
Map.get(dropdown_context, :is_open, false),
Map.get(dropdown_context, :placement, :bottom),
children
)
end
}
end
@doc """
Dropdown item that represents a single menu option.
## Props
- `:value` - Value associated with this item
- `:disabled` - Whether this item is disabled
- `:on_select` - Callback when this item is selected
"""
def dropdown_item do
%{
type: :compound_component,
render_fn: fn props, _context ->
value = Map.get(props, :value)
disabled = Map.get(props, :disabled, false)
on_select = Map.get(props, :on_select)
children = Map.get(props, :children, [])
# Get dropdown context
dropdown_context = Hooks.use_context(:dropdown_context) || %{}
handle_select =
create_dropdown_item_handler(
disabled,
on_select,
value,
dropdown_context
)
%{
type: :button,
attrs: %{
role: :menuitem,
disabled: disabled,
on_click: handle_select,
style: %{
width: "100%",
padding: 8,
text_align: :left,
background: :transparent,
border: :none,
cursor: get_cursor_style(disabled),
opacity: get_opacity_style(disabled),
hover: %{
background: get_hover_background(disabled)
}
}
},
children: children
}
end
}
end
@doc """
Dropdown separator that visually separates groups of menu items.
"""
def dropdown_separator do
%{
type: :compound_component,
render_fn: fn _props, _context ->
%{
type: :box,
attrs: %{
role: :separator,
style: %{
height: 1,
background: "#e0e0e0",
margin: %{vertical: 4}
}
}
}
end
}
end
## Tabs Compound Component
@doc """
Tabs root component that manages active tab state.
## Props
- `:value` - Controlled active tab value
- `:default_value` - Default active tab (uncontrolled)
- `:on_value_change` - Callback when active tab changes
- `:orientation` - :horizontal or :vertical
"""
def tabs do
%{
type: :compound_component,
render_fn: fn props, _context ->
controlled_value = Map.get(props, :value)
default_value = Map.get(props, :default_value)
on_value_change = Map.get(props, :on_value_change)
orientation = Map.get(props, :orientation, :horizontal)
children = Map.get(props, :children, [])
{active_tab, set_active_tab} =
manage_tabs_state(controlled_value, on_value_change, default_value)
tabs_context = %{
active_tab: active_tab,
set_active_tab: set_active_tab,
orientation: orientation
}
Context.create_provider(
Context.create_context(tabs_context, :tabs_context),
tabs_context,
children
)
end
}
end
@doc """
Tabs list that contains the tab triggers.
"""
def tabs_list do
%{
type: :compound_component,
render_fn: fn props, _context ->
children = Map.get(props, :children, [])
# Get tabs context
tabs_context = Hooks.use_context(:tabs_context)
container_type =
case Map.get(tabs_context || %{}, :orientation) do
:horizontal -> :row
:vertical -> :column
end
%{
type: container_type,
attrs: %{
role: :tablist,
aria_orientation: Map.get(tabs_context || %{}, :orientation),
style: %{
border_bottom:
case Map.get(tabs_context || %{}, :orientation) do
:horizontal -> "1px solid #e0e0e0"
_ -> :none
end,
border_right:
case Map.get(tabs_context || %{}, :orientation) do
:vertical -> "1px solid #e0e0e0"
_ -> :none
end
}
},
children: children
}
end
}
end
@doc """
Tab trigger that switches to a specific tab when clicked.
## Props
- `:value` - The tab value this trigger activates (required)
- `:disabled` - Whether this tab is disabled
"""
def tabs_trigger do
%{
type: :compound_component,
render_fn: fn props, _context ->
value = Map.get(props, :value)
disabled = Map.get(props, :disabled, false)
children = Map.get(props, :children, [])
case value do
nil ->
render_tabs_trigger_error()
_ ->
# Get tabs context
tabs_context = Hooks.use_context(:tabs_context)
is_active = Map.get(tabs_context || %{}, :active_tab) == value
handle_click =
create_tabs_click_handler(disabled, value, tabs_context)
%{
type: :button,
attrs: %{
role: :tab,
"aria-selected": is_active,
"aria-controls": "tab-panel-#{value}",
disabled: disabled,
on_click: handle_click,
style: %{
padding: 12,
background:
case is_active do
true -> :white
false -> :transparent
end,
border: :none,
border_bottom:
case {is_active, Map.get(tabs_context || %{}, :orientation)} do
{true, :horizontal} -> "2px solid #007acc"
_ -> :none
end,
border_right:
case {is_active, Map.get(tabs_context || %{}, :orientation)} do
{true, :vertical} -> "2px solid #007acc"
_ -> :none
end,
cursor:
case disabled do
true -> :not_allowed
false -> :pointer
end,
opacity:
case disabled do
true -> 0.5
false -> 1.0
end,
font_weight:
case is_active do
true -> :bold
false -> :normal
end
}
},
children: children
}
end
end
}
end
@doc """
Tab content panel that displays content for a specific tab.
## Props
- `:value` - The tab value this content is associated with (required)
"""
def tabs_content do
%{
type: :compound_component,
render_fn: fn props, _context ->
value = Map.get(props, :value)
children = Map.get(props, :children, [])
case value do
nil -> render_tabs_content_error()
_ -> render_tabs_content_panel(value, children)
end
end
}
end
# Helper functions (placeholders)
defp manage_dropdown_state(controlled_open, on_open_change, _default_open)
when controlled_open != nil do
{controlled_open, create_controlled_change_handler(on_open_change)}
end
defp manage_dropdown_state(_controlled_open, _on_open_change, default_open) do
Hooks.use_state(default_open)
end
defp create_controlled_change_handler(nil), do: fn _new_open -> :ok end
defp create_controlled_change_handler(on_open_change) do
fn new_open -> on_open_change.(new_open) end
end
defp setup_outside_click_handler(false, _set_is_open), do: nil
defp setup_outside_click_handler(true, set_is_open) do
outside_click_handler = fn _event -> set_is_open.(false) end
register_outside_click_listener(outside_click_handler)
fn -> unregister_outside_click_listener(outside_click_handler) end
end
defp render_accordion_content(false, _value, _children) do
%{type: :text, attrs: %{content: "", style: %{display: :none}}}
end
defp render_accordion_content(true, value, children) do
%{
type: :column,
attrs: %{
id: "accordion-content-#{value}",
style: %{
padding: 10,
border_top: "1px solid #e0e0e0"
}
},
children: children
}
end
defp render_dropdown_content(false, _placement, _children) do
%{type: :text, attrs: %{content: "", style: %{display: :none}}}
end
defp render_dropdown_content(true, placement, children) do
%{
type: :column,
attrs: %{
role: :menu,
style: %{
position: :absolute,
top: get_dropdown_position(:top, placement),
bottom: get_dropdown_position(:bottom, placement),
left: get_dropdown_position(:left, placement),
right: get_dropdown_position(:right, placement),
z_index: 1000,
background: :white,
border: "1px solid #e0e0e0",
border_radius: 4,
box_shadow: "0 2px 8px rgba(0,0,0,0.15)",
padding: 4,
min_width: 150
}
},
children: children
}
end
defp get_dropdown_position(:top, :top), do: :auto
defp get_dropdown_position(:top, _), do: "100%"
defp get_dropdown_position(:bottom, :top), do: "100%"
defp get_dropdown_position(:bottom, _), do: :auto
defp get_dropdown_position(:left, :left), do: :auto
defp get_dropdown_position(:left, :right), do: "100%"
defp get_dropdown_position(:left, _), do: 0
defp get_dropdown_position(:right, :right), do: :auto
defp get_dropdown_position(:right, :left), do: "100%"
defp get_dropdown_position(:right, _), do: :auto
defp create_dropdown_item_handler(
disabled,
on_select,
value,
dropdown_context
) do
Hooks.use_callback(
fn ->
handle_dropdown_item_select(
disabled,
on_select,
value,
dropdown_context
)
end,
[disabled, on_select, value]
)
end
defp handle_dropdown_item_select(true, _on_select, _value, _dropdown_context),
do: :ok
defp handle_dropdown_item_select(false, on_select, value, dropdown_context) do
execute_on_select(on_select, value)
dropdown_context.close.()
end
defp execute_on_select(nil, _value), do: :ok
defp execute_on_select(on_select, value), do: on_select.(value)
defp manage_tabs_state(controlled_value, on_value_change, _default_value)
when controlled_value != nil do
{controlled_value, create_tabs_change_handler(on_value_change)}
end
defp manage_tabs_state(_controlled_value, _on_value_change, default_value) do
Hooks.use_state(default_value)
end
defp create_tabs_change_handler(nil), do: fn _new_value -> :ok end
defp create_tabs_change_handler(on_value_change) do
fn new_value -> on_value_change.(new_value) end
end
defp render_tabs_trigger_error do
%{
type: :text,
attrs: %{content: "Error: tabs_trigger requires :value prop"}
}
end
defp create_tabs_click_handler(disabled, value, tabs_context) do
Hooks.use_callback(
fn -> handle_tabs_click(disabled, value, tabs_context) end,
[disabled, value]
)
end
defp handle_tabs_click(true, _value, _tabs_context), do: :ok
defp handle_tabs_click(false, value, tabs_context) do
Map.get(tabs_context || %{}, :set_active_tab, fn _ -> nil end).(value)
end
defp get_cursor_style(true), do: :not_allowed
defp get_cursor_style(false), do: :pointer
defp get_opacity_style(true), do: 0.5
defp get_opacity_style(false), do: 1.0
defp get_hover_background(true), do: :transparent
defp get_hover_background(false), do: :primary_light
# Unused function - commented out to reduce warnings
# defp render_tabs_trigger_content(
# is_active,
# disabled,
# tabs_context,
# handle_click,
# children,
# value
# ) do
# %{
# type: :button,
# attrs: %{
# role: :tab,
# "aria-selected": is_active,
# "aria-controls": "tab-panel-#{value}",
# disabled: disabled,
# on_click: handle_click,
# style: %{
# padding: 12,
# background: get_tab_background(is_active),
# border: :none,
# border_bottom:
# get_tab_border_bottom(is_active, tabs_context.orientation),
# border_right:
# get_tab_border_right(is_active, tabs_context.orientation),
# cursor: get_cursor_style(disabled),
# opacity: get_opacity_style(disabled),
# font_weight: get_font_weight(is_active)
# }
# },
# children: children
# }
# end
# defp get_tab_background(true), do: :white
# defp get_tab_background(false), do: :transparent
# defp get_tab_border_bottom(true, :horizontal), do: "2px solid #007acc"
# defp get_tab_border_bottom(_, _), do: :none
# defp get_tab_border_right(true, :vertical), do: "2px solid #007acc"
# defp get_tab_border_right(_, _), do: :none
# defp get_font_weight(true), do: :bold
# defp get_font_weight(false), do: :normal
defp render_tabs_content_error do
%{
type: :text,
attrs: %{content: "Error: tabs_content requires :value prop"}
}
end
defp render_tabs_content_panel(value, children) do
tabs_context = Hooks.use_context(:tabs_context)
is_active = Map.get(tabs_context || %{}, :active_tab) == value
render_tabs_panel_if_active(is_active, value, children)
end
defp render_tabs_panel_if_active(false, _value, _children) do
%{type: :text, attrs: %{content: "", style: %{display: :none}}}
end
defp render_tabs_panel_if_active(true, value, children) do
%{
type: :column,
attrs: %{
role: :tabpanel,
id: "tab-panel-#{value}",
"aria-labelledby": "tab-#{value}",
style: %{
padding: 16
}
},
children: children
}
end
defp register_outside_click_listener(_handler) do
# This would integrate with the actual event system
:ok
end
defp unregister_outside_click_listener(_handler) do
# This would integrate with the actual event system
:ok
end
end