defmodule Corex.Tabs do @moduledoc ~S''' Phoenix implementation of [Zag.js Tabs](https://zagjs.com/components/react/tabs). ## Examples ### List You must use `Corex.List.Item` struct for items. The value for each item is optional, useful for controlled mode and API to identify the item. You can specify disabled for each item. ```heex <.tabs class="tabs" items={[ %Corex.List.Item{ trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique." }, %Corex.List.Item{ trigger: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus." }, %Corex.List.Item{ trigger: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus." } ]} /> ``` ### List Custom Similar to List but render custom trigger and content slots that will be used for all items. Use `:trigger` and `:content` slots to customize the rendering. Each slot receives the item data. ```heex <.tabs class="tabs" value="lorem" items={[ %Corex.List.Item{ value: "lorem", trigger: "Lorem", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.", meta: %{ indicator: "hero-chevron-right", } }, %Corex.List.Item{ trigger: "Duis", content: "Nullam eget vestibulum ligula, at interdum tellus.", meta: %{ indicator: "hero-chevron-right", } }, %Corex.List.Item{ value: "donec", trigger: "Donec", content: "Congue molestie ipsum gravida a. Sed ac eros luctus.", disabled: true } ]} > <:trigger :let={item}> {item.data.trigger} <:content :let={item}> {item.data.content} ``` ### Custom Render custom trigger and content slots for each tab item manually. Use `:trigger` slot for the tab button and `:content` slot for the tab panel content. Each slot accepts a `value` attribute to identify the tab item. ```heex <.tabs id="my-tabs" value="duis" class="tabs"> <:trigger value="lorem"> Lorem ipsum dolor sit amet <:content value="lorem"> Consectetur adipiscing elit. Sed sodales ullamcorper tristique. Proin quis risus feugiat tellus iaculis fringilla. <:trigger value="duis"> Duis dictum gravida odio ac pharetra? <:content value="duis"> Nullam eget vestibulum ligula, at interdum tellus. Quisque feugiat, dui ut fermentum sodales, lectus metus dignissim ex. <:trigger value="donec" disabled> Donec condimentum ex mi <:content value="donec"> Congue molestie ipsum gravida a. Sed ac eros luctus. ``` ### Controlled Render tabs controlled by the server. You must use the `on_value_change` event to update the value on the server and pass the value as a string. The event will receive the value as a map with the key `value` and the id of the tabs. ```elixir defmodule MyAppWeb.TabsLive do use MyAppWeb, :live_view def mount(_params, _session, socket) do {:ok, assign(socket, :value, "lorem")} end def handle_event("on_value_change", %{"value" => value}, socket) do {:noreply, assign(socket, :value, value)} end def render(assigns) do ~H""" <.tabs controlled value={@value} on_value_change="on_value_change" class="tabs"> <:trigger value="lorem"> Lorem ipsum dolor sit amet <:content value="lorem"> Consectetur adipiscing elit. Sed sodales ullamcorper tristique. Proin quis risus feugiat tellus iaculis fringilla. <:trigger value="duis"> Duis dictum gravida odio ac pharetra? <:content value="duis"> Nullam eget vestibulum ligula, at interdum tellus. Quisque feugiat, dui ut fermentum sodales, lectus metus dignissim ex. """ end end ``` ### Async When the initial props are not available on mount, you can use the `Phoenix.LiveView.assign_async` function to assign the props asynchronously You can use the optional `Corex.Tabs.tabs_skeleton/1` to render a loading or error state ```elixir defmodule MyAppWeb.TabsAsyncLive do use MyAppWeb, :live_view def mount(_params, _session, socket) do socket = socket |> assign_async(:tabs, fn -> Process.sleep(1000) items = [ %Corex.List.Item{ value: "lorem", trigger: "Lorem ipsum dolor sit amet", content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.", disabled: true }, %Corex.List.Item{ value: "duis", trigger: "Duis dictum gravida odio ac pharetra?", content: "Nullam eget vestibulum ligula, at interdum tellus." }, %Corex.List.Item{ value: "donec", trigger: "Donec condimentum ex mi", content: "Congue molestie ipsum gravida a. Sed ac eros luctus." } ] {:ok, %{ tabs: %{ items: items, value: "duis" } }} end) {:ok, socket} end def render(assigns) do ~H""" <.async_result :let={tabs} assign={@tabs}> <:loading> <.tabs_skeleton count={3} class="tabs" /> <:failed> there was an error loading the tabs <.tabs id="async-tabs" class="tabs" items={tabs.items} value={tabs.value} /> """ end end ``` ## API Control In order to use the API, you must use an id on the component ***Client-side*** ```heex ``` ***Server-side*** ```elixir def handle_event("open_item", _, socket) do {:noreply, Corex.Tabs.set_value(socket, "my-tabs", "item-1")} end ``` ## Styling Use data attributes to target elements: ```css [data-scope="tabs"][data-part="root"] {} [data-scope="tabs"][data-part="item"] {} [data-scope="tabs"][data-part="item-trigger"] {} [data-scope="tabs"][data-part="item-content"] {} [data-scope="tabs"][data-part="item-indicator"] {} ``` If you wish to use the default Corex styling, you can use the class `tabs` on the component. This requires to install mix corex.design first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/tabs.css"; ``` You can then use modifiers ```heex <.tabs class="tabs tabs--accent tabs--lg"> ``` Learn more about modifiers and [Corex Design](https://corex-ui.com/components/tabs#modifiers) ''' @doc type: :component use Phoenix.Component alias Corex.Tabs.Anatomy.{Props, Root, List, Trigger, Content} alias Corex.Tabs.Connect @doc """ Renders a tabs component. You can use either: - The `:trigger` and `:content` slots for manual tab definition with full control - The `:items` attribute for programmatic tab generation from a list of `%Corex.List.Item{}` structs When using `:items`, each item MUST be a `%Corex.List.Item{}` struct with: - `:value` (optional) - unique identifier for the item - `:trigger` (required) - content for the trigger button - `:content` (optional, default: "") - content for the tabs panel - `:disabled` (optional, default: false) - whether the item is disabled When using `:trigger` and `:content` slots: - Each `:trigger` slot should have a `value` attribute to identify the tab - Each `:content` slot should have a matching `value` attribute - Triggers are rendered in the list, content panels are rendered outside the list """ attr(:id, :string, required: false, doc: "The id of the tabs, useful for API to identify the tabs" ) attr(:items, :list, default: nil, doc: "The items of the tabs, must be a list of %Corex.List.Item{} structs" ) attr(:value, :string, default: nil, doc: "The initial value or the controlled value of the tabs, must be a string" ) attr(:controlled, :boolean, default: false, doc: "Whether the tabs is controlled. Only in LiveView, the on_value_change event is required" ) attr(:collapsible, :boolean, default: true, doc: "Whether the tabs is collapsible") attr(:disabled, :boolean, default: false, doc: "Whether the tabs is disabled") attr(:multiple, :boolean, default: true, doc: "Whether the tabs allows multiple items to be selected" ) attr(:orientation, :string, default: "horizontal", values: ["horizontal", "vertical"], doc: "The orientation of the tabs" ) attr(:dir, :string, default: "ltr", values: ["ltr", "rtl"], doc: "The direction of the tabs" ) attr(:on_value_change, :string, default: nil, doc: "The server event name when the value change" ) attr(:on_value_change_client, :string, default: nil, doc: "The client event name when the value change" ) attr(:on_focus_change, :string, default: nil, doc: "The server event name when the focus change" ) attr(:on_focus_change_client, :string, default: nil, doc: "The client event name when the focus change" ) attr(:rest, :global) slot :trigger, required: false do attr(:value, :string, doc: "The value of the trigger, useful in controlled mode and for API to identify the item" ) attr(:disabled, :boolean, doc: "Whether the trigger is disabled") end slot :content, required: false do attr(:value, :string, doc: "The value of the content, must match the corresponding trigger value" ) attr(:disabled, :boolean, doc: "Whether the content is disabled") end def tabs(assigns) do assigns = assigns |> assign_new(:id, fn -> "tabs-#{System.unique_integer([:positive])}" end) |> validate_items() |> assign( :values, if(is_binary(assigns.value), do: [assigns.value], else: assigns.value || []) ) |> assign( :value_for_props, Enum.at(if(is_binary(assigns.value), do: [assigns.value], else: assigns.value || []), 0) ) |> assign(:items_list, build_items_list(assigns)) ~H"""
<% item_data = %{ id: @id, changed: if(@__changed__, do: true, else: false), value: item_entry.value || "item-#{index}", disabled: item_entry.disabled || @disabled, values: @values, orientation: @orientation, dir: @dir, disabled_root: @disabled, data: %{ trigger: Map.get(item_entry, :trigger, ""), content: Map.get(item_entry, :content, ""), meta: Map.get(item_entry, :meta, %{}) } } %> <.tabs_trigger item={item_data}> <%= render_slot(trigger_slot, item_data) %>
<.tabs_trigger item={%{ id: @id, changed: if(@__changed__, do: true, else: false), value: Map.get(trigger_entry, :value, "item-#{index}"), disabled: Map.get(trigger_entry, :disabled, false), values: @values, orientation: @orientation, dir: @dir, disabled_root: @disabled }}> <%= render_slot(trigger_entry) %>
{item_entry.content}
<% item_data = %{ id: @id, changed: if(@__changed__, do: true, else: false), value: item_entry.value || "item-#{index}", disabled: item_entry.disabled || @disabled, values: @values, orientation: @orientation, dir: @dir, disabled_root: @disabled, data: %{ trigger: Map.get(item_entry, :trigger, ""), content: Map.get(item_entry, :content, ""), meta: Map.get(item_entry, :meta, %{}) } } %> <.tabs_content item={item_data}> <%= render_slot(content_slot, item_data) %>
<.tabs_content item={%{ id: @id, changed: if(@__changed__, do: true, else: false), value: Map.get(content_entry, :value, "item-#{index}"), disabled: Map.get(content_entry, :disabled, false), values: @values, orientation: @orientation, dir: @dir, disabled_root: @disabled }}> <%= render_slot(content_entry) %>
""" end defp build_items_list(%{items: items}) when is_list(items) do Enum.map(Enum.with_index(items), fn {item_entry, index} -> %{ value: item_entry.value || "item-#{index}", disabled: Map.get(item_entry, :disabled, false) } end) end defp build_items_list(%{trigger: trigger_slots}) when is_list(trigger_slots) do Enum.map(Enum.with_index(trigger_slots), fn {trigger_entry, index} -> %{ value: Map.get(trigger_entry, :value, "item-#{index}"), disabled: Map.get(trigger_entry, :disabled, false) } end) end defp build_items_list(_), do: [] defp validate_items(%{items: nil} = assigns), do: assigns defp validate_items(%{items: items} = assigns) when is_list(items) do Enum.each(items, fn item -> unless is_struct(item, Corex.List.Item) do raise ArgumentError, """ Invalid item in :items attribute. Expected %Corex.List.Item{} struct, got: #{inspect(item)} Please use the Corex.List.Item struct: %Corex.List.Item{ value: "unique-id", trigger: "Trigger text", content: "Content text", disabled: false # optional, defaults to false } """ end end) assigns end defp validate_items(assigns), do: assigns @doc type: :component @doc """ Renders the tabs trigger button. """ attr(:item, :map, required: true) slot(:inner_block, required: true) def tabs_trigger(assigns) do ~H""" """ end @doc type: :component @doc """ Renders the tabs content area. """ attr(:item, :map, required: true) slot(:inner_block, required: true) def tabs_content(assigns) do ~H"""
{render_slot(@inner_block)}
""" end @doc type: :component @doc """ Renders a loading skeleton for the tabs component. """ attr(:count, :integer, default: 3) attr(:rest, :global) slot(:trigger) slot(:indicator) slot(:content) def tabs_skeleton(assigns) do ~H"""
{render_slot(@trigger)} {render_slot(@indicator)}
""" end defp validate_tabs_value!(nil), do: nil defp validate_tabs_value!(value) when is_binary(value), do: value defp validate_tabs_value!(value), do: raise(ArgumentError, "value must be a string or nil, got: #{inspect(value)}") @doc type: :api @doc """ Sets the tabs value from client-side. Returns a `Phoenix.LiveView.JS` command. ## Examples """ def set_value(tabs_id, value) when is_binary(tabs_id) do Phoenix.LiveView.JS.dispatch("phx:tabs:set-value", to: "##{tabs_id}", detail: %{value: validate_tabs_value!(value)}, bubbles: false ) end @doc type: :api @doc """ Sets the tabs value from server-side. Pushes a LiveView event. ## Examples def handle_event("open_item", _params, socket) do socket = Corex.Tabs.set_value(socket, "my-tabs", "item-1") {:noreply, socket} end def handle_event("close_tabs", _params, socket) do socket = Corex.Tabs.set_value(socket, "my-tabs", nil) {:noreply, socket} end """ def set_value(socket, tabs_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(tabs_id) do Phoenix.LiveView.push_event(socket, "tabs_set_value", %{ tabs_id: tabs_id, value: validate_tabs_value!(value) }) end end