diff --git a/sources/lib/components/station_ui/html/accordion.ex b/sources/lib/components/station_ui/html/accordion.ex new file mode 100644 index 0000000..8a2f888 --- /dev/null +++ b/sources/lib/components/station_ui/html/accordion.ex @@ -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 + + <:content> + Content something 1 + + + + 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 + + <:content> + Content something 1 + + + """ + 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""" +
+
+ <% # Accordion Trigger %> + + + <% # Accordion Content %> + +
+
+ """ + end +end diff --git a/sources/lib/components/station_ui/html/avatars.ex b/sources/lib/components/station_ui/html/avatars.ex new file mode 100644 index 0000000..e2e366f --- /dev/null +++ b/sources/lib/components/station_ui/html/avatars.ex @@ -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_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""" +
+ <.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 %> + + +
+ <%= for {avatar, i} <- Enum.with_index(@avatar), i < @display_max do %> + <%= render_slot(avatar) %> + <% end %> +
+
+ """ + 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""" + + <.avatar {Map.drop(assigns, [:link_class])} /> + + """ + 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""" +
+ {@name + <.avatar_status_badge :if={assigns[:status]} status={@status} /> +
+ """ + end + + def avatar(%{variant: "initials"} = assigns) do + ~H""" +
+
+ + + <%= render_slot(@initials) %> + + <%= @name %> +
+ <.avatar_status_badge :if={assigns[:status]} status={@status} /> +
+ """ + end + + def avatar(%{variant: "placeholder"} = assigns) do + ~H""" +
+ <%= render_slot(@placeholder) || default_avatar_placeholder_icon(assigns) %> + <.avatar_status_badge :if={assigns[:status]} status={@status} /> +
+ """ + 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""" + + <%= @name %> + + + + + + """ + 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 diff --git a/sources/lib/components/station_ui/html/banners.ex b/sources/lib/components/station_ui/html/banners.ex new file mode 100644 index 0000000..4192d63 --- /dev/null +++ b/sources/lib/components/station_ui/html/banners.ex @@ -0,0 +1,72 @@ +defmodule StationUI.HTML.Banners do + use Phoenix.Component + + import StationUI.HTML.Icons, only: [icon: 1] + import StationUI.HTML.Buttons + + alias Phoenix.LiveView.JS + + @base_classes "max-w-[800px] text-[--sui-brand-primary-text] w-full rounded-lg border py-2.5 pl-3" + defp base_classes, do: @base_classes + + @doc """ + The banner component renders an enclosed title, description, and close button. + The title content goes into the main inner_block slot. + The optional secondary (lower) content goes into the secondary slot. + + ## Examples + + Default banner with left icon, title, and secondary text: + + <.banner id="icon-title-and-secondary"> + <.icon name="hero-information-circle-solid" class="text-[--sui-brand-primary] shrink-0" /> +

Default Banner with Icon and Secondary

+ <:secondary> + Secondary text. + + + + Banner of default size but without border: + + <.banner id="no-border" class="border-transparent [&_span]:h-6 [&_span]:w-6 text-base"> + ... + + + Suggested classes for various text sizes and the default border styling: + + - xs -> "border-[--sui-brand-primary-border] [&_span]:h-3.5 [&_span]:w-3.5 text-xs" + - sm -> "border-[--sui-brand-primary-border] [&_span]:h-4.5 [&_span]:w-4.5 text-sm" + - md -> "border-[--sui-brand-primary-border] [&_span]:h-6 [&_span]:w-6 text-base" (the default) + - lg -> "border-[--sui-brand-primary-border] [&_span]:h-9 [&_span]:w-9 text-xl" + - xl -> "border-[--sui-brand-primary-border] [&_span]:h-12 [&_span]:w-12 text-3xl" + """ + + slot :inner_block, required: true + slot :secondary + + attr :id, :string, required: true + attr :class, :any, default: "border-[--sui-brand-primary-border] [&_span]:h-6 [&_span]:w-6 text-base" + attr :on_cancel, JS, default: %JS{} + + def banner(assigns) do + ~H""" +
+
+
+ <%= render_slot(@inner_block) %> +
+ <.button class="sui-secondary min-h-11 border-0 bg-white" aria-label="Dismiss" phx-click={hide_banner(@on_cancel, @id)}> + <.icon name="hero-x-mark" /> + +
+

<%= render_slot(@secondary) %>

+
+ """ + end + + defp hide_banner(js, id) do + js + |> JS.hide(to: "##{id}") + |> JS.pop_focus() + end +end diff --git a/sources/lib/components/station_ui/html/buttons.ex b/sources/lib/components/station_ui/html/buttons.ex new file mode 100644 index 0000000..44bd6f5 --- /dev/null +++ b/sources/lib/components/station_ui/html/buttons.ex @@ -0,0 +1,84 @@ +defmodule StationUI.HTML.Buttons do + use Phoenix.Component + + @moduledoc """ + The button component renders a + """ + end + + defp base_classes do + ~w" + [:where(&)]:rounded-lg + [:where(&)]:text-base + + py-[7px] + bg-[--sui-bg-btn] + border-[--sui-border-btn] + text-[--sui-text-btn] + inline-flex + items-center + justify-center + gap-x-1.5 + whitespace-nowrap + border + px-4 + font-bold + + hover:bg-[--sui-bg-btn-hover] + hover:border-[--sui-border-btn-hover] + hover:text-[--sui-text-btn-hover] + + focus-visible:outline-none + focus-visible:ring-2 + focus-visible:ring-purple-500 + focus-visible:ring-offset-4 + + active:bg-[--sui-bg-btn-active] + active:border-[--sui-border-btn-active] + active:text-[--sui-text-btn-active] + + disabled:bg-[--sui-bg-btn-disabled] + disabled:border-[--sui-border-btn-disabled] + disabled:text-[--sui-text-btn-disabled] + + lg:gap-x-2 + " + end +end diff --git a/sources/lib/components/station_ui/html/cards.ex b/sources/lib/components/station_ui/html/cards.ex new file mode 100644 index 0000000..273b511 --- /dev/null +++ b/sources/lib/components/station_ui/html/cards.ex @@ -0,0 +1,180 @@ +defmodule StationUI.HTML.Cards do + use Phoenix.Component + + @moduledoc """ + The cards component renders a self-contained area of content which can contain: + - Title + - Image + - Description + - Date + - Read More link + + The card can utilize either a vertical or horizontal layout. + + ## Examples + + ### Vertical Card + + <.card> + <:header> + A whale leaps out of the water + + <:content> +
+ +

+ The Whales Are Here! +

+

Nov 12, 2022

+
+

+ Lorem ipsum, dolor sit amet consectetur adipisicing elit. Facilis fugiat, aliquam assumenda repellat rerum nostrum. +

+ + + Read More + + + + + ### Horizontal Card + + <.card_horizontal> + <:header> + A whale leaps out of the water + + <:content> +
+

+ Headline +

+

+ Lorem ipsum, dolor sit amet consectetur adipisicing elit. Facilis fugiat, aliquam assumenda repellat rerum nostrum. +

+
+ +
+ <.button class="w-10 h-10 rounded-full border-0 sui-secondary"> + <.icon name="hero-face-smile-solid" class="w-5 h-5 shrink-0" /> + + <.button class="w-10 h-10 rounded-full border-0 sui-secondary"> + <.icon name="hero-face-smile-solid" class="w-5 h-5 shrink-0" /> + + <.button class="w-10 h-10 rounded-full border-0 sui-secondary"> + <.icon name="hero-face-smile-solid" class="w-5 h-5 shrink-0" /> + +
+ + + """ + + @base_classes "@container min-w-[200px] w-full h-full" + defp base_classes, do: @base_classes + + @base_inner_classes "overflow-hidden drop-shadow-md @[425px]:drop-shadow-lg @[625px]:drop-shadow-xl @[850px]:drop-shadow-2xl rounded-xl w-auto h-full" + defp base_inner_classes, do: @base_inner_classes + + @base_content_classes "grid gap-0.5 @[350px]:gap-1 @[425px]:gap-2 p-2 @[425px]:px-4 @[625px]:px-6 @[625px]:py-3 @[850px]:px-8 @[850px]:py-4" + defp base_content_classes, do: @base_content_classes + + attr :class, :any, default: "" + + slot :header + + slot :content, required: true do + attr :class, :string + end + + def card(assigns) do + ~H""" +
+
+
+ <%= render_slot(header) %> +
+ <.content_card slot={@content} /> +
+
+ """ + end + + attr :slot, :any, required: true + + defp content_card(assigns) do + class = + case assigns.slot do + [%{class: class} | _] -> class + _ -> "bg-white" + end + + assigns = assign(assigns, :class, class) + + ~H""" +
+ <%= render_slot(@slot) %> +
+ """ + end + + @base_horizontal_classes "@container min-w-[200px] w-full h-full" + defp base_horizontal_classes, do: @base_horizontal_classes + + @base_horizontal_inner_classes "overflow-hidden drop-shadow-md @[425px]:drop-shadow-lg @[625px]:drop-shadow-xl @[850px]:drop-shadow-2xl rounded-xl w-full flex" + defp base_horizontal_inner_classes, do: @base_horizontal_inner_classes + + @base_horizontal_content_classes "flex w-full gap-1 py-2 pl-2 @[425px]:py-4 @[425px]:pl-4 @[625px]:py-6 @[625px]:pl-6 @[850px]:py-8 @[850px]:pl-8" + defp base_horizontal_content_classes, do: @base_horizontal_content_classes + + attr :class, :any, default: "" + slot :inner_block, required: true + slot :header + + slot :content do + attr :class, :string + end + + def card_horizontal(assigns) do + ~H""" +
+
+
+ <%= render_slot(header) %> +
+ <.content_card_horizontal slot={@content} /> +
+
+ """ + end + + attr :slot, :any, required: true + + defp content_card_horizontal(assigns) do + class = + case assigns.slot do + [%{class: class} | _] -> class + _ -> "bg-white" + end + + assigns = assign(assigns, :class, class) + + ~H""" +
+ <%= render_slot(@slot) %> +
+ """ + end +end diff --git a/sources/lib/components/station_ui/html/footer.ex b/sources/lib/components/station_ui/html/footer.ex new file mode 100644 index 0000000..5d2d9e1 --- /dev/null +++ b/sources/lib/components/station_ui/html/footer.ex @@ -0,0 +1,224 @@ +defmodule StationUI.HTML.Footer do + use Phoenix.Component + + @moduledoc """ + The Footer component includes "simple" (default), and "columns" variant. + The default variant will list any footer_link slotted in horizontally, while + the columns variant will loop over a grouped list of links under a heading. + + ## Default Footer example + <.footer logo_src={~p"/images/my_logo.png"} logo_alt_text="[Organization name] logo"> + <:footer_link> + <.link patch={~p"/"}> + Home + + + <:footer_link> + <.link href="https://www.foo.com/blog"> + Blog + + + + + ## columns variant + <.footer variant="columns" logo_src={~p"/images/my_logo.png"}> + <:column heading="One"> + <.column_items> + <:footer_link> + <.link patch={~p"/"}> + Home + + + <:footer_link > + <.link href="https://www.foo.com/blog"> + Blog + + + + + <:column heading="Two"> + <.column_items> + <:footer_link> + <.link patch={~p"/"}> + Home + + + <:footer_link> + <.link href="https://www.foo.com/blog"> + Blog + + + + + <./footer> + + ## Both variants accept social links and icons + <.footer> + <:social_icon url="https://www.instagram.com" title="Instagram"> + + ... + + + <:social_icon url="https://www.facebook.com" title="Facebook" class="text-[#1877F2]"> + + ... + + + + """ + + slot :inner_block + + slot :footer_link do + attr :class, :string + end + + slot :column do + attr :heading, :string, required: true + end + + slot :social_icon do + attr :url, :string, required: true + attr :title, :string, required: true + attr :class, :string + end + + attr :variant, :string, default: "simple", values: ~w[simple columns] + attr :logo_src, :string, default: nil + attr :logo_alt_text, :string, default: "" + attr :legal_text, :string, default: "© #{DateTime.utc_now().year} Your Company, Inc. All rights reserved." + + def footer(%{variant: "simple"} = assigns) do + ~H""" + + """ + end + + def footer(%{variant: "columns"} = assigns) do + ~H""" + + """ + end + + slot :footer_link do + attr :class, :string + end + + def column_items(assigns) do + ~H""" + + """ + end + + defp footer_link_base_classes do + ~w" + font-bold + text-4xl + [&_a]:rounded-lg + [&_a:hover]:underline + [&_a:hover]:underline-offset-8 + [&_a:focus-visible]:outline-none + [&_a:focus-visible]:ring-4 + [&_a:focus-visible]:ring-purple-500 + [&_a:focus-visible]:ring-offset-4 + [&_a:focus-visible]:ring-offset-[--sui-brand-secondary-bg] + " + end + + defp social_icons_base_classes do + ~w" + [&_a]:block + [&_a]:rounded-lg + [&_a:focus-visible]:outline-none + [&_a:focus-visible]:ring-4 + [&_a:focus-visible]:ring-purple-500 + [&_a:focus-visible]:ring-offset-4 + [&_a:focus-visible]:ring-offset-[--sui-brand-secondary-bg] + " + end +end diff --git a/sources/lib/components/station_ui/html/forms.ex b/sources/lib/components/station_ui/html/forms.ex new file mode 100644 index 0000000..07e0e46 --- /dev/null +++ b/sources/lib/components/station_ui/html/forms.ex @@ -0,0 +1,100 @@ +defmodule StationUI.HTML.Forms do + @moduledoc """ + This module exists to provide the same API as the Phoenix Core Components so as to support + generators that target the Core Components (`mix phx.gen.live`, `mix phx.gen.auth`, etc...) + """ + use Phoenix.Component + alias StationUI.HTML.Inputs + + attr :id, :any, default: nil + attr :name, :any + attr :label, :string, default: nil + attr :value, :any + + attr :type, :string, + default: "text", + values: ~w(checkbox color date datetime-local email file hidden month number password + range radio search select tel text textarea time url week) + + attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" + + attr :errors, :list, default: [] + attr :checked, :boolean, doc: "the checked flag for checkbox inputs" + attr :prompt, :string, default: nil, doc: "the prompt for select inputs" + attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" + attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" + + attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength + multiple pattern placeholder readonly required rows size step) + + slot :inner_block + + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + assigns + |> assign(field: nil, id: assigns.id || field.id) + |> assign(:errors, Enum.map(field.errors, &translate_error(&1))) + |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) + |> assign_new(:value, fn -> field.value end) + |> input() + end + + def input(%{type: "checkbox"} = assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + def input(assigns) do + ~H""" + + <:label :if={@label}><%= @label %> + + """ + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(StationUI.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(StationUI.Gettext, "errors", msg, opts) + end + end + + @doc """ + Translates the errors for a field from a keyword list of errors. + """ + def translate_errors(errors, field) when is_list(errors) do + for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) + end +end diff --git a/sources/lib/components/station_ui/html/icons.ex b/sources/lib/components/station_ui/html/icons.ex new file mode 100644 index 0000000..4c54de5 --- /dev/null +++ b/sources/lib/components/station_ui/html/icons.ex @@ -0,0 +1,28 @@ +defmodule StationUI.HTML.Icons do + use Phoenix.Component + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> + """ + attr(:name, :string, required: true) + attr(:class, :any, default: nil) + attr(:rest, :global) + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" +