defmodule Noora.Table do @moduledoc """ Noora's table component. The table component is used to display data in a tabular format. It supports various types of cells, such as text, badges, and buttons. ## Usage The component expects its content to be passed as a list of rows or a LiveView stream as the `rows` attribute. Each row is a map with its values. Columns are defined using the `col` slot, which takes a label and an optional icon. Inside the columns, a large number of different cells are supported. ## Example ### Basic table ``` <.table id="table-single-cell-types" rows={[%{id: 1, label: "Row One", status: "error"}, %{id: 2, label: "Row Two", status: "success"}]} > <:col :let={i} label="Text"> <.text_cell label={i.label} sublabel="(Internal)" icon="alert_circle" /> <:col :let={i} label="Status badge"> <.status_badge_cell label={i.status} status={i.status /> ``` ### Expandable rows Tables can have expandable rows that reveal additional content when clicked. Use the `row_expandable` attribute to determine which rows can be expanded, and the `expanded_content` slot to define what content to show. ``` <.table id="expandable-table" rows={@tasks} row_key={fn task -> task.key end} row_expandable={fn task -> not Enum.empty?(task.details) end} expanded_rows={MapSet.to_list(@expanded_task_keys)} > <:col :let={task} label="Task"> <.text_cell label={task.description} /> <:col :let={task} label="Status"> <.badge_cell label={task.status} color="success" /> <:expanded_content :let={task}>
<%= for detail <- task.details do %>

{detail}

<% end %>
``` In your LiveView, handle the expand/collapse interaction: ``` def handle_event("toggle-expand", %{"row-key" => row_key}, socket) do expanded_keys = socket.assigns.expanded_task_keys updated_keys = if MapSet.member?(expanded_keys, row_key) do MapSet.delete(expanded_keys, row_key) else MapSet.put(expanded_keys, row_key) end {:noreply, assign(socket, expanded_task_keys: updated_keys)} end ``` """ use Phoenix.Component import Noora.Badge import Noora.Button import Noora.Icon import Noora.Tag import Noora.Time import Noora.Utils attr(:id, :string, required: true, doc: "A uniqie identifier for the table") attr(:rows, :list, required: true, doc: "The table content") attr(:row_key, :fun, default: nil, doc: "A function to generate the row key. Required when using a LiveView stream. If using streams and not provided, defaults to the `id` key of the stream." ) attr(:row_navigate, :fun, default: nil, doc: "A function to generate the link to navigate to when clicking on a row." ) attr(:row_click, :fun, default: nil, doc: "A function to generate the click handler for a row." ) attr(:row_expandable, :fun, default: nil, doc: "A function to determine if a row can be expanded. Returns true/false." ) attr(:expanded_rows, :list, default: [], doc: "A list of row keys/IDs that are currently expanded." ) slot(:empty_state, required: false) slot :col, required: true do attr(:label, :string, required: false, doc: "The label of the column") attr(:icon, :string, doc: "An icon to render next to the label") attr(:patch, :string, doc: "A patch to apply to the column") end slot(:expanded_content, required: false, doc: "Content to display when a row is expanded") def table(assigns) do assigns = case assigns do %{rows: %Phoenix.LiveView.LiveStream{}} -> assign(assigns, row_key: assigns.row_key || fn {id, _item} -> id end ) _ -> assign(assigns, row_key: assigns.row_key || fn row -> key = Map.get(row, :id) if is_binary(key), do: key, else: "#{assigns.id}-row-#{key}" end ) end ~H"""
<%= for row <- @rows do %> <% row_key = @row_key && @row_key.(row) %> <% is_expandable = @row_expandable && @row_expandable.(row) %> <% is_expanded = row_key in @expanded_rows %> "toggle-expand", "phx-value-row-key" => row_key }, else: if(@row_click, do: @row_click.(row) || %{}, else: %{})} data-expandable={is_expandable} data-expanded={is_expanded} > <% end %>
<%= if col[:patch] do %> <.link patch={col[:patch]} data-part="sort-link"> {col[:label]} <.icon name={col[:icon]} /> <% else %> {col[:label]} <.icon name={col[:icon]} /> <% end %>
<%= if is_expandable && index == 0 do %>
<.chevron_down :if={is_expanded} /> <.chevron_right :if={!is_expanded} /> {render_slot(col, row)}
<% else %> <%= if @row_navigate do %> <.link navigate={@row_navigate.(row)} data-part="link"> {render_slot(col, row)} <% else %> {render_slot(col, row)} <% end %> <% end %>
{render_slot(@expanded_content, row)}
{render_slot(@empty_state)}
""" end attr(:label, :string, default: nil, doc: "The label of the cell") attr(:icon, :string, default: nil, doc: "An optional icon to render next to the label. Mutually exclusive with `image`." ) attr(:sublabel, :string, default: nil, doc: "An optional sublabel") attr(:rest, :global) slot(:image, required: false, doc: "An optional image to render next to the label. Mutually exclusive with `icon`. Takes precedence over `icon`." ) def text_cell(assigns) do ~H"""
<.icon :if={@icon && !has_slot_content?(@image, assigns)} name={@icon} /> <%= if has_slot_content?(@image, assigns) do %> {render_slot(@image)} <% end %>
{@label} {@sublabel}
""" end attr(:label, :string, default: nil, doc: "The label of the cell") attr(:icon, :string, default: nil, doc: "An optional icon to render next to the label. Mutually exclusive with `image`." ) attr(:description, :string, default: nil, doc: "The description of the cell") attr(:secondary_description, :string, default: nil, doc: "The secondary description of the cell") attr(:rest, :global) slot(:image, required: false, doc: "An optional image to render next to the label. Takes precedence over `icon`." ) def text_and_description_cell(assigns) do ~H"""
<.icon :if={@icon && !has_slot_content?(@image, assigns)} name={@icon} /> <%= if has_slot_content?(@image, assigns) do %> {render_slot(@image)} <% end %>
{@label} {@description} {@secondary_description}
""" end attr(:label, :string, default: nil, doc: "The label of the badge") attr(:icon, :string, default: nil, doc: "An optional icon to render next to the label." ) attr(:style, :string, values: ~w(fill light-fill), default: "fill", doc: "The style of the badge" ) attr(:color, :string, values: ~w(neutral destructive warning attention success information focus primary secondary), default: "neutral", doc: "The color of the badge" ) attr(:rest, :global) def badge_cell(assigns) do ~H"""
<.badge style={@style} color={@color} size="large" label={@label}> <:icon :if={@icon}> <.icon name={@icon} />
""" end attr(:label, :string, default: nil, doc: "The label of the badge") attr(:icon, :string, default: nil, doc: "An optional icon to render next to the label." ) attr(:rest, :global) def tag_cell(assigns) do ~H"""
<.tag label={@label} icon={@icon} />
""" end attr(:status, :string, values: ~w(success error warning disabled attention), required: true, doc: "The status of the badge" ) attr(:label, :string, default: nil, doc: "The label of the badge") attr(:rest, :global) def status_badge_cell(assigns) do ~H"""
<.status_badge status={@status} label={@label} />
""" end attr(:rest, :global) slot(:button, required: true, doc: "The button or buttons to render") def button_cell(assigns) do ~H"""
<%= for button <- @button do %> {render_slot(button)} <% end %>
""" end attr(:label, :string, required: true, doc: "The label of the button") attr(:variant, :string, values: button_variants(), default: "primary", doc: "Determines the style" ) attr(:underline, :boolean, default: false, doc: "Determines if the button is underlined") attr(:rest, :global) slot(:icon_left, doc: "Icon displayed on the left of an item") slot(:icon_right, doc: "Icon displayed on the right of an item") def link_button_cell(assigns) do ~H"""
<.link_button label={@label} variant={@variant} underline={@underline} size="large"> <:icon_left> {render_slot(@icon_left)} <:icon_right> {render_slot(@icon_right)}
""" end attr(:time, DateTime, required: true, doc: "The time to render.") attr(:show_time, :boolean, default: false, doc: "Whether to show the time or date only.") attr(:relative, :boolean, default: false, doc: "Whether to show the time relative to now.") attr(:rest, :global) def time_cell(assigns) do ~H"""
<.time time={@time} show_time={@show_time} relative={@relative} />
""" end attr(:icon, :string, default: nil, doc: "Icon to show in the empty state.") attr(:title, :string, default: nil, doc: "Title of the empty state.") attr(:subtitle, :string, default: nil, doc: "Subtitle of the empty state.") slot(:inner_block, doc: "Custom empty state content. Supersedes all attributes.") def table_empty_state(assigns) do ~H"""
<%= if has_slot_content?(@inner_block, assigns) do %> {render_slot(@inner_block)} <% else %>
<.icon name={@icon} />
{@title}
{@subtitle}
<% end %>
""" end end