Packages
petal_components
4.8.1
4.8.1
4.8.0
4.7.0
4.6.2
4.6.1
4.6.0
4.5.0
4.4.0
4.3.0
4.2.1
4.2.0
4.1.2
4.1.1
4.1.0
4.0.12
4.0.11
4.0.10
4.0.6
4.0.5
4.0.4
4.0.3
4.0.1
3.2.2
3.2.1
3.2.0
3.1.0
3.0.2
3.0.1
3.0.0
2.9.3
2.9.2
retired
2.9.1
retired
2.9.0
retired
2.8.4
2.8.3
2.8.2
2.8.1
2.8.0
2.7.4
2.7.3
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.2
2.5.1
2.5.0
2.4.3
2.4.2
2.4.1
2.4.0
2.3.0
2.2.1
2.2.0
2.1.2
2.1.1
2.1.0
2.0.6
2.0.5
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
1.9.3
1.9.2
1.9.1
1.9.0
1.8.0
1.7.1
1.7.0
1.6.2
1.6.1
1.6.0
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.0
1.2.14
1.2.13
1.2.12
1.2.11
1.2.10
1.2.9
1.2.8
1.2.7
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.6
1.1.5
1.1.4
1.1.3
1.1.2
1.1.1
1.1.0
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.19.10
0.19.9
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.7
0.17.6
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.0
0.15.0
0.14.1
0.14.0
0.13.7
0.13.6
0.13.5
0.13.4
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.8
0.10.7
0.10.6
0.10.5
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.1
0.2.0
0.1.0
Shadcn-style Phoenix LiveView components that AI assistants can actually use. Pair with the MCP server so AI coding tools can inspect the real component API.
Current section
Files
Jump to
Current section
Files
lib/petal_components/accordion.ex
defmodule PetalComponents.Accordion do
use Phoenix.Component
alias Phoenix.LiveView.JS
import PetalComponents.Icon
import PetalComponents.Helpers, only: [compose_js: 2]
attr(:container_id, :string)
attr(:class, :any, default: nil, doc: "CSS class for parent container")
attr(:entries, :list, default: [%{}])
attr(:variant, :string,
default: "default",
values: ["default", "bordered", "ghost"],
doc:
"default is the shadcn row style (hairline dividers, no boxes); bordered is the boxed card-accordion. ghost is a legacy alias of default (identical look) slated for removal in a future major"
)
attr(:size, :string,
default: "md",
values: ["sm", "md"],
doc: "sm tightens paddings and type for dense UIs (settings panels, sidebars)"
)
attr(:open_index, :integer, default: nil, doc: "Index of item to be open at render")
attr(:multiple, :boolean,
default: false,
doc: "When true, multiple sections can be expanded simultaneously"
)
attr :on_toggle, JS,
default: %JS{},
doc: "additional JS commands to run when an accordion section is toggled (LiveView.JS only)"
attr(:rest, :global)
slot :item, required: true, doc: "CSS class for parent container" do
attr(:heading, :string)
end
def accordion(assigns) do
assigns =
assigns
|> assign_new(:container_id, fn -> "accordion_#{Ecto.UUID.generate()}" end)
# ghost became visually indistinguishable from the row default - alias it
|> then(&if &1.variant == "ghost", do: assign(&1, :variant, "default"), else: &1)
item =
for entry <- assigns.entries, item <- assigns.item do
item_heading = Map.get(item, :heading)
entry_heading = Map.get(entry, :heading)
if item_heading && entry_heading do
raise ArgumentError, "specify heading in either :item or :entries"
end
heading = item_heading || entry_heading
item
|> Map.put(:heading, heading)
|> Map.put(:entry, entry)
end
assigns = assign(assigns, :item, item)
~H"""
<div
id={@container_id}
class={[
@class,
@size == "sm" && "pc-accordion--sm",
@variant == "ghost" && "pc-accordion--ghost",
@variant == "default" && "pc-accordion--rows"
]}
{@rest}
{js_attributes("container", @container_id, nil, nil, @open_index, @variant, @multiple)}
>
<%= for {current_item, i} <- Enum.with_index(@item) do %>
<% is_open = i == @open_index %>
<div
{js_attributes("item", @container_id, i, length(@item), is_open, @variant, @multiple)}
data-i={i}
data-open={if is_open, do: "true", else: "false"}
class={
case @variant do
"ghost" -> "pc-accordion-item--ghost"
"default" -> "pc-accordion-row"
_ -> nil
end
}
>
<h2 id={content_panel_header_id(@container_id, i)}>
<button
type="button"
{js_attributes("button", @container_id, i, length(@item), is_open, @variant, @multiple, @on_toggle)}
class={
case @variant do
"ghost" ->
"pc-accordion-item__button--ghost"
"default" ->
"pc-accordion-item--row accordion-button"
_ ->
[
"pc-accordion-item accordion-button",
if(i == 0, do: "pc-accordion-item--first"),
unless(i == length(@item) - 1, do: "pc-accordion-item--all-except-last"),
if(i == length(@item) - 1,
do:
"pc-accordion-item--last #{if !is_open, do: "pc-accordion-item--last--closed"}"
)
]
end
}
>
<span class={
if(@variant == "ghost",
do: "pc-accordion-item__heading--ghost",
else: "pc-accordion-item__heading"
)
}>
{current_item.heading}
</span>
<%= if @variant == "ghost" do %>
<span class="pc-accordion-item__icon-container--ghost">
<.icon
name="hero-plus-mini"
class={["pc-accordion-item__plus", if(is_open, do: "hidden")]}
/>
<.icon
name="hero-minus-mini"
class={["pc-accordion-item__minus", if(is_open, do: nil, else: "hidden")]}
/>
</span>
<% else %>
<.icon
name="hero-chevron-down-solid"
class={["pc-accordion-item__chevron", if(is_open, do: "rotate-180")]}
{js_attributes("icon", @container_id, i, length(@item), is_open, @variant, @multiple)}
/>
<% end %>
</button>
</h2>
<div
{js_attributes("content_container", @container_id, i, length(@item), is_open, @variant, @multiple)}
class="accordion-content-container"
>
<div class={
case @variant do
"ghost" ->
"pc-accordion-item__content--ghost"
"default" ->
"pc-accordion-item__content--row"
_ ->
[
"pc-accordion-item__content-container",
if(i == length(@item) - 1,
do: "pc-accordion-item__content-container--last",
else: "pc-accordion-item__content-container--not-last"
)
]
end
}>
{render_slot(current_item, current_item.entry)}
</div>
</div>
</div>
<% end %>
</div>
"""
end
defp js_attributes(type, container_id, i, l, open, variant, multiple, on_toggle \\ %JS{}) do
case type do
"container" ->
%{"phx-update": "ignore"}
"item" ->
%{}
"button" ->
detail =
%{container_id: container_id, index: i, length: l}
|> then(fn d -> if variant == "ghost", do: Map.put(d, :variant, "ghost"), else: d end)
|> then(fn d -> if multiple, do: Map.put(d, :multiple, true), else: d end)
click =
compose_js(
on_toggle,
JS.dispatch("click_accordion",
to: "##{container_id} [data-i='#{i}']",
detail: detail
)
)
%{
"phx-click": click,
"aria-controls": content_panel_id(container_id, i),
"aria-expanded": "#{open}"
}
"content_container" ->
%{
id: content_panel_id(container_id, i),
role: "region",
"aria-labelledby": content_panel_header_id(container_id, i),
style: if(open, do: "display: block;", else: "display: none;")
}
"icon" ->
if variant == "ghost" do
%{class: if(open, do: "hidden")}
else
%{class: if(open, do: "rotate-180")}
end
"icon_minus" ->
%{class: if(open, do: "", else: "hidden")}
_ ->
%{}
end
end
defp content_panel_header_id(container_id, idx) do
"acc-header-#{container_id}-#{idx}"
end
defp content_panel_id(container_id, idx) do
"acc-content-panel-#{container_id}-#{idx}"
end
end