How can I specify some fallback content for slots in my LV components?

If component supports slot, but it is optional, and in case no slot is provided, component should show some default html

<%= if @trigger[:inner_block] do %>
  <%= render_slot([@trigger], assigns_to_attributes(@trigger, [:__slot__, :inner_block])) %>
<% else %>
  <.dropdown_trigger {@trigger}/>
<% end %>

I don’t like that I have to have those conditionals in ~H"""""" to check if is slot render it if not render different component.

How can I before ~H"""""" do those checks and prepare fallback slot as if it was passed?

So in ~H"""""" I could write

<%= render_slot([@trigger], assigns_to_attributes(@trigger, [:__slot__, :inner_block])) %>

Each slot map contains :__slot__ (which i presume is only name, and has no actual logic surounding it) and there is :inner_block which is function with arity 2.

How can I mimic this function or where I can find what its contents is? I tried to dig into live view source code, but couldn’t grasp what I need to look for.

P.S.
Maybe you have some strong arguments against it, some performance penalties or smth that im not aware of?

1 Like

It’s not exactly clear what you want from your example, but you can check the contents of inner_block. Slots are always lists, so if it’s empty, then the user didn’t provide one. You can do an if/else with two different ~H’s or do two separate function clauses:

slot :inner_block
def my_component(%{inner_block: []} = assigns) do
  ~H"""
  This is the fallback for <.my_component />
  """
end

def my_component(%{inner_block: _} = assigns) do
  ~H"""
  <%= render_slot(@inner_block) %>
  """
end
1 Like

The way I understood the question is: “how can I specify some fallback content for slots in my LV components?”. The LV documentation doesn’t mention it. Other frameworks explicitly provide a method to define fallback content for slots. For example, this is how Vue.js handles it. I think the documentation should talk about this and show an example of how to do it.

1 Like

@trisolaran Document slot fallback · phoenixframework/phoenix_live_view@7f6780b · GitHub :slight_smile:

7 Likes

Thanks @josevalim :+1:

1 Like

Thanks, will try with slot fallback.

@chrismccord thanks for suggestion and i tried it, but it falls short once component has multiple slots that all have to have fallback logic (becomes exponentially complex with each new fallback slot) or there just is too much other stuff in ~H"""""" so there would be too much duplication (one could extract it to common/generic parts in separate component)

You can check if the assign is an empty list within the template as well, instead of doing it in the function head. For slots other than inner_block make sure to assign_new them to be optional.

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>

Its alive
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.

I’m not following entirely your goals, but I think you’re over complicating it :slight_smile: . The __changed__ and __slot__ and such are all private, so you never want to base things on those. In general, when you want to render a component, you should use the <. call syntax. So if you need to render a component with assigns as fallback, then do so directly by rendering the tag based form. It’s not clear what attributes you want to pass down to the header and footer fallback, but it sounds like you want to support all arbitrary HTML attrs, in which case we have a :global attr type for that. I believe this is what you want:

defmodule Components.TestComponent do
  use ComponentsWeb, :live_component

  slot :header
  slot :inner_block, required: true
  slot :footer
  attr :rest, :global

  def modal(assigns) do
    ~H"""
    <div class="modal">
      <div class="modal-header">
        <%= if @header != [] do %>
          <%= render_slot(@header) %>
        <% else %>
          <.modal_header {@rest}/>
        <% end %>
      </div>
      <div class="modal-body">
        <%= render_slot(@inner_block) %>
      </div>
      <div class="modal-footer">
        <%= render_slot(@footer) %>
      </div>
    </div>
    """
  end

  attr :class, :string, default: "red"
  attr :rest, :global
  def modal_header(assigns) do
    ~H"""
    <span class={@class} {@rest}>Modal title</span>
    """
  end

  attr :rest, :global
  def modal_footer(assigns) do
    ~H"""
    <span {@rest}>Modal footer</span>
    """
  end
end
1 Like

There are two parts to this:

One: some errors/oddities/documentation typos that led to confusion on my part and confusion on your part what exactly im trying to understand.

<div class="modal-footer">
  <%= render_slot(@footer) || submit_button() %> <--- this line
</div>

If fallowing documentation just for consistency sake one would need to at least pass %{__changed__: nil} to the submit_button(). But i get you, one shouldn’t call those components directly, but with <. notation.

  • slots without body will raise RuntimeError when try to render them.

So some errors/misconceptions arise from these on my side. I will fix my code accordingly.

And than post part two :smiley:

Bottom line tho: i would greatly appreciate if you could point me to where inner_block callback function is generated.

I think there is miscomunication. As i try to simplify as much my problem when describing it. And you looking at simples possible way i can represent it, and find simpler solution for it. For simplest representation. But it does not scale up once i want to apply to more complex problem.

There will be ugly ass code. I know one should use assign_new/assign and so on, not just declare variables and then use them directly in ~H"""""". Just for clarity and ability to see all code as a whole. Down below I will explicitly describe relevant code bits.

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)

    footer =
      assigns
      |> Map.get(:footer, [])
      |> then(fn
        [%{inner_block: nil} = params] ->
          params
          |> Map.drop([:__slot__, :inner_block])
          |> Map.put(:__changed__, nil)

        [footer_slot] ->
          footer_slot

        [] ->
          %{__changed__: nil}
      end)

    header_contents = "here goes possibly some complex logic how header contents is determined"
    footer_contents = "here goes possibly some complex logic how footer contents is determined"

    ~H"""
    <div class="modal">
      <div class="modal-header">
        <%= if header[:inner_block] do %>
          <%= render_slot([header]) %>
        <% else %>
          <.modal_header {header}>
            <%= header_contents %>
          </.modal_header>
        <% end %>
      </div>
      <div class="modal-body">
        <%= render_slot(@inner_block) %>
      </div>
      <div class="modal-footer">
        <%= if footer[:inner_block] do %>
          <%= render_slot([footer]) %>
        <% else %>
          <.modal_footer {footer}>
            <%= footer_contents %>
          </.modal_footer>
        <% end %>
      </div>
    </div>
    """
  end

  slot(:inner_block)
  attr :class, :string, default: "red"
  attr :rest, :global

  def modal_header(assigns) do
    ~H"""
      <span class={@class} {@rest}><%= render_slot(@inner_block) || "Header here" %></span>
    """
  end

  slot(:inner_block)
  attr :class, :string, default: "green"
  attr :rest, :global

  def modal_footer(assigns) do
    ~H"""
      <span class={@class} {@rest}><%= render_slot(@inner_block) || "Footer here" %></span>
    """
  end
end

Modal has logic how to determine what to show in header and footer. Those two lines.

header_contents = "here goes possibly some complex logic how header contents is determined"
footer_contents = "here goes possibly some complex logic how footer contents is determined"

Both header and footer are optional slots that work like this:
If no slot passed, it will use default fallback component (modal_header/modal_footer respectively).
If slot without body is passed it will take its arguments and pass them to default fallback component.
if slot with body is passed it will overwrite default fallback component.

How it is done prior ~H"""""" is irrelevant for current argument.

<%= if header[:inner_block] do %>
  <%= render_slot([header]) %>
<% else %>
  <.modal_header {header}>
    <%= header_contents %>
  </.modal_header>
<% end %>

Analog part in your code snippet:

<%= if @header != [] do %>
  <%= render_slot(@header) %>
<% else %>
  <.modal_header {@rest}/>
<% end %>

With your code tho it works like this:
If no slot passed, it will use default fallback component (modal_header/modal_footer respectively). (same as my)

if slot with body is passed it will overwrite default fallback component. (same as my)

Differences start here:
If slot without body is passed it will take its arguments and pass them to default fallback component.
In your case it will result in RuntimeError.

you can say:

  • “But it will pass down class/most attributes to the sub components by passing it to the parent” (i extended your example. Made footer slot work the same as header slot)
    My counterargument:
    it’s not enough granularity: I can’t pass different class to header and footer components. On class attribute that will be propagated down to footer and header. in that case i need to create footer_class, and header_class attributes, that i already explained why i find wrong in my previous long-ass post.

  • “But you can use slot with body to pass custom attributes/class to slot”.

<Components.TestComponent2.modal>
  <:header>
    <Components.TestComponent2.modal_header class="custom class" {some_other_header_attributes_if_necesary}>
    here goes possibly some complex logic how header contents is determined
    </Components.TestComponent2.modal_header>
  </:header>

  Body
</Components.TestComponent2.modal>

My problem with that is that now i need to reproduce logic from modal. In this case
header_contents = "here goes possibly some complex logic how header contents is determined"

If its simple then yeah i can just copy paste, but if it some complex tedious stuff there? If there is some private function in modal module, that i don’t have access to even reproduce the same thing? Ok, one can copy over and make it work. Yes one can. But then it is just question of time till there start to creep inconsistencies, because one code part will be updated but others wont. Ok correct procedure would be extract this super complex logic in separate place where both context can get it (i know, but real life not always is so shiny).

your example, which currently i assume is also live view default approach is like have boolean states, either slot is passed, or it isn’t. Either developer does not change anything, or it have to maintain his own custom implementation, even if only once class is the difference maker.

Im telling that there is this missing middle ground of customising default fallback for slot.
Question is how to pass params to this slot default fallback. I also explained why i don’t like header slot and header_params, but i think ideologically for many developers that pill would be easier to swallow.

i think from usage standpoint my approach is pretty nice/readable. From insides of component it complete mess tho.

I think I understand what you’re wanting now a bit better. I’m not sure if the scenario where you want to render the fallback on both missing slot and blockless slot is a pattern that LV needs to address, but you can handle succinctly with basic primitives, or you can make a higher level fallback component if this is a pattern you used frequently. My example does the basic if/for for the header, and for the footer shows how you could do the fallback its own component if you consider the former too much duplication. I would honestly go with the if/for, but I haven’t needed this kind of thing in my own apps so if you’re doing this all over the place a fallback component could work well:

  # <.modal>
  #   <:header class="the-header">This the header</:header>
  #   This is the body
  #   <:footer class="the-footer"/>
  # </.modal>
  slot :inner_block, rquired: true
  slot :header
  slot :footer
  def modal(assigns) do
    ~H"""
    <div id="modal">
      <div id="header">
        <%= if @header == [] do %>
          <.modal_header />
        <% end %>
        <%= for header <- @header do %>
          <%= if header[:inner_block] do %>
            <%= render_slot(header) %>
          <% else %>
            <.modal_header {header} />
          <% end %>
        <% end %>
      </div>
      <%= render_slot(@inner_block) %>
      <div id="footer">
        <.fallback slot={@footer}>
          <:blockless :let={attrs}><.modal_footer {attrs} /></:blockless>
          <.modal_footer />
        </.fallback>
      </div>
    </div>
    """
  end

  def modal_header(assigns) do
    ~H"""
    fallback header <%= inspect(assigns) %>
    """
  end

  def modal_footer(assigns) do
    ~H"""
    fallback footer <%= inspect(assigns) %>
    """
  end

  attr :slot, :any
  slot :innter_block
  slot :blockless

  def fallback(assigns) do
    ~H"""
    <%= if @slot == [] do %>
      <%= render_slot(@inner_block) %>
    <% end %>
    <%= for slot <- @slot do %>
      <%= if slot[:inner_block] do %>
        <%= render_slot(slot) %>
      <% else %>
        <%= render_slot(@blockless, slot) %>
      <% end %>
    <% end %>
    """
  end
2 Likes