Current section
Files
Jump to
Current section
Files
lib/skua/components/display.ex
defmodule Skua.Components.Display do
@moduledoc """
Display & feedback components — badge, dot, card, header, list, empty state,
spinner, alert, accordion, breadcrumb, avatar, progress, skeleton.
All token-driven: rounding derives from `--skua-radius`, colours from the 12
semantic tokens, so they re-skin globally with everything else.
"""
use Phoenix.Component
@doc """
A small status badge/chip. The `success`/`warning`/`info`/`danger` variants
pull their colours from the matching `--skua-*` status tokens, so they stay in
harmony and re-theme with everything else. (`ok` is kept as an alias for
`success`.) Rounding shares `--sk-chip-r` with the multi-select chips.
<.badge>Draft</.badge>
<.badge variant="success">Live</.badge>
<.badge variant="warning">Pending</.badge>
<.badge variant="danger">Failed</.badge>
"""
attr :variant, :string, default: "neutral", values: ~w(neutral success warning info danger ok)
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block, required: true
def badge(assigns) do
~H"""
<span class={["sk-badge", @variant != "neutral" && "sk-badge--#{@variant}", @class]} {@rest}>
{render_slot(@inner_block)}
</span>
"""
end
@doc "A 7px status dot. Pass a `color` (any CSS color or token)."
attr :color, :string, default: "var(--skua-fg-muted)"
attr :class, :any, default: nil
attr :rest, :global
def dot(assigns) do
~H"""
<span class={["sk-dot", @class]} style={"background:#{@color}"} {@rest}></span>
"""
end
@doc """
A surface card.
<.card>
<:title>Plan</:title>
<:subtitle>Pro · billed monthly</:subtitle>
<p class="sk-p">Everything in Free, plus…</p>
<:footer><.button variant="primary">Upgrade</.button></:footer>
</.card>
"""
attr :class, :any, default: nil
attr :rest, :global
slot :title
slot :subtitle
slot :footer
slot :inner_block, required: true
def card(assigns) do
~H"""
<div class={["sk-card", @class]} {@rest}>
<div :if={@title != [] || @subtitle != []} class="sk-card-head">
<p :if={@title != []} class="sk-card-title">{render_slot(@title)}</p>
<p :if={@subtitle != []} class="sk-card-sub">{render_slot(@subtitle)}</p>
</div>
<div class="sk-card-body">{render_slot(@inner_block)}</div>
<div :if={@footer != []} class="sk-card-foot">{render_slot(@footer)}</div>
</div>
"""
end
@doc """
A page/section header — title, optional subtitle, optional right-aligned
actions. (Drop-in for `CoreComponents.header/1`.)
<.header>
Team members
<:subtitle>Manage who has access.</:subtitle>
<:actions><.button variant="primary">Invite</.button></:actions>
</.header>
"""
attr :class, :any, default: nil
attr :level, :string, default: "h2", values: ~w(h1 h2 h3)
slot :subtitle
slot :actions
slot :inner_block, required: true
def header(assigns) do
~H"""
<header class={["sk-header", @class]}>
<div class="sk-header-text">
<p class={"sk-#{@level}"}>{render_slot(@inner_block)}</p>
<p :if={@subtitle != []} class="sk-header-sub">{render_slot(@subtitle)}</p>
</div>
<div :if={@actions != []} class="sk-header-actions">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
A description list — `title`/value rows. (Drop-in for `CoreComponents.list/1`.)
<.list>
<:item title="Name">Ada Lovelace</:item>
<:item title="Email">ada@example.com</:item>
</.list>
"""
attr :class, :any, default: nil
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<dl class={["sk-list", @class]}>
<div :for={item <- @item} class="sk-list-row">
<dt class="sk-list-title">{item.title}</dt>
<dd class="sk-list-val">{render_slot(item)}</dd>
</div>
</dl>
"""
end
@doc """
An empty state — for "no results", an empty table, an unconfigured feature.
<.empty_state title="No users yet">
<:icon><svg>…</svg></:icon>
Invite your team to get started.
<:action><.button variant="primary">Invite</.button></:action>
</.empty_state>
"""
attr :title, :string, default: nil
attr :class, :any, default: nil
slot :icon
slot :action
slot :inner_block
def empty_state(assigns) do
~H"""
<div class={["sk-empty", @class]}>
<div :if={@icon != []} class="sk-empty-icon">{render_slot(@icon)}</div>
<p :if={@title} class="sk-empty-title">{@title}</p>
<p :if={@inner_block != []} class="sk-empty-desc">{render_slot(@inner_block)}</p>
<div :if={@action != []} class="sk-empty-action">{render_slot(@action)}</div>
</div>
"""
end
@doc "A loading spinner. `size` is `sm | md | lg`."
attr :size, :string, default: "md", values: ~w(sm md lg)
attr :class, :any, default: nil
attr :rest, :global
def spinner(assigns) do
~H"""
<svg
class={["sk-spinner", "sk-spinner--#{@size}", @class]}
viewBox="0 0 24 24"
role="status"
aria-label="Loading"
{@rest}
>
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2.5" />
<path
d="M12 3a9 9 0 0 1 9 9"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
"""
end
@doc """
An inline alert / callout — a persistent message (unlike a transient toast).
<.alert variant="warning" title="Heads up">Your trial ends in 3 days.</.alert>
<.alert variant="success">Saved.</.alert>
`variant` is `info | success | warning | error | neutral`. Warning/error get
`role="alert"`. Pass `icon={false}` to drop the leading glyph, or an `:actions`
slot for trailing buttons.
"""
attr :variant, :string, default: "info", values: ~w(info success warning error neutral)
attr :title, :string, default: nil
attr :icon, :boolean, default: true
attr :class, :any, default: nil
attr :rest, :global
slot :inner_block
slot :actions
def alert(assigns) do
~H"""
<div
class={["sk-alert", "sk-alert--#{@variant}", @class]}
role={(@variant in ~w(warning error) && "alert") || nil}
{@rest}
>
<span :if={@icon} class="sk-alert-icon">
<svg class="sk-glyph" viewBox="0 0 24 24" aria-hidden="true">
<%= case @variant do %>
<% "success" -> %>
<path d="M20 6 9 17l-5-5" />
<% "warning" -> %>
<path d="M10.3 4 2 18.3A2 2 0 0 0 3.7 21h16.6a2 2 0 0 0 1.7-2.7L13.7 4a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" />
<% "error" -> %>
<circle cx="12" cy="12" r="10" /><path d="m15 9-6 6M9 9l6 6" />
<% _ -> %>
<circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" />
<% end %>
</svg>
</span>
<div class="sk-alert-body">
<p :if={@title} class="sk-alert-title">{@title}</p>
<div :if={@inner_block != []} class="sk-alert-text">{render_slot(@inner_block)}</div>
</div>
<div :if={@actions != []} class="sk-alert-actions">{render_slot(@actions)}</div>
</div>
"""
end
@doc """
An accordion of collapsible panels, built on native `<details>` (free
keyboard + a11y). Pass `exclusive` so opening one closes the others (native
`<details name>` grouping); mark an `:item` `open` to start expanded.
<.accordion id="faq" exclusive>
<:item title="What is Skua?">A headless, token-styled component kit.</:item>
<:item title="Is it free?" open>Yes — MIT.</:item>
</.accordion>
"""
attr :id, :string, required: true
attr :exclusive, :boolean, default: false
attr :class, :any, default: nil
slot :item, required: true do
attr :title, :string, required: true
attr :open, :boolean
end
def accordion(assigns) do
~H"""
<div class={["sk-accordion", @class]} id={@id}>
<details
:for={item <- @item}
class="sk-acc-item"
name={@exclusive && "#{@id}-group"}
open={item[:open]}
>
<summary class="sk-acc-trigger sk-focusable">
<span class="sk-acc-label">{item.title}</span>
<svg class="sk-glyph sk-acc-chev" viewBox="0 0 24 24" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<div class="sk-acc-panel">{render_slot(item)}</div>
</details>
</div>
"""
end
@doc """
A breadcrumb trail. Give each `:crumb` an `href`/`navigate`/`patch`; a final
crumb without one renders as the current page.
<.breadcrumb>
<:crumb navigate={~p"/"}>Home</:crumb>
<:crumb navigate={~p"/team"}>Team</:crumb>
<:crumb>Settings</:crumb>
</.breadcrumb>
"""
attr :class, :any, default: nil
slot :crumb, required: true do
attr :href, :string
attr :navigate, :string
attr :patch, :string
end
def breadcrumb(assigns) do
~H"""
<nav class={["sk-breadcrumb", @class]} aria-label="Breadcrumb">
<ol class="sk-crumbs">
<li :for={crumb <- @crumb} class="sk-crumb">
<.link
:if={crumb[:href] || crumb[:navigate] || crumb[:patch]}
href={crumb[:href]}
navigate={crumb[:navigate]}
patch={crumb[:patch]}
class="sk-crumb-link"
>
{render_slot(crumb)}
</.link>
<span
:if={!(crumb[:href] || crumb[:navigate] || crumb[:patch])}
class="sk-crumb-current"
aria-current="page"
>
{render_slot(crumb)}
</span>
</li>
</ol>
</nav>
"""
end
@doc """
An avatar — image with a graceful initials fallback.
<.avatar src={@user.avatar_url} name={@user.name} />
<.avatar name="Ada Lovelace" size="lg" />
`size` is `xs | sm | md | lg | xl`; `shape` is `circle` (default) or `square`
(rounded with `--sk-r`). Initials derive from `name` unless `initials` is set.
"""
attr :src, :string, default: nil
attr :name, :string, default: nil
attr :alt, :string, default: nil
attr :initials, :string, default: nil
attr :size, :string, default: "md", values: ~w(xs sm md lg xl)
attr :shape, :string, default: "circle", values: ~w(circle square)
attr :class, :any, default: nil
attr :rest, :global
def avatar(assigns) do
assigns = assign(assigns, :glyph, assigns.initials || initials(assigns.name))
~H"""
<span
class={[
"sk-avatar",
"sk-avatar--#{@size}",
@shape == "square" && "sk-avatar--square",
@class
]}
role={(!@src && "img") || nil}
aria-label={(!@src && (@alt || @name)) || nil}
{@rest}
>
<img :if={@src} class="sk-avatar-img" src={@src} alt={@alt || @name || ""} />
<span :if={!@src} class="sk-avatar-fallback" aria-hidden="true">{@glyph}</span>
</span>
"""
end
@doc """
A progress bar. Omit `value` for an indeterminate (animated) bar.
<.progress value={70} />
<.progress value={3} max={5} label="Step 3 of 5" />
<.progress />
"""
attr :value, :integer, default: nil
attr :max, :integer, default: 100
attr :label, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
def progress(assigns) do
pct =
if assigns.value,
do: max(0, min(100, round(assigns.value / max(assigns.max, 1) * 100)))
assigns = assign(assigns, :pct, pct)
~H"""
<div
class={["sk-progress", is_nil(@value) && "sk-progress--indeterminate", @class]}
role="progressbar"
aria-valuenow={@value}
aria-valuemin="0"
aria-valuemax={@max}
aria-label={@label}
{@rest}
>
<div class="sk-progress-bar" style={@pct && "width:#{@pct}%"}></div>
</div>
"""
end
@doc """
A loading placeholder with a shimmer. `variant` is `text | circle | rect`.
<.skeleton variant="text" width="60%" />
<.skeleton variant="circle" width="40px" height="40px" />
<.skeleton variant="rect" height="120px" />
"""
attr :variant, :string, default: "text", values: ~w(text circle rect)
attr :width, :string, default: nil
attr :height, :string, default: nil
attr :class, :any, default: nil
attr :rest, :global
def skeleton(assigns) do
assigns = assign(assigns, :style, dims_style(assigns.width, assigns.height))
~H"""
<span
class={["sk-skeleton", "sk-skeleton--#{@variant}", @class]}
style={@style}
aria-hidden="true"
{@rest}
></span>
"""
end
# --- private helpers ------------------------------------------------------
defp initials(nil), do: "?"
defp initials(name) do
name
|> String.split(~r/\s+/, trim: true)
|> Enum.take(2)
|> Enum.map_join(&String.first/1)
|> String.upcase()
|> case do
"" -> "?"
s -> s
end
end
defp dims_style(width, height) do
[width && "width:#{width}", height && "height:#{height}"]
|> Enum.filter(& &1)
|> Enum.join(";")
|> case do
"" -> nil
s -> s
end
end
end