Current section
Files
Jump to
Current section
Files
lib/cinder/renderers/grid.ex
defmodule Cinder.Renderers.Grid do
@moduledoc """
Renderer for grid/card layout.
This module contains the render function for displaying data in a
responsive grid format with sort controls rendered as a button group.
"""
use Phoenix.Component
use Cinder.Messages
require Logger
import Cinder.Renderers.Helpers
alias Cinder.Renderers.BulkActions
alias Cinder.Renderers.Pagination
alias Cinder.Renderers.SortControls
@doc """
Renders the grid layout.
"""
def render(assigns) do
has_item_slot = Map.get(assigns, :item_slot, []) != []
unless has_item_slot do
Logger.warning("Cinder.Grid: No <:item> slot provided. Items will not be rendered.")
end
container_class =
get_container_class(assigns.container_class, assigns.grid_columns, assigns.theme)
{item_class, item_data_key} = get_item_classes(assigns.theme, assigns.item_click)
assigns =
assigns
|> assign(:has_item_slot, has_item_slot)
|> assign(:grid_container_class, container_class)
|> assign(:grid_item_class, item_class)
|> assign(:grid_item_data_key, item_data_key)
~H"""
<div class={[@theme.container_class, "relative"]} data-key="container_class">
<!-- Controls Area (filters + sort) -->
<div :if={@show_filters or (@show_sort && SortControls.has_sortable_columns?(@columns))} class={[@theme.controls_class, "!flex !flex-col"]} data-key="controls_class">
<!-- Filter Controls (including search) -->
<Cinder.FilterManager.render_filter_controls
:if={@show_filters}
table_id={@id}
columns={Map.get(assigns, :query_columns, @columns)}
filters={@filters}
theme={@theme}
target={@myself}
filters_label={@filters_label}
filter_mode={@show_filters}
search_term={@search_term}
show_search={@search_enabled}
search_label={@search_label}
search_placeholder={@search_placeholder}
raw_filter_params={Map.get(assigns, :raw_filter_params, %{})}
/>
<!-- Sort Controls (button group since no table headers) -->
<SortControls.render
:if={@show_sort}
columns={@columns}
sort_by={@sort_by}
sort_label={@sort_label}
theme={@theme}
myself={@myself}
/>
</div>
<!-- Bulk Actions -->
<BulkActions.render
selectable={@selectable}
selected_ids={@selected_ids}
bulk_action_slots={@bulk_action_slots}
theme={@theme}
myself={@myself}
/>
<!-- Grid Items Container -->
<div class={@grid_container_class} data-key="grid_container_class">
<%= if @has_item_slot do %>
<div
:for={item <- @data} :if={not @error}
class={get_item_classes_with_selection(@grid_item_class, Map.get(assigns, :selectable, false), Map.get(assigns, :selected_ids, MapSet.new()), item, Map.get(assigns, :id_field, :id), @item_click, @theme)}
data-key={@grid_item_data_key}
phx-click={item_click_action(@item_click, Map.get(assigns, :selectable, false), item, Map.get(assigns, :id_field, :id), @myself)}
>
<div
:if={Map.get(assigns, :selectable, false)}
class={@theme.grid_selection_overlay_class}
data-key="grid_selection_overlay_class"
>
<input
type="checkbox"
checked={item_selected?(Map.get(assigns, :selected_ids, MapSet.new()), item, Map.get(assigns, :id_field, :id))}
phx-click="toggle_select"
phx-value-id={to_string(Map.get(item, Map.get(assigns, :id_field, :id)))}
phx-target={@myself}
class={@theme.selection_checkbox_class}
data-key="selection_checkbox_class"
/>
</div>
{render_slot(@item_slot, item)}
</div>
<% else %>
<!-- No item slot provided - render message -->
<div :if={not @loading} class={@theme.empty_class} data-key="empty_class">
No item template provided. Add an <:item> slot to render items.
</div>
<% end %>
<!-- Error State -->
<div :if={@error and not @loading} class={[@theme.empty_class, "col-span-full"]} data-key="error_class">
<%= if has_slot?(assigns, :error_slot) do %>
{render_slot(@error_slot)}
<% else %>
<div class={@theme.error_container_class} data-key="error_container_class">
<span class={@theme.error_message_class} data-key="error_message_class">{@error_message}</span>
</div>
<% end %>
</div>
<!-- Empty State (only when not loading and not error) -->
<div :if={@data == [] and not @loading and not @error and @has_item_slot} class={[@theme.empty_class, "col-span-full"]} data-key="empty_class">
<%= if has_slot?(assigns, :empty_slot) do %>
{render_slot(@empty_slot, empty_context(assigns))}
<% else %>
{@empty_message}
<% end %>
</div>
</div>
<!-- Loading indicator -->
<div :if={@loading} class={@theme.loading_overlay_class} data-key="loading_overlay_class">
<%= if has_slot?(assigns, :loading_slot) do %>
{render_slot(@loading_slot)}
<% else %>
<div class={@theme.loading_container_class} data-key="loading_container_class">
<svg class={@theme.loading_spinner_class} data-key="loading_spinner_class" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class={@theme.loading_spinner_circle_class} data-key="loading_spinner_circle_class" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class={@theme.loading_spinner_path_class} data-key="loading_spinner_path_class" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{@loading_message}
</div>
<% end %>
</div>
<!-- Pagination -->
<Pagination.render
page={@page}
page_size_config={@page_size_config}
theme={@theme}
myself={@myself}
show_pagination={@show_pagination}
pagination_mode={@pagination_mode}
id={@id}
/>
</div>
"""
end
# ============================================================================
# CONTAINER AND ITEM HELPERS
# ============================================================================
# Explicit container_class override takes precedence
defp get_container_class(custom_class, _grid_columns, _theme) when is_binary(custom_class) do
custom_class
end
# Build from theme base + grid_columns
defp get_container_class(nil, grid_columns, theme) do
base = Map.get(theme, :grid_container_class, "grid gap-4")
cols = build_grid_cols(grid_columns)
[base, cols]
end
defp build_grid_cols(cols) when is_binary(cols) do
build_grid_cols(String.to_integer(cols))
end
defp build_grid_cols(cols) when is_integer(cols) and cols in 1..12 do
"grid grid-cols-#{cols}"
end
# If an invalid number is provided, default to 3
defp build_grid_cols(cols) when is_integer(cols), do: "grid-cols-3"
defp build_grid_cols(cols) when is_list(cols) do
Enum.map(cols, &breakpoint_class/1)
end
defp build_grid_cols(_), do: "grid-cols-3"
defp breakpoint_class({:xs, cols}), do: "grid-cols-#{cols}"
defp breakpoint_class({:sm, cols}), do: "sm:grid-cols-#{cols}"
defp breakpoint_class({:md, cols}), do: "md:grid-cols-#{cols}"
defp breakpoint_class({:lg, cols}), do: "lg:grid-cols-#{cols}"
defp breakpoint_class({:xl, cols}), do: "xl:grid-cols-#{cols}"
defp breakpoint_class({:"2xl", cols}), do: "2xl:grid-cols-#{cols}"
defp breakpoint_class(_), do: nil
defp get_item_classes(theme, item_click) do
base =
Map.get(theme, :grid_item_class, "p-4 bg-white border border-gray-200 rounded-lg shadow-sm")
if item_click do
clickable =
Map.get(
theme,
:grid_item_clickable_class,
"cursor-pointer hover:shadow-md transition-shadow"
)
{[base, clickable], "grid_item_clickable_class"}
else
{base, "grid_item_class"}
end
end
# ============================================================================
# SELECTION HELPERS
# ============================================================================
defp get_item_classes_with_selection(
base_class,
selectable,
selected_ids,
item,
id_field,
item_click,
theme
) do
classes = [base_class]
# Add cursor-pointer if item is clickable (either via item_click or selectable without item_click)
clickable = item_click != nil or (selectable and item_click == nil)
classes = if clickable, do: classes ++ ["cursor-pointer"], else: classes
if selectable and item_selected?(selected_ids, item, id_field) do
classes ++ [theme.selected_item_class]
else
classes
end
end
defp item_click_action(item_click, _selectable, item, _id_field, _myself)
when item_click != nil do
item_click.(item)
end
defp item_click_action(nil, true, item, id_field, myself) do
Phoenix.LiveView.JS.push("toggle_select",
value: %{id: to_string(Map.get(item, id_field))},
target: myself
)
end
defp item_click_action(nil, false, _item, _id_field, _myself), do: nil
defp item_selected?(selected_ids, item, id_field) do
id = to_string(Map.get(item, id_field))
MapSet.member?(selected_ids, id)
end
# Tailwind safelist - these classes are dynamically generated, keep them here for purge detection:
# grid-cols-1 grid-cols-2 grid-cols-3 grid-cols-4 grid-cols-5 grid-cols-6 grid-cols-7 grid-cols-8 grid-cols-9 grid-cols-10 grid-cols-11 grid-cols-12
# sm:grid-cols-1 sm:grid-cols-2 sm:grid-cols-3 sm:grid-cols-4 sm:grid-cols-5 sm:grid-cols-6
# md:grid-cols-1 md:grid-cols-2 md:grid-cols-3 md:grid-cols-4 md:grid-cols-5 md:grid-cols-6
# lg:grid-cols-1 lg:grid-cols-2 lg:grid-cols-3 lg:grid-cols-4 lg:grid-cols-5 lg:grid-cols-6
# xl:grid-cols-1 xl:grid-cols-2 xl:grid-cols-3 xl:grid-cols-4 xl:grid-cols-5 xl:grid-cols-6
# 2xl:grid-cols-1 2xl:grid-cols-2 2xl:grid-cols-3 2xl:grid-cols-4 2xl:grid-cols-5 2xl:grid-cols-6
end