Skip to content

Instantly share code, notes, and snippets.

@Neophen
Last active November 24, 2024 09:35
Show Gist options
  • Save Neophen/7935fb2c57deae98705059269577d11a to your computer and use it in GitHub Desktop.
Save Neophen/7935fb2c57deae98705059269577d11a to your computer and use it in GitHub Desktop.

SaladUI Card

This card is inspired by the SaladUI (which is inspired by ShadCN)

example_card.mov

See better-states tailwind plugin to have the full functionality:

https://gist.github.com/Neophen/2f512ace1e7182e5346076333e4a0fdc

Explanation

The order of the card_header/card_content/card_footer doesn't matter as they all slot into the right place

So this markup:

<.card action={[
        {:patch, ~q"/:locale/admin/users/#{@user.id}"},
        {:sr_label, dgettext("admin", "View user %{name}", name: @user.first_name)}
      ]} >
   <.card_footer />
   <.card_header />
   <.card_content />
<./card>

Will always render the following:

<.card action="...">
   <.card_header />
   <.card_content />
   <.card_footer />
<./card>

I've placed the main action on the card component to avoid thinking about tabbing index issues for keyboard navigation so that if you have a menu like so:

<.card
  id={@user.id}
  action={[
    {:patch, ~q"/:locale/admin/users/#{@user.id}"},
    {:sr_label, dgettext("admin", "View user %{name}", name: @user.first_name)}
  ]}
>
  <.card_header>
    <.card_accent_text>
      <.text_h4 text={@user.email} />
    </.card_accent_text>
  </.card_header>

  <.card_menu>
    <.card_menu_action icon="lucide-settings" patch={~q"/:locale/admin/users/#{@user.id}"}>
      <%= dgettext("admin", "Settings") %>
    </.card_menu_action>
    <.card_menu_action
      data-confirm-title={dgettext("admin", "Delete %{user}", user: @user.first_name)}
      data-confirm-message={dgettext("admin", "Are you sure? This action cannot be undone!")}
      data-confirm-confirm={dgettext("admin", "Delete user?")}
      data-confirm-type="danger"
      phx-click="delete_user"
      phx-value-id={@user.id}
      icon="lucide-trash"
    >
      <%= dgettext("admin", "Delete") %>
    </.card_menu_action>
  </.card_menu>
  <.card_footer_muted_text text={dgettext("admin", "Edited %{time}", time: @user.updated_at)} />
</.card>

The first tab index is always on the <.card_action /> this is for accesibility sake, you could also just delete this part from the card and make sure the <.card_action> is always the first component in the html. Again it works by placing it self in the correct visual position. so the order doesn't matter.

The <.card_accent_text> and <.card_muted_text> act to provide highlighting or muting stuff on the card to avoid visual overload for the consumers.

defmodule AdminUI.Card do
@moduledoc """
Implement of card components from https://ui.shadcn.com/docs/components/card
"""
use AdminUI.Component
use Gettext, backend: Octafest.Gettext
import AdminUI.Icon
import AdminUI.Typography
import AdminUI.Menu
import AdminUI.DropdownMenu
import AdminUI.Button
@doc """
Render dropdown menu
## Examples:
<.card id={@user.id} action={[{:patch, ~q"/"}, {:sr_label, dgettext("admin", "Say hello") ]} >
<.card_header>
<.card_accent_text>Title</.card_accent_text>
<.card_menu>
<.card_menu_action icon="lucide-settings" patch={~q"/"}>
<%= dgettext("admin", "Settings") %>
</.card_menu_action>
</.card_menu>
</.card_header>
<.card_footer>
<.card_muted_text>
<%= dgettext("admin", "Edited %{time}", time: @user.updated_at) %>
</.card_muted_text>
</.card_footer>
</.card>
"""
attr :id, :string, default: nil
attr :action, :list, default: nil
attr :rest, :global
slot :inner_block, required: true
@spec card(map()) :: Phoenix.LiveView.Rendered.t()
def card(assigns) do
~H"""
<li
id={"card-#{@id}"}
class="group/card rounded-xl gap-2 bg-background relative grid [grid-template-areas:'header_menu''main_main''footer_action'] grid-rows-auto grid-cols-[1fr_min-content] border-2 border-transparent p-4 hovered-action:border-accent pressed-action:bg-accent shadow-md hovered-action:shadow-accent shadow-primary/5"
{@rest}
>
<.card_action :if={@action} {@action} />
<%= render_slot(@inner_block) %>
</li>
"""
end
slot :inner_block, required: true
@spec card_accent_text(map()) :: Phoenix.LiveView.Rendered.t()
def card_accent_text(assigns) do
~H"""
<div class="text-foreground group-hovered-action/card:text-accent-foreground">
<%= render_slot(@inner_block) %>
</div>
"""
end
slot :inner_block, required: true
@spec card_muted_text(map()) :: Phoenix.LiveView.Rendered.t()
def card_muted_text(assigns) do
~H"""
<div class="text-muted-foreground group-hovered-action/card:text-foreground">
<%= render_slot(@inner_block) %>
</div>
"""
end
attr :sr_label, :string, required: true
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global,
include:
~w(navigate patch href replace method csrf_token download hreflang referrerpolicy rel target type)
def card_action(assigns) do
~H"""
<.link
{@rest}
data-action
class="[grid-area:action] justify-self-end self-end before:absolute before:inset-0 hovered:outline-none hovered:ring-0"
>
<div class="relative"><span class="sr-only"><%= @sr_label %></span></div>
<div class="text-muted-foreground pointer-events-none underline group-hovered-action/card:text-accent-foreground flex items-center">
<.text text={@label || dgettext("admin", "View")} />
<.icon icon="lucide-chevron-right" class="size-4" />
</div>
</.link>
"""
end
slot :inner_block, required: true
def card_menu(assigns) do
~H"""
<.dropdown_menu class="[grid-area:menu] justify-self-end opacity-5 group-hovered/card:opacity-100 z-10">
<.dropdown_menu_trigger>
<.button variant="btn-outline" size="btn-icon" class="overflow-hidden rounded-full">
<.icon icon="lucide-settings" class="size-5" />
</.button>
</.dropdown_menu_trigger>
<.dropdown_menu_content side="bottom" align="end">
<.menu>
<.menu_group>
<%= render_slot(@inner_block) %>
</.menu_group>
</.menu>
</.dropdown_menu_content>
</.dropdown_menu>
"""
end
attr :icon, :string, default: nil
attr :class, :string, default: nil
attr :disabled, :boolean, default: false
attr :rest, :global,
include:
~w(disabled form name value download hreflang referrerpolicy rel target type navigate patch href replace method type csrf_token)
slot :inner_block, required: true
def card_menu_action(%{"phx-click": nil} = assigns) do
~H"""
<.link {@rest}>
<.menu_item class={@class} disabled={@disabled}>
<.icon :if={@icon} icon={@icon} class="size-4 mr-2" />
<%= render_slot(@inner_block) %>
</.menu_item>
</.link>
"""
end
def card_menu_action(assigns) do
~H"""
<button type="button" disabled={@disabled} class="block w-full" {@rest}>
<.menu_item class={@class} disabled={@disabled}>
<.icon :if={@icon} icon={@icon} class="size-4 mr-2" />
<%= render_slot(@inner_block) %>
</.menu_item>
</button>
"""
end
slot :inner_block, required: true
def card_footer(assigns) do
~H"""
<div class="[grid-area:footer]">
<hr class="mb-2 w-6 border-border group-hovered-action/card:border-primary" />
<div class="flex items-center">
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
slot :inner_block, required: true
def card_header(assigns) do
~H"""
<div class="flex items-center justify-between [grid-area:header]">
<%= render_slot(@inner_block) %>
</div>
"""
end
attr :text, :string, required: true
def card_footer_muted_text(assigns) do
~H"""
<.card_footer>
<.card_muted_text>
<.text text={@text} />
</.card_muted_text>
</.card_footer>
"""
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment