defmodule Corex.Accordion do
@moduledoc ~S'''
Phoenix implementation of the [Zag.js Accordion](https://zagjs.com/components/react/accordion).
## Anatomy
### Minimal
```heex
<.accordion
class="accordion"
items={
Corex.Content.new([
%{trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit."},
%{trigger: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula."},
%{trigger: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a."}
])
}
/>
```
### With slots
With `items` and `<:indicator>` slot so every panel shares the same indicator markup.
```heex
<.accordion
class="accordion"
items={
Corex.Content.new([
%{trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit."},
%{trigger: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula."},
%{trigger: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a."}
])
}
>
<:indicator>
<.heroicon name="hero-chevron-right" />
```
### Custom slots
With `items`, customize each item using slots with `:let={item}` to access the item and its `meta` data
```heex
<.accordion
class="accordion"
value="lorem"
items={
Corex.Content.new([
%{
value: "lorem",
trigger: "Lorem ipsum dolor sit amet",
content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.",
meta: %{indicator: "hero-arrow-long-right", icon: "hero-chat-bubble-left-right"}
},
%{
trigger: "Duis dictum gravida odio ac pharetra?",
content: "Nullam eget vestibulum ligula, at interdum tellus.",
meta: %{indicator: "hero-chevron-right", icon: "hero-device-phone-mobile"}
},
%{
value: "donec",
trigger: "Donec condimentum ex mi",
content: "Congue molestie ipsum gravida a. Sed ac eros luctus.",
disabled: true,
meta: %{indicator: "hero-chevron-double-right", icon: "hero-phone"}
}
])
}
>
<:trigger :let={item}>
<.heroicon name={item.meta.icon} />{item.trigger}
<:content :let={item}>
{item.content}
<:indicator :let={item}>
<.heroicon name={item.meta.indicator} />
```
### Manual slots
With an empty `items` list, use multiple `:trigger`, `:content`, and optional `:indicator` slots.
Each slot takes a `value` string that ties the three together.
```heex
<.accordion class="accordion" value="lorem">
<:trigger value="lorem">
<.heroicon name="hero-chevron-right" /> Lorem ipsum dolor sit amet
<:content value="lorem">
Consectetur adipiscing elit. Sed sodales ullamcorper tristique.
"""
end
@doc type: :compound
@doc """
Renders the root container for an accordion in compound mode.
Use inside `accordion` compound mode with `:let={ctx}`, wrapping all `accordion_item` components.
"""
attr(:ctx, :map,
required: true,
doc: "The context map yielded by the parent accordion via :let={ctx}"
)
attr(:rest, :global)
slot(:inner_block, required: true)
def accordion_root(assigns) do
root = %Root{
id: assigns.ctx.id,
orientation: assigns.ctx.orientation,
dir: assigns.ctx.dir
}
assigns = assign(assigns, :root, root)
~H"""
{render_slot(@inner_block)}
"""
end
@doc type: :compound
@doc """
Renders an accordion item. Use inside `accordion` compound mode with `:let={ctx}`.
Yields the `%Item{}` struct via `:let` for use in child parts.
"""
attr(:ctx, :map,
required: true,
doc: "The context map yielded by the parent accordion via :let={ctx}"
)
attr(:value, :string, required: true, doc: "The unique value identifying this item")
attr(:disabled, :boolean, default: false, doc: "Whether the item is disabled")
attr(:rest, :global)
slot(:inner_block, required: false)
def accordion_item(assigns) do
item = %Item{
id: assigns.ctx.id,
value: assigns.value,
disabled: assigns.disabled,
values: assigns.ctx.values,
orientation: assigns.ctx.orientation,
dir: assigns.ctx.dir,
animation: Map.get(assigns.ctx, :animation, "instant")
}
assigns = assign(assigns, :item, item)
~H"""
{render_slot(@inner_block, @item)}
"""
end
@doc type: :compound
@doc """
Renders the trigger button for an accordion item.
Use inside `accordion_item` with `:let={item}`, passing the yielded `item` as the `item` attr.
Place `accordion_indicator` inside this component's inner block if needed.
"""
attr(:item, :map, required: true)
attr(:rest, :global)
slot(:inner_block, required: true)
slot(:indicator, required: false)
def accordion_trigger(assigns) do
~H"""
"""
end
@doc type: :compound
@doc """
Renders the indicator for an accordion item.
Use inside `accordion_trigger` inner block, passing the same `item` from `accordion_item`.
"""
attr(:item, :map,
required: true,
doc: "The item struct yielded by accordion_item via :let={item}"
)
attr(:rest, :global)
slot(:inner_block, required: true)
def accordion_indicator(assigns) do
~H"""
{render_slot(@inner_block)}
"""
end
@doc type: :compound
@doc """
Renders the content panel for an accordion item.
Use inside `accordion_item` with `:let={item}`, passing the yielded `item` as the `item` attr.
"""
attr(:item, :map,
required: true,
doc: "The item struct yielded by accordion_item via :let={item}"
)
attr(:animation, :string,
default: nil,
doc:
"Override animation mode; defaults to the parent accordion `animation` from compound ctx."
)
attr(:rest, :global)
slot(:inner_block, required: true)
def accordion_content(assigns) do
animation = assigns.animation || assigns.item.animation
assigns = assign(assigns, :resolved_animation, animation)
~H"""
{render_slot(@inner_block)}
"""
end
@doc type: :component
@doc """
Renders a loading skeleton for the accordion component.
"""
attr(:count, :integer, default: 3)
attr(:rest, :global)
slot :trigger do
attr(:class, :string, required: false)
end
slot :indicator do
attr(:class, :string, required: false)
end
slot :content do
attr(:class, :string, required: false)
end
def accordion_skeleton(assigns) do
~H"""
{render_slot(@trigger)}
{render_slot(@indicator)}
"""
end
@doc type: :api
@doc """
Sets the accordion value from client-side. Returns a `Phoenix.LiveView.JS` command.
Pass a list of open item ids, or a comma-separated binary (trimmed); an empty binary closes all panels.
## Examples
#### From Client Binding
<.action phx-click={Corex.Accordion.set_value("my-accordion", ["lorem"])} class="button button--sm">
Open Lorem
<.action phx-click={Corex.Accordion.set_value("my-accordion", ["lorem", "donec"])} class="button button--sm">
Open Lorem and Donec
<.action phx-click={Corex.Accordion.set_value("my-accordion", [])} class="button button--sm">
Close all
<.accordion
id="my-accordion"
class="accordion"
items={
Corex.Content.new([
%{
value: "lorem",
trigger: "Lorem ipsum dolor sit amet",
content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique."
},
%{
value: "duis",
trigger: "Duis dictum gravida odio ac pharetra?",
content: "Nullam eget vestibulum ligula, at interdum tellus."
},
%{
value: "donec",
trigger: "Donec condimentum ex mi",
content: "Congue molestie ipsum gravida a. Sed ac eros luctus.",
disabled: true
}
])
}
/>
#### From Client JS
```javascript
const el = document.getElementById("my-accordion");
const set = (value) =>
el?.dispatchEvent(
new CustomEvent("corex:accordion:set-value", {
bubbles: false,
detail: { value },
})
);
set(["lorem"]);
set(["lorem", "donec"]);
set([]);
```
"""
def set_value(accordion_id, value) when is_binary(accordion_id) do
JS.dispatch("corex:accordion:set-value",
to: "##{accordion_id}",
detail: %{value: normalize_set_value!(value)},
bubbles: false
)
end
@doc type: :api
@doc """
Sets the accordion value from server-side. Pushes a LiveView event.
## Examples
### Heex
<.action phx-click="open_lorem" class="button button--sm">
Open Lorem
<.action phx-click="open_lorem_and_donec" class="button button--sm">
Open Lorem and Donec
<.action phx-click="close_all" class="button button--sm">
Close all
<.accordion
id="my-accordion"
class="accordion"
items={
Corex.Content.new([
%{
value: "lorem",
trigger: "Lorem ipsum dolor sit amet",
content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique."
},
%{
value: "duis",
trigger: "Duis dictum gravida odio ac pharetra?",
content: "Nullam eget vestibulum ligula, at interdum tellus."
},
%{
value: "donec",
trigger: "Donec condimentum ex mi",
content: "Congue molestie ipsum gravida a. Sed ac eros luctus.",
disabled: true
}
])
}
/>
### Elixir
def handle_event("open_lorem", _params, socket) do
{:noreply, Corex.Accordion.set_value(socket, "my-accordion", ["lorem"])}
end
def handle_event("open_lorem_and_donec", _params, socket) do
{:noreply, Corex.Accordion.set_value(socket, "my-accordion", ["lorem", "donec"])}
end
def handle_event("close_all", _params, socket) do
{:noreply, Corex.Accordion.set_value(socket, "my-accordion", [])}
end
"""
def set_value(socket, accordion_id, value)
when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) do
LiveView.push_event(socket, "accordion_set_value", %{
id: accordion_id,
value: normalize_set_value!(value)
})
end
defp normalize_set_value!(value) when is_list(value), do: validate_value!(value)
defp normalize_set_value!(value) when is_binary(value) do
case String.trim(value) do
"" ->
[]
trimmed ->
trimmed
|> String.split(",", trim: true)
|> validate_value!()
end
end
defp normalize_set_value!(value), do: validate_value!(value)
@doc type: :api
@doc """
Requests the accordion's current open values from the browser. Returns a `Phoenix.LiveView.JS` command.
Options:
- `:respond_to` — `:server` (default, LiveView `accordion_value_response` only), `:both` (also dispatches
`accordion-value`), or `:client` (DOM `accordion-value` only). When `:server` and the LiveView is not connected, nothing is pushed.
The hook pushes `accordion_value_response` when `:respond_to` is `:both` or `:server`, and dispatches
`accordion-value` on the element when `:respond_to` is `:both` or `:client`.
## Examples
#### From Client Binding
<.action phx-click={Corex.Accordion.value("my-accordion")} class="button button--sm">
Value
<.action phx-click={Corex.Accordion.value("my-accordion", respond_to: :client)} class="button button--sm">
Value (client only)
<.accordion id="my-accordion" class="accordion" items={Corex.Content.new([
%{value: "lorem", trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique."},
%{value: "duis", trigger: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus."},
%{value: "donec", trigger: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus."}
])} />
```javascript
const el = document.getElementById("my-accordion");
el?.addEventListener("accordion-value", (e) => console.log(e.detail));
```
#### JS.dispatch
<.action
phx-click={JS.dispatch("corex:accordion:value",
to: "#my-accordion",
detail: %{respond_to: "client"},
bubbles: false
)}
class="button button--sm"
>
Value (JS.dispatch, client only)
"""
def value(accordion_id) when is_binary(accordion_id), do: value(accordion_id, [])
def value(accordion_id, opts) when is_binary(accordion_id) and is_list(opts) do
JS.dispatch("corex:accordion:value",
to: "##{accordion_id}",
detail: respond_to_fields(opts),
bubbles: false
)
end
@doc type: :api
@doc """
Requests the accordion's current open values from the client. Pushes a LiveView event handled by the hook.
See `value/2` for `:respond_to`. The hook pushes `accordion_value_response` and/or dispatches `accordion-value`
accordingly.
## Examples
def handle_event("accordion_value_response", %{"id" => id, "value" => value}, socket) do
{:noreply, assign(socket, :accordion_value, {id, value})}
end
"""
def value(socket, accordion_id, opts \\ [])
when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) and
is_list(opts) do
LiveView.push_event(
socket,
"accordion_value",
Map.merge(%{id: accordion_id}, respond_to_fields(opts))
)
end
@doc type: :api
@doc """
Requests the accordion's focused item value from the browser. Returns a `Phoenix.LiveView.JS` command.
Options: `:respond_to` — `:server` (default, `accordion_focused_response` only), `:both` (also dispatches
`accordion-focused`), or `:client` (`accordion-focused` DOM event only).
## Examples
#### From Client Binding
<.action phx-click={Corex.Accordion.focused("my-accordion")} class="button button--sm">
Focused
<.action phx-click={Corex.Accordion.focused("my-accordion", respond_to: :client)} class="button button--sm">
Focused (client only)
<.accordion id="my-accordion" class="accordion" items={Corex.Content.new([
%{value: "lorem", trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique."},
%{value: "duis", trigger: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus."}
])} />
```javascript
const el = document.getElementById("my-accordion");
el?.addEventListener("accordion-focused", (e) => console.log(e.detail));
```
#### JS.dispatch
<.action
phx-click={JS.dispatch("corex:accordion:focused",
to: "#my-accordion",
detail: %{respond_to: "client"},
bubbles: false
)}
class="button button--sm"
>
Focused (JS.dispatch, client only)
"""
def focused(accordion_id) when is_binary(accordion_id), do: focused(accordion_id, [])
def focused(accordion_id, opts) when is_binary(accordion_id) and is_list(opts) do
JS.dispatch("corex:accordion:focused",
to: "##{accordion_id}",
detail: respond_to_fields(opts),
bubbles: false
)
end
@doc type: :api
@doc """
Requests the accordion's focused item value from the client. Pushes a LiveView event handled by the hook.
See `focused/2` for `:respond_to`.
## Examples
def handle_event("accordion_focused_response", %{"id" => id, "value" => value}, socket) do
{:noreply, assign(socket, :accordion_focused, {id, value})}
end
"""
def focused(socket, accordion_id, opts \\ [])
when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) and
is_list(opts) do
LiveView.push_event(
socket,
"accordion_focused",
Map.merge(%{id: accordion_id}, respond_to_fields(opts))
)
end
@doc type: :api
@doc """
Zag `getItemState` for one item. Pass `value` and keyword `opts` (`:disabled`, `:respond_to`).
- `item_state/2` — `item_state(id, value)`; same as `item_state(id, value, [])`.
- `item_state/3` — client: `item_state(id, value, opts)`. Server: `item_state(socket, id, value)` or `item_state(socket, id, value, opts)`.
The hook receives `value` and `disabled`, calls `getItemState`, pushes `accordion_item_state_response`
and/or dispatches `accordion-item-state` with `detail: %{id, value, state}` according to `:respond_to`.
## Examples
### From Client Binding
<.action phx-click={Corex.Accordion.item_state("my-accordion", "lorem", disabled: false)} class="button button--sm">
item_state("lorem")
<.action phx-click={Corex.Accordion.item_state("my-accordion", "donec", disabled: true)} class="button button--sm">
item_state("donec", disabled)
<.accordion id="my-accordion" class="accordion" items={Corex.Content.new([
%{value: "lorem", trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique."},
%{value: "donec", trigger: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus."}
])} />
### From Client JS
```javascript
const el = document.getElementById("my-accordion");
el?.addEventListener("accordion-item-state", (e) => console.log(e.detail));
```
<.action
phx-click={JS.dispatch("corex:accordion:item-state",
to: "#my-accordion",
detail: %{value: "lorem", disabled: false, respond_to: "client"},
bubbles: false
)}
class="button button--sm"
>
item_state("lorem") (JS.dispatch, client only)
### From Server
def handle_event(
"accordion_item_state_response",
%{"id" => id, "value" => v, "state" => state},
socket
) do
{:noreply, assign(socket, :accordion_item_state, {id, v, state})}
end
"""
def item_state(accordion_id, item_value)
when is_binary(accordion_id) and is_binary(item_value) do
item_state(accordion_id, item_value, [])
end
def item_state(accordion_id, item_value, opts)
when is_binary(accordion_id) and is_binary(item_value) and is_list(opts) do
disabled = Keyword.get(opts, :disabled, false)
JS.dispatch("corex:accordion:item-state",
to: "##{accordion_id}",
detail:
Map.merge(
%{value: validate_item_value!(item_value), disabled: disabled},
respond_to_fields(opts)
),
bubbles: false
)
end
def item_state(socket, accordion_id, item_value)
when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) and
is_binary(item_value) do
item_state(socket, accordion_id, item_value, [])
end
def item_state(socket, accordion_id, item_value, opts)
when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(accordion_id) and
is_binary(item_value) and
is_list(opts) do
disabled = Keyword.get(opts, :disabled, false)
LiveView.push_event(
socket,
"accordion_item_state",
Map.merge(
%{
id: accordion_id,
value: validate_item_value!(item_value),
disabled: disabled
},
respond_to_fields(opts)
)
)
end
defp validate_item_value!(v) when is_binary(v) and byte_size(v) > 0, do: v
defp validate_item_value!(_),
do: raise(ArgumentError, "accordion item value must be a non-empty string")
defp accordion_assert_trigger_content_pair!(assigns) do
if not assigns.compound and Enum.empty?(assigns.items) do
tri = assigns.trigger != []
con = assigns.content != []
if tri != con do
raise ArgumentError,
"Accordion with an empty :items list requires both :trigger and :content slots together, or use :items."
end
end
assigns
end
defp accordion_assign_manual_mode!(assigns) do
if manual_accordion_mode?(assigns) do
panels =
Corex.Slot.resolve_panels!(
%{trigger: assigns.trigger, content: assigns.content, indicator: assigns.indicator},
required: [:trigger, :content],
optional: [:indicator],
disabled: &accordion_panel_disabled?/1,
component: "Accordion"
)
assigns
|> assign(:accordion_manual_mode, true)
|> assign(:accordion_manual_panels, panels)
else
assigns
|> assign(:accordion_manual_mode, false)
|> assign(:accordion_manual_panels, [])
end
end
defp manual_accordion_mode?(assigns) do
not assigns.compound and Enum.empty?(assigns.items) and assigns.trigger != [] and
assigns.content != []
end
defp accordion_panel_disabled?(entries) do
entries
|> Map.take([:trigger, :content])
|> Map.values()
|> Enum.any?(&truthy_disabled?/1)
end
defp truthy_disabled?(nil), do: false
defp truthy_disabled?(entry), do: Map.get(entry, :disabled, false) == true
defp accordion_assign_panels(assigns) do
panels =
cond do
assigns.compound ->
[]
assigns.accordion_manual_mode ->
Enum.map(assigns.accordion_manual_panels, fn p ->
%{
source: :slots,
value: p.value,
disabled: p.disabled,
trigger_slot: p.trigger,
content_slot: p.content,
indicator_slot: p.indicator
}
end)
true ->
assigns.items
|> Enum.with_index()
|> Enum.map(fn {entry, index} ->
%{
source: :items,
value: entry.value || entry.id || "item-#{index}",
disabled: entry.disabled,
item_entry: entry
}
end)
end
assign(assigns, :panels, panels)
end
defp accordion_panel_has_indicator?(%{source: :slots, indicator_slot: slot}, _top), do: !!slot
defp accordion_panel_has_indicator?(%{source: :items}, top_indicator), do: top_indicator != []
defp normalize_value(nil), do: []
defp normalize_value(v) when is_binary(v), do: [v]
defp normalize_value(v) when is_list(v), do: v
defp normalize_value(_), do: []
end