Current section

Files

Jump to
petal_components lib petal_components accordion.ex
Raw

lib/petal_components/accordion.ex

defmodule PetalComponents.Accordion do
use Phoenix.Component
alias Phoenix.LiveView.JS
import PetalComponents.Icon
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", "ghost"])
attr(:js_lib, :string,
default: PetalComponents.default_js_lib(),
values: ["alpine_js", "live_view_js"],
doc: "JavaScript library used for toggling"
)
attr(:open_index, :integer, default: nil, doc: "Index of item to be open at render")
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)
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,
if(@variant == "ghost", do: "pc-accordion--ghost")
]}
{@rest}
{js_attributes("container", @js_lib, @container_id, nil, nil, @open_index, @variant)}
>
<%= for {current_item, i} <- Enum.with_index(@item) do %>
<% is_open = i == @open_index %>
<div
{js_attributes("item", @js_lib, @container_id, i, length(@item), is_open, @variant)}
data-i={i}
data-open={if is_open, do: "true", else: "false"}
class={if(@variant == "ghost", do: "pc-accordion-item--ghost")}
>
<h2 id={content_panel_header_id(@container_id, i)}>
<button
type="button"
{js_attributes("button", @js_lib, @container_id, i, length(@item), is_open, @variant)}
class={
if @variant == "ghost" do
"pc-accordion-item__button--ghost"
else
[
"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 @js_lib == "live_view_js" and !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"
x-cloak={@js_lib == "alpine_js"}
data-js-loading={@js_lib == "live_view_js"}
{js_attributes("icon", @js_lib, @container_id, i, length(@item), is_open, @variant)}
/>
<.icon
name="hero-minus-mini"
class="pc-accordion-item__minus"
x-cloak={@js_lib == "alpine_js"}
data-js-loading={@js_lib == "live_view_js"}
{js_attributes("icon_minus", @js_lib, @container_id, i, length(@item), is_open, @variant)}
/>
</span>
<% else %>
<.icon
name="hero-chevron-down-solid"
class={["pc-accordion-item__chevron", if(is_open, do: "rotate-180")]}
{js_attributes("icon", @js_lib, @container_id, i, length(@item), is_open, @variant)}
/>
<% end %>
</button>
</h2>
<div
{js_attributes("content_container", @js_lib, @container_id, i, length(@item), is_open, @variant)}
class="accordion-content-container"
>
<div class={
if(@variant == "ghost",
do: "pc-accordion-item__content--ghost",
else: [
"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"
)
]
)
}>
<%= render_slot(current_item, current_item.entry) %>
</div>
</div>
</div>
<% end %>
</div>
<script>
window.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[data-js-loading]').forEach(el => {
el.removeAttribute('data-js-loading');
});
});
window.addEventListener("click_accordion", e => {
let i = e.detail.index;
let l = e.detail.length
let clickedAccordionItem = e.target;
let currentlyOpenAccordionItem = document.querySelector("[data-open='true']")
let isClosingClickedAccordionItem = !!currentlyOpenAccordionItem && currentlyOpenAccordionItem == clickedAccordionItem;
let isLastAccordionItem = i == l - 1;
let isGhostVariant = clickedAccordionItem.closest("[id^='accordion_']").classList.contains("pc-accordion--ghost");
// Close open accordion item
if(currentlyOpenAccordionItem) {
currentlyOpenAccordionItem.dataset.open = false;
if (isGhostVariant) {
let plusIcon = currentlyOpenAccordionItem.querySelector(".pc-accordion-item__plus");
let minusIcon = currentlyOpenAccordionItem.querySelector(".pc-accordion-item__minus");
if (plusIcon && minusIcon) {
plusIcon.classList.remove("hidden");
minusIcon.classList.add("hidden");
}
} else {
currentlyOpenAccordionItem.querySelector("span.hero-chevron-down-solid").classList.remove("rotate-180");
currentlyOpenAccordionItem.querySelector(`.accordion-button`).classList.remove("pc-accordion-item__content-container--highlight-accordion-button-on-expanded-js-attributes");
if(isLastAccordionItem){
clickedAccordionItem.querySelector(`.accordion-button`).classList.add("pc-accordion-item--last--closed");
}
}
currentlyOpenAccordionItem.querySelector(`.accordion-content-container`).style.display = "none";
}
// Open clicked accordion item (if not already open)
if (!isClosingClickedAccordionItem) {
clickedAccordionItem.dataset.open = true;
if (isGhostVariant) {
let plusIcon = clickedAccordionItem.querySelector(".pc-accordion-item__plus");
let minusIcon = clickedAccordionItem.querySelector(".pc-accordion-item__minus");
if (plusIcon && minusIcon) {
plusIcon.classList.add("hidden");
minusIcon.classList.remove("hidden");
}
} else {
clickedAccordionItem.querySelector("span.hero-chevron-down-solid").classList.add("rotate-180");
clickedAccordionItem.querySelector(`.accordion-button`).classList.add("pc-accordion-item__content-container--highlight-accordion-button-on-expanded-js-attributes");
if(isLastAccordionItem){
clickedAccordionItem.querySelector(`.accordion-button`).classList.remove("pc-accordion-item--last--closed");
}
}
clickedAccordionItem.querySelector(`.accordion-content-container`).style.display = "block";
}
})
</script>
"""
end
defp js_attributes(type, js_lib, container_id, i, l, open, variant) do
case {type, js_lib} do
{"container", "alpine_js"} ->
%{"x-data": "{ active: #{open || "null"}, isGhost: #{variant == "ghost"} }"}
{"item", "alpine_js"} ->
%{
"x-data": "{
id: #{i},
get expanded() {
return this.active === this.id
},
set expanded(value) {
this.active = value ? this.id : null
},
}"
}
{"button", "alpine_js"} when i == l - 1 ->
if variant == "ghost" do
%{
"x-on:click": "expanded = !expanded",
":aria-expanded": "expanded",
"aria-controls": content_panel_id(container_id, i)
}
else
%{
"x-on:click": "expanded = !expanded",
":class":
"expanded ? 'pc-accordion-item__content-container--highlight-accordion-button-on-expanded-js-attributes' : 'pc-accordion-item--last--closed'",
":aria-expanded": "expanded",
"aria-controls": content_panel_id(container_id, i)
}
end
{"button", "alpine_js"} ->
if variant == "ghost" do
%{
"x-on:click": "expanded = !expanded",
":aria-expanded": "expanded",
"aria-controls": content_panel_id(container_id, i)
}
else
%{
"x-on:click": "expanded = !expanded",
":class":
"expanded ? 'pc-accordion-item__content-container--highlight-accordion-button-on-expanded-js-attributes' : ''",
":aria-expanded": "expanded",
"aria-controls": content_panel_id(container_id, i)
}
end
{"content_container", "alpine_js"} ->
%{
id: content_panel_id(container_id, i),
role: "region",
"aria-labelledby": content_panel_header_id(container_id, i),
"x-show": "expanded",
"x-cloak": true,
"x-collapse": true
}
{"icon", "alpine_js"} ->
if variant == "ghost" do
%{
"x-show": "!expanded"
}
else
%{
":class": "{ 'rotate-180': expanded }"
}
end
{"icon_minus", "alpine_js"} ->
%{
"x-show": "expanded"
}
{"container", "live_view_js"} ->
%{}
{"item", "live_view_js"} ->
%{}
{"button", "live_view_js"} ->
if variant == "ghost" do
%{
"phx-click":
JS.dispatch("click_accordion",
to: "##{container_id} [data-i='#{i}']",
detail: %{container_id: container_id, index: i, length: l, variant: "ghost"}
),
"aria-controls": content_panel_id(container_id, i),
"aria-expanded": "#{open}"
}
else
%{
"phx-click":
JS.dispatch("click_accordion",
to: "##{container_id} [data-i='#{i}']",
detail: %{container_id: container_id, index: i, length: l}
),
"aria-controls": content_panel_id(container_id, i),
"aria-expanded": "#{open}"
}
end
{"content_container", "live_view_js"} ->
%{
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", "live_view_js"} ->
if variant == "ghost" do
%{
class: if(open, do: "hidden")
}
else
%{
class: if(open, do: "rotate-180")
}
end
{"icon_minus", "live_view_js"} ->
%{
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