Modal component loosely based on this docs example (at the end is full final modal componenet):
# Commit 1
defmodule Components.TestComponent do
use ComponentsWeb, :live_component
slot(:header)
slot(:inner_block, required: true)
slot(:footer)
def modal(assigns) do
~H"""
<div class="modal">
<div class="modal-header">
<%= render_slot(@header) %>
</div>
<div class="modal-body">
<%= render_slot(@inner_block) %>
</div>
<div class="modal-footer">
<%= render_slot(@footer) %>
</div>
</div>
"""
end
def modal_header(assigns) do
~H"""
<span {assigns_to_attributes(assigns)}>Modal title</span>
"""
end
def modal_footer(assigns) do
~H"""
<span {assigns_to_attributes(assigns)}>Modal footer</span>
"""
end
end
Usage:
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Output;
<div class="modal">
<div class="modal-header">
</div>
<div class="modal-body">
Some some
</div>
<div class="modal-footer">
</div>
</div>
as you can see there are fallback value, as it wasn’t provided. Lets provide it as in docs:
# Commit 2
- <%= render_slot(@header) %>
+ <%= render_slot(@header) || modal_header() %>
Will result in CompileError
: undefined function modal_header/0
Its easy fix tho (lets pass empty assigns):
# Commit 3
- <%= render_slot(@header) || modal_header() %>
+ <%= render_slot(@header) || modal_header(%{}) %>
Let’s call the same elixir code:
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Output:
<div class="modal">
<div class="modal-header">
<span>Modal title</span>
</div>
...
</div>

Sanity check actually provide header slot
<Components.TestComponent.modal>
<:header>
Here goes custom header
</:header>
Some some
</Components.TestComponent.modal>
Output:
<div class="modal">
<div class="modal-header">
Here goes custom header
</div>
...
</div>
Let’s check header assigns
# Commit 4
def modal_header(assigns) do
+ IO.inspect(assigns)
~H"""
<span {assigns_to_attributes(assigns)}>Modal title</span>
"""
end
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Console output is: %{}
# Commit 5
- <%= render_slot(@header) || modal_header(%{}) %>
+ <%= if @header != [] do %>
+ <%= render_slot(@header) %>
+ <% else %>
+ <.modal_header/>
+ <% end %>
Now calling the same elixir code as previous example:
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Console output is: %{__changed__: nil}
.
It seems like small change, and i can’t grasp all edge cases it creates, but here one:
# Commit 6
def modal_header(assigns) do
IO.inspect(assigns)
+ assigns = assign_new(assigns, :class, fn -> "red" end)
~H"""
<span {assigns_to_attributes(assigns)}>Modal title</span>
"""
end
Now if we use commit 5
slot rendering everything works fine:
# Slot rendering part:
<%= if @header != [] do %>
<%= render_slot(@header) %>
<% else %>
<.modal_header/>
<% end %>
# Call code:
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Output:
<div class="modal">
<div class="modal-header">
<span class="red">Modal title</span>
</div>
...
</div>
But if we use commit 3
slot rendering…
# Slot rendering part:
<%= render_slot(@header) || modal_header(%{}) %>
# Call code:
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
We get ArgumentError
: assign_new/3 expects a socket from Phoenix.LiveView/Phoenix.LiveComponent or an assigns map from Phoenix.Component as first argument, got: %{}
. assign_new/3
needs map that has __changed__
key.
# Commit 7
+ <%= render_slot(@header) || modal_header(%{__changed__: nil}) %>
- <%= if @header != [] do %>
- <%= render_slot(@header) %>
- <% else %>
- <.modal_header/>
- <% end %>
With slot rendered like this where in fallback case component is called with map with __changed__
. It works fine:
Output:
<div class="modal">
<div class="modal-header">
<span class="red">Modal title</span>
</div>
...
</div>
It isn’t mayor breaking bug, but passing explicitly __changed__
seems like internal implementations are spilling out a little bit.
Passing data to subcomponent
Currently header assigns_new (if no value presented) class “red”. But if i want to pass to it class “green”?
First naive aproach:
# Commit 8
+ attr :slot_id, :string
+ attr :slot_class, :string
---
- <%= render_slot(@header) || modal_header(%{__changed__: nil}) %>
+ <%= render_slot(@header) || modal_header(%{__changed__: nil, id: @header_id, class: @header_class}) %>
Call:
<Components.TestComponent.modal header_id="id" header_class="green">
Some some
</Components.TestComponent.modal>
Output:
<div class="modal">
<div class="modal-header">
<span class="green" id="5">Modal title</span>
</div>
...
</div>
But if we call modal without header_id
and header_class
there will be fatal error:
<Components.TestComponent.modal header_id="id" header_class="green">
Some some
</Components.TestComponent.modal>
Output:
KeyError
: key :header_id not found in
Eazy fix:
# Commit 9
- <%= render_slot(@header) || modal_header(%{__changed__: nil, id: @header_id, class: @header_class}) %>
+ <%= render_slot(@header) || modal_header(%{__changed__: nil, id: assigns[:header_id], class: assigns[:header_class]}) %>
Lets try it:
```elixir
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Output:
<div class="modal">
<div class="modal-header">
<span>Modal title</span>
</div>
</div>
Hm, where is class?
If we look at modal_header assigs we can see %{__changed__: nil, class: nil, id: nil}
So assign_new does not assign class red, as there already is class nil. Either in receive or call context we should add filter out empty?
Maybe there is better way?
How about this:
# commit 10
- attr :slot_id, :string
- attr :slot_class, :string
+ attrs, :header_params, :map, default: %{}
---
- <%= render_slot(@header) || modal_header(%{__changed__: nil, id: assigns[:header_id], class: assigns[:header_class]}) %>
+ <%= render_slot(@header) || modal_header(Map.put(@header_params, :__changed__, nil)) %>
Lets test this
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Output:
<div class="modal">
<div class="modal-header">
<span class="red">Modal title</span>
</div>
</div>
Class red is back. Can i pass custom class tho?
<Components.TestComponent.modal header_params={%{class: "green"}}>
Some some
</Components.TestComponent.modal>
Output:
<div class="modal">
<div class="modal-header">
<span class="green">Modal title</span>
</div>
</div>
Nice.
Here will be the dragons (sortof kinda, not realy)
Modal component needs to have both
slot(:header)
attr(:header, :map, default: %{})
for all functionality to be available.
Previous examples:
If
- i want to default header, i ignore header, header_params
- i want customize default header, i pass header_params attribute (with some boilerplate with
__changed__
, which i assume will be solved at live view lib level at some point)
- i want to overwrite component completely i pass header.
But, but, but:
# commit 11
def modal(assigns) do
+ IO.inspect(assigns)
~H"""
<div class="modal">
<Components.TestComponent.modal>
<:header class="green"/>
Some some
</Components.TestComponent.modal>
modal componenet receives:
%{
__changed__: nil,
footer: [],
header: [%{__slot__: :header, class: "green", inner_block: nil}],
inner_block: [
%{
__slot__: :inner_block,
inner_block: #Function<1.33592677/2 in ComponentsWeb.Live.ComponentsDashbaord.render/1>
}
]
}
Output thos is an RuntimeError
: attempted to render slot <:header> but the slot has no inner content. This probably should jsut return nil as well. IMHO.
This part is what im interested in:
header: [%{__slot__: :header, class: "green", inner_block: nil}],
First, fact that it is a list makes it somewhat awkward to work with.
But i can check if :inner_block
is nil
to determine to render_slot, or use it as params to.
Something like:
# commit 12
- attrs, :header_params, :map, default: %{}
slot(:header)
slot(:inner_block, required: true)
slot(:footer)
def modal(assigns) do
- IO.inspect(assigns)
+ header =
+ assigns
+ |> Map.get(:header, [])
+ |> then(fn
+ # If slot with without body passed
+ [%{inner_block: nil} = params] ->
+ params
+ |> Map.drop([:__slot__, :inner_block])
+ |> Map.put(:__changed__, nil)
+
+ # If slot with body passed
+ [header_slot] ->
+ header_slot
+ # If nothing passed
+ [] ->
+ %{__changed__: nil}
+ end)
~H"""
<div class="modal">
<div class="modal-header">
- <%= render_slot(@header) || modal_header(Map.put(@header_params, :__changed__, nil)) %>
+ <%= if header[:inner_block], do: render_slot([header]), else: modal_header(header) %>
</div>
"""
end
How it looks in action?
Final version
Call 1: without header slot
<Components.TestComponent.modal>
Some some
</Components.TestComponent.modal>
Output (notice it has default class “red”):
<div class="modal">
<div class="modal-header">
<span class="red">Modal title</span>
</div>
...
</div>
Call 2: with header slot
<Components.TestComponent.modal>
<:header>
Here goes custom header
</:header>
Some some
</Components.TestComponent.modal>
Output: (Notice it has custom header contents)
<div class="modal">
<div class="modal-header">
Here goes custom header
</div>
...
</div>
Call 3: with slot, but without body
<Components.TestComponent.modal>
<:header class="custom colors" />
Some some
</Components.TestComponent.modal>
Output: (notice custom classes have been passed down to next componenet)
<div class="modal">
<div class="modal-header">
<span class="custom colors">Modal title</span>
</div>
Final thoughts:
I don’t like exactly how i need to check if slot has inner body, and there most likely is more optimal way of doing it. This is more like crude sketch of an idea.
But this creates straight forward way (at least for me) how to customize component.
Can use default slot, don’t pass it.
Need to customize default slot. Pass slot without body.
Need to fully rewrite it, pass slot with body.
More complex usage might look like (this is not implemented in actual modal, but i think at this point it is clear how it can be extended to this)
<Components.TestComponent.modal action="here goes form action" changeset={changeset} :let={form}>
<:header class="regsiter-form vip dark" text="Register to the vip section" />
<:footer class="dark" submit-button-text="Register" />
# Here goes form inputs for register form
</Components.TestComponent.modal>
Recap of bugs if i may say so:
- documentation error (render_slot(@slot) || alternative_component())
- need to know about
__changed__
- render_slot with inner_body nil results in RuntimeError
- i personally think that many use cases not only need just one slot, but require, that there aren’t passed two modal headers for example. Maybe while defining slot
slot(:header)
there could be option, limit: 1
or smth like that.
Final code:
defmodule Components.TestComponent do
use ComponentsWeb, :live_component
slot(:header)
slot(:inner_block, required: true)
slot(:footer)
def modal(assigns) do
header =
assigns
|> Map.get(:header, [])
|> then(fn
[%{inner_block: nil} = params] ->
params
|> Map.drop([:__slot__, :inner_block])
|> Map.put(:__changed__, nil)
[header_slot] ->
header_slot
[] ->
%{__changed__: nil}
end)
~H"""
<div class="modal">
<div class="modal-header">
<%= if header[:inner_block], do: render_slot([header]), else: modal_header(header) %>
</div>
<div class="modal-body">
<%= render_slot(@inner_block) %>
</div>
<div class="modal-footer">
<%= render_slot(@footer) %>
</div>
</div>
"""
end
def modal_header(assigns) do
assigns = assign_new(assigns, :class, fn -> "red" end)
~H"""
<span {assigns_to_attributes(assigns)}>Modal title</span>
"""
end
def modal_footer(assigns) do
~H"""
<span {assigns_to_attributes(assigns)}>Modal footer</span>
"""
end
end
P.S. I have spent few hours trying to put it all together :D. I hope there aren’t many typos. I hope it isn’t too verbose. It also should make it more clear why my initial question code example looked a little bit odd.