-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
1f1fe72
commit 4318505
Showing
23 changed files
with
3,537 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
defmodule StationUI.HTML.Accordion do | ||
use Phoenix.Component | ||
|
||
import StationUI.HTML.Icons, only: [icon: 1] | ||
alias Phoenix.LiveView.JS | ||
|
||
@moduledoc """ | ||
The accordion component renders a list of items with child content that can be expanded or collapsed. | ||
## Example | ||
<.accordion_set> | ||
<:header> | ||
Title something 1 | ||
</:header> | ||
<:content> | ||
Content something 1 | ||
</:content> | ||
</.accordion_set> | ||
Suggested size classes | ||
The Default size for accordions is "md" but the size can be change by passing in these additional classes | ||
using `header_size_class="..."` and `content_size_class="..."` as follows | ||
header_size_class: | ||
sm: "p-1 text-base sm:text-lg gap-x-0.5" | ||
md: "p-1 text-base sm:text-lg md:text-xl md:py-1 md:pr-1 md:pl-1.5 md:gap-x-1" | ||
lg: "p-1 text-base sm:text-lg md:text-xl lg:text-2xl md:py-1 md:pr-1 md:pl-1.5 lg:pl-2 md:gap-x-1 lg:gap-x-1.5" | ||
xl: "p-1 text-base sm:text-lg md:text-xl lg:text-2xl xl:text-3xl md:pt-1 md:pb-0 md:pr-1 md:pl-1.5 lg:pl-4 sm:gap-x-3 md:gap-x-4 lg:gap-x-5" | ||
content_size_class: | ||
sm: "text-base" | ||
md: "grid transition-grid-rows text-base md:text-lg" | ||
lg: "md:text-lg lg:text-xl" | ||
xl: "md:text-lg lg:text-xl xl:text-2xl" | ||
""" | ||
|
||
def accordion(assigns) do | ||
~H""" | ||
<.accordion_set> | ||
<:header> | ||
Title something 1 | ||
</:header> | ||
<:content> | ||
Content something 1 | ||
</:content> | ||
</.accordion_set> | ||
""" | ||
end | ||
|
||
slot :header, required: true do | ||
attr :button_id, :string | ||
end | ||
|
||
slot :content, required: true | ||
attr :header_size_class, :string, default: "text-base sm:text-lg md:text-xl" | ||
attr :content_size_class, :string, default: "text-base md:text-lg" | ||
attr :rest, :global | ||
|
||
def accordion_set(assigns) do | ||
assigns = | ||
assigns | ||
|> assign(:header, List.wrap(assigns.header)) | ||
|> assign(:content, List.wrap(assigns.content)) | ||
|> assign(:random_id, :rand.uniform(9999)) | ||
|> assign(:items, Enum.with_index(Enum.zip(List.wrap(assigns.header), List.wrap(assigns.content)))) | ||
|
||
~H""" | ||
<div class="grid gap-2"> | ||
<div :for={{{header, content}, index} <- @items} class="relative grid gap-1"> | ||
<% # Accordion Trigger %> | ||
<button | ||
id={Map.get(header, :button_id)} | ||
class={[ | ||
"group flex w-full min-w-min cursor-pointer items-center whitespace-nowrap bg-transparent font-semibold outline-none transition hover:bg-slate-50 focus-visible:ring-4 focus-visible:ring-purple-600 focus-visible:rounded-lg active:bg-slate-50 disabled:bg-slate-50 disabled:text-slate-300", | ||
"pl-3 pr-2 py-1 gap-x-2 md:pl-4 md:pr-3 md:gap-x-4", | ||
@header_size_class | ||
]} | ||
type="button" | ||
id={"accordion-trigger-#{@random_id}-#{index}"} | ||
aria-controls={"accordion-#{@random_id}-#{index}"} | ||
aria-expanded="false" | ||
phx-click={ | ||
JS.toggle_attribute({"aria-expanded", "true", "false"}) | ||
|> then(fn js -> | ||
Enum.reduce(Enum.to_list(0..(length(@items) - 1)) -- [index], js, fn item_index, js -> | ||
js | ||
|> JS.set_attribute({"aria-expanded", "false"}, to: "#accordion-trigger-#{@random_id}-#{item_index}") | ||
|> JS.hide(to: "#accordion-#{@random_id}-#{item_index}", transition: "fade-out-scale") | ||
|> JS.remove_class("rotate-180", to: "#accordion-chevron-#{@random_id}-#{item_index}") | ||
end) | ||
end) | ||
|> JS.focus(to: "#accordion-#{@random_id}-#{index}") | ||
|> JS.toggle(to: "#accordion-#{@random_id}-#{index}", in: "fade-in-scale", out: "fade-out-scale", display: "grid") | ||
|> JS.toggle_class("rotate-180", to: "#accordion-chevron-#{@random_id}-#{index}") | ||
} | ||
> | ||
<span class="flex h-10 w-10 items-center justify-center text-violet-600 group-disabled:text-slate-300 md:h-12 md:w-12"> | ||
<.icon name="hero-folder-solid" aria-hidden="true" class="h-8 w-8 md:h-10 md:w-10" /> | ||
</span> | ||
<span class="text-xl lg:text-2xl"><%= render_slot(header) %></span> | ||
<span | ||
aria-hidden="true" | ||
class="flex justify-center items-center ml-auto [&_path]:transition-transform h-6 w-6 sm:h-[34px] sm:w-[34px] md:h-10 md:w-10" | ||
> | ||
<svg | ||
id={"accordion-chevron-#{@random_id}-#{index}"} | ||
xmlns="http://www.w3.org/2000/svg" | ||
width="38" | ||
height="38" | ||
viewBox="0 0 38 38" | ||
fill="none" | ||
class="h-4 w-4 rotate-0 fill-slate-800 transition-transform duration-300 sm:h-5 sm:w-5 md:h-6 md:w-6" | ||
> | ||
<path | ||
fill-rule="evenodd" | ||
clip-rule="evenodd" | ||
d="M27.9435 24.1436C27.2015 24.8855 25.9985 24.8855 25.2565 24.1436L19 17.8871L12.7435 24.1436C12.0015 24.8855 10.7985 24.8855 10.0565 24.1436C9.3145 23.4016 9.3145 22.1985 10.0565 21.4565L17.6565 13.8565C18.3985 13.1145 19.6015 13.1145 20.3435 13.8565L27.9435 21.4565C28.6855 22.1985 28.6855 23.4016 27.9435 24.1436Z" | ||
/> | ||
</svg> | ||
</span> | ||
</button> | ||
<% # Accordion Content %> | ||
<div id={"accordion-#{@random_id}-#{index}"} class={["grid px-3 pt-1 hidden transition-grid-rows md:px-4", @content_size_class]} role="region"> | ||
<div class="overflow-hidden"> | ||
<%= render_slot(content) %> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
""" | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,239 @@ | ||
defmodule StationUI.HTML.Avatars do | ||
use Phoenix.Component | ||
|
||
import StationUI.HTML.StatusBadges, only: [status_badge: 1] | ||
|
||
@moduledoc """ | ||
The avatar component renders initials, an SVG, or an image thumbnail to represent a user. | ||
Avatars can be displayed as single items or combined into a horizontal stack. | ||
Sets up an avatar stack. | ||
## Stack Example | ||
<.avatar_stack overflow_link={~p"/avatars/link"} display_max={2}> | ||
<:avatar> | ||
<.avatar .../> | ||
</:avatar> | ||
<:avatar> | ||
<.avatar .../> | ||
</:avatar> | ||
<:avatar> | ||
<.avatar .../> | ||
</:avatar> | ||
<:avatar> | ||
<.avatar .../> | ||
</:avatar> | ||
<.avatar_stack> | ||
""" | ||
@stack_base_classes [ | ||
"flex items-start [&_div]:flex [&_div]:flex-row-reverse", | ||
"[&>a]:z-20 [&>a]:hover:z-40 [&_div_a_figure]:z-10", | ||
"[&_div_a]:hover:z-30 [&_a:focus-visible]:z-50 [&_a]:active:z-50 [&_a:hover_figure]:ml-0 [&_a:focus-visible_figure]:ml-0" | ||
] | ||
|
||
def stack_base_classes, do: @stack_base_classes | ||
|
||
attr(:class, :any, default: "[&_div]:ml-1.5 [&_div_figure]:-ml-3.5") | ||
attr(:display_max, :integer, default: 3) | ||
attr(:total_count, :integer, default: nil) | ||
attr(:overflow_link, :string, required: true) | ||
|
||
slot(:avatar) | ||
|
||
def avatar_stack(assigns) do | ||
assigns = | ||
assigns | ||
|> assign(:total_count, assigns.total_count || length(assigns.avatar)) | ||
|
||
~H""" | ||
<div class={[stack_base_classes() | List.wrap(@class)]}> | ||
<.avatar_link :if={@total_count > @display_max} to={@overflow_link} variant="initials" class="h-[42px] w-[42px] border-[--sui-brand-primary-border]"> | ||
<:initials count={true}>+<%= @total_count - @display_max %></:initials> | ||
</.avatar_link> | ||
<div> | ||
<%= for {avatar, i} <- Enum.with_index(@avatar), i < @display_max do %> | ||
<%= render_slot(avatar) %> | ||
<% end %> | ||
</div> | ||
</div> | ||
""" | ||
end | ||
|
||
@doc """ | ||
An avatar that links somewhere. | ||
## Example | ||
<.avatar_link to={~p"/some/link"} variant="placeholder" /> | ||
""" | ||
@link_base_classes "rounded-full outline-none transition hover:ring-2 hover:ring-[--sui-brand-primary-muted] focus-visible:ring-[--sui-brand-primary-focus] focus-visible:ring-offset-4 active:ring-[--sui-brand-primary]" | ||
|
||
def link_base_classes, do: @link_base_classes | ||
|
||
attr(:status, :string, values: ~w[active inactive deactivated pending]) | ||
attr(:variant, :string, values: ~w[image initials placeholder]) | ||
attr(:index, :integer) | ||
attr(:name, :string, default: nil) | ||
attr(:image_src, :string, default: nil) | ||
attr(:to, :string, required: true) | ||
attr(:link_class, :any, default: "focus-visible:ring-2 active:ring-1") | ||
attr(:class, :any, default: nil) | ||
|
||
# These are all passed through. | ||
slot :initials do | ||
attr(:count, :boolean) | ||
end | ||
|
||
slot(:placeholder) | ||
|
||
def avatar_link(assigns) do | ||
assigns = | ||
case assigns do | ||
%{class: nil} = assigns -> Map.drop(assigns, [:class]) | ||
assigns -> assigns | ||
end | ||
|
||
~H""" | ||
<a href={@to} class={[link_base_classes() | List.wrap(@link_class)]}> | ||
<.avatar {Map.drop(assigns, [:link_class])} /> | ||
</a> | ||
""" | ||
end | ||
|
||
@doc """ | ||
A single avatar | ||
## Examples | ||
Avatar with initials, a border, and an active status icon: | ||
<.avatar variant="initials" status="active" class="h-[42px] w-[42px] border-[--sui-brand-primary]" /> | ||
Avatar with placeholder image with a pending status icon: | ||
<.avatar variant="placeholder" status="pending" /> | ||
Suggested classes for various sizes: | ||
- xs -> "h-6 w-6 [&_svg]:w-3 text-xs" | ||
- sm -> "h-8 w-8 [&_svg]:w-4 text-sm" | ||
- md -> "h-[42px] w-[42px] [&_svg]:w-[21px]" (default) | ||
- lg -> "h-[52px] w-[52px] [&_svg]:w-[26px] text-lg" | ||
- xl -> "h-16 w-16 [&_svg]:w-8 text-lg" | ||
""" | ||
@figure_base_classes "relative flex items-center justify-center border rounded-full bg-slate-50 transition-all duration-200 font-sans font-medium uppercase text-[--sui-brand-primary]" | ||
|
||
def figure_base_classes, do: @figure_base_classes | ||
|
||
attr(:status, :string, values: ~w[active inactive deactivated pending]) | ||
attr(:variant, :string, values: ~w[image initials placeholder]) | ||
attr(:index, :integer) | ||
attr(:name, :string, default: nil) | ||
attr(:image_src, :string, default: "") | ||
attr(:class, :any, default: "h-[42px] w-[42px] [&_svg]:w-[21px] border-transparent") | ||
|
||
slot :initials do | ||
attr(:count, :boolean) | ||
end | ||
|
||
# We may have to deal with applying styles to placeholders? | ||
slot(:placeholder) | ||
|
||
def avatar(%{variant: "image"} = assigns) do | ||
~H""" | ||
<figure class={[figure_base_classes() | List.wrap(@class)]}> | ||
<img class="h-full w-full rounded-full object-cover" src={@image_src} alt={@name || ""} /> | ||
<.avatar_status_badge :if={assigns[:status]} status={@status} /> | ||
</figure> | ||
""" | ||
end | ||
|
||
def avatar(%{variant: "initials"} = assigns) do | ||
~H""" | ||
<figure class={[figure_base_classes(), @class]}> | ||
<figcaption> | ||
<span aria-hidden="true"> | ||
<%= render_slot(@initials) || initials_from_name(@name || "") %> | ||
</span> | ||
<span :for={initials <- @initials} :if={initials[:count]} class="sr-only"> | ||
<%= render_slot(@initials) %> | ||
</span> | ||
<span :if={@name} class="sr-only"><%= @name %></span> | ||
</figcaption> | ||
<.avatar_status_badge :if={assigns[:status]} status={@status} /> | ||
</figure> | ||
""" | ||
end | ||
|
||
def avatar(%{variant: "placeholder"} = assigns) do | ||
~H""" | ||
<figure class={[figure_base_classes(), @class]}> | ||
<%= render_slot(@placeholder) || default_avatar_placeholder_icon(assigns) %> | ||
<.avatar_status_badge :if={assigns[:status]} status={@status} /> | ||
</figure> | ||
""" | ||
end | ||
|
||
defp initials_from_name(name) do | ||
String.split(name) |> Enum.map_join(&String.first/1) | ||
end | ||
|
||
@doc """ | ||
The default placeholder icon for a placeholder variant of an avatar. | ||
""" | ||
attr(:name, :string, default: nil) | ||
|
||
def default_avatar_placeholder_icon(assigns) do | ||
~H""" | ||
<svg | ||
class="h-auto self-end" | ||
role="img" | ||
width="32" | ||
height="52" | ||
viewBox="0 0 32 52" | ||
fill="none" | ||
xmlns="http://www.w3.org/2000/svg" | ||
aria-label={@name || "Avatar Image"} | ||
> | ||
<title :if={@name}><%= @name %></title> | ||
<circle class="fill-gray-300" cx="16" cy="16" r="16" /> | ||
<path class="fill-gray-400" opacity="0.6" d="M16 0C7.16344 0 0 7.16344 0 16C4 16 6 14 8 12C11.2 20 30 20 32 16C32 7.16344 24.8366 0 16 0Z" /> | ||
<path | ||
class="fill-gray-300" | ||
d="M0.00195312 47.7202C0.151367 39.0127 7.25684 32 16 32C24.7432 32 31.8486 39.0127 31.998 47.7202C27.292 50.4421 21.8271 52 16 52C10.1729 52 4.70801 50.4421 0.00195312 47.7202Z" | ||
/> | ||
<path | ||
class="fill-gray-400" | ||
opacity="0.4" | ||
d="M27.9998 37.4164C25.0679 34.0949 20.7785 32 15.9998 32C14.206 32 12.4811 32.2952 10.8711 32.8397C16.3681 38.3114 24.5807 38.1707 27.9998 37.4164Z" | ||
fill="#A5B4FC" | ||
/> | ||
</svg> | ||
""" | ||
end | ||
|
||
@doc """ | ||
An avatar-specific status icon. | ||
""" | ||
attr(:status, :string, required: true, values: ~w[active inactive deactivated pending]) | ||
attr(:class, :any, default: nil, doc: "additional or overriding classes") | ||
|
||
def avatar_status_badge(assigns) do | ||
~H""" | ||
<.status_badge | ||
:if={@status} | ||
status={@status} | ||
class={[ | ||
"absolute -right-px -bottom-px z-10 transition-opacity duration-200", | ||
"after:absolute after:inset-0", | ||
"after:h-full after:w-full after:rounded-full", | ||
"w-3 [&>span]:w-0.5" | ||
]} | ||
/> | ||
""" | ||
end | ||
end |
Oops, something went wrong.