diff --git a/lib/mix/tasks/station_ui.install.ex b/lib/mix/tasks/station_ui.install.ex
index 7673a7f..ce7c130 100644
--- a/lib/mix/tasks/station_ui.install.ex
+++ b/lib/mix/tasks/station_ui.install.ex
@@ -6,7 +6,7 @@ defmodule Mix.Tasks.StationUi.Install do
@assets_subpath "assets"
- @station_ui_subpath "station_ui"
+ @station_ui_subpath "components/station_ui"
@sources_root_path __DIR__ |> Path.join("../../../sources") |> Path.expand()
@@ -131,7 +131,7 @@ defmodule Mix.Tasks.StationUi.Install do
end
defp template_source_destination_file_path(context, source_file_path) do
- installer_lib = Path.join(@sources_root_path, "lib/station_ui")
+ installer_lib = Path.join(@sources_root_path, "lib/components/station_ui")
relative_source_path = Path.relative_to(source_file_path, installer_lib)
Path.join([context.web_path, @station_ui_subpath, relative_source_path])
@@ -191,7 +191,7 @@ defmodule Mix.Tasks.StationUi.Install do
String.replace(tailwind_content, module_exports_line, module_exports_line <> sui_preset)
File.write!(tailwind_config_path, tailwind_content)
- IO.puts("Updated #{tailwind_config_path}")
+ Mix.shell().info("Updated #{tailwind_config_path}")
app_css_path = Path.join(context.web_assets_path, "css/app.css")
app_css_content = File.read!(app_css_path)
@@ -201,31 +201,38 @@ defmodule Mix.Tasks.StationUi.Install do
~s|@import "./station-ui.css";\n@import "./station-ui-fonts.css";\n| <> app_css_content
)
- IO.puts("Updated #{app_css_path}")
+ Mix.shell().info("Updated #{app_css_path}")
web_app_ex_path = PathContext.web_app_ex_path(context)
web_app_ex_content = File.read!(web_app_ex_path)
+ core_components_import = "import #{context.web_app_name}.CoreComponents"
web_app_ex_content =
String.replace(
web_app_ex_content,
- "import #{context.web_app_name}.CoreComponents",
- "use #{context.web_app_name}.StationUI.HTML"
+ core_components_import,
+ "# #{core_components_import}\n use #{context.web_app_name}.StationUI.HTML"
)
File.write!(web_app_ex_path, web_app_ex_content)
- IO.puts("Updated #{web_app_ex_path}")
-
- core_components_path = Path.join(context.web_path, "components/core_components.ex")
- [first | rest] = File.read!(core_components_path) |> String.trim() |> String.split("\n")
- last = List.last(rest)
- File.rm!(core_components_path)
-
- File.write(
- core_components_path,
- Enum.join([first, " # Replaced by StationUI components", last], "\n")
- )
+ Mix.shell().info("Updated #{web_app_ex_path}")
+
+ if Mix.shell().yes?(
+ "StationUI is designed to replace the default Phoenix CoreComponents. Ok to clear out the existing CoreComponents file? Unless you made custom changes to this file that you still want, this should be fine to do."
+ ) do
+ core_components_path = Path.join(context.web_path, "components/core_components.ex")
+ [first | rest] = File.read!(core_components_path) |> String.trim() |> String.split("\n")
+ last = List.last(rest)
+ File.rm!(core_components_path)
+
+ File.write(
+ core_components_path,
+ Enum.join([first, " # Replaced by StationUI components, do not remove or phoenix generators will recreate the full file", last], "\n")
+ )
- IO.puts("Replaced #{core_components_path}")
+ Mix.shell().info("Replaced #{core_components_path}")
+ else
+ Mix.shell().info("Skipping replacing CoreComponents")
+ end
end
end
diff --git a/sources/lib/station_ui/HTML/accordion.ex b/sources/lib/components/station_ui/HTML/accordion.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/accordion.ex
rename to sources/lib/components/station_ui/HTML/accordion.ex
diff --git a/sources/lib/station_ui/HTML/avatars.ex b/sources/lib/components/station_ui/HTML/avatars.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/avatars.ex
rename to sources/lib/components/station_ui/HTML/avatars.ex
diff --git a/sources/lib/station_ui/HTML/banners.ex b/sources/lib/components/station_ui/HTML/banners.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/banners.ex
rename to sources/lib/components/station_ui/HTML/banners.ex
diff --git a/sources/lib/station_ui/HTML/buttons.ex b/sources/lib/components/station_ui/HTML/buttons.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/buttons.ex
rename to sources/lib/components/station_ui/HTML/buttons.ex
diff --git a/sources/lib/station_ui/HTML/cards.ex b/sources/lib/components/station_ui/HTML/cards.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/cards.ex
rename to sources/lib/components/station_ui/HTML/cards.ex
diff --git a/sources/lib/station_ui/HTML/footer.ex b/sources/lib/components/station_ui/HTML/footer.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/footer.ex
rename to sources/lib/components/station_ui/HTML/footer.ex
diff --git a/sources/lib/station_ui/HTML/forms.ex b/sources/lib/components/station_ui/HTML/forms.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/forms.ex
rename to sources/lib/components/station_ui/HTML/forms.ex
diff --git a/sources/lib/station_ui/HTML/icons.ex b/sources/lib/components/station_ui/HTML/icons.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/icons.ex
rename to sources/lib/components/station_ui/HTML/icons.ex
diff --git a/sources/lib/station_ui/HTML/inputs.ex b/sources/lib/components/station_ui/HTML/inputs.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/inputs.ex
rename to sources/lib/components/station_ui/HTML/inputs.ex
diff --git a/sources/lib/station_ui/HTML/legacy_core_components.ex b/sources/lib/components/station_ui/HTML/legacy_core_components.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/legacy_core_components.ex
rename to sources/lib/components/station_ui/HTML/legacy_core_components.ex
diff --git a/sources/lib/station_ui/HTML/modals.ex b/sources/lib/components/station_ui/HTML/modals.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/modals.ex
rename to sources/lib/components/station_ui/HTML/modals.ex
diff --git a/sources/lib/station_ui/HTML/navbar.ex b/sources/lib/components/station_ui/HTML/navbar.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/navbar.ex
rename to sources/lib/components/station_ui/HTML/navbar.ex
diff --git a/sources/lib/station_ui/HTML/notification_badges.ex b/sources/lib/components/station_ui/HTML/notification_badges.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/notification_badges.ex
rename to sources/lib/components/station_ui/HTML/notification_badges.ex
diff --git a/sources/lib/station_ui/HTML/pagination.ex b/sources/lib/components/station_ui/HTML/pagination.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/pagination.ex
rename to sources/lib/components/station_ui/HTML/pagination.ex
diff --git a/sources/lib/station_ui/HTML/spinners.ex b/sources/lib/components/station_ui/HTML/spinners.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/spinners.ex
rename to sources/lib/components/station_ui/HTML/spinners.ex
diff --git a/sources/lib/station_ui/HTML/status_badges.ex b/sources/lib/components/station_ui/HTML/status_badges.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/status_badges.ex
rename to sources/lib/components/station_ui/HTML/status_badges.ex
diff --git a/sources/lib/station_ui/HTML/tab_group.ex b/sources/lib/components/station_ui/HTML/tab_group.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/tab_group.ex
rename to sources/lib/components/station_ui/HTML/tab_group.ex
diff --git a/sources/lib/station_ui/HTML/table_cell.ex b/sources/lib/components/station_ui/HTML/table_cell.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/table_cell.ex
rename to sources/lib/components/station_ui/HTML/table_cell.ex
diff --git a/sources/lib/station_ui/HTML/table_header.ex b/sources/lib/components/station_ui/HTML/table_header.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/table_header.ex
rename to sources/lib/components/station_ui/HTML/table_header.ex
diff --git a/sources/lib/station_ui/HTML/tags.ex b/sources/lib/components/station_ui/HTML/tags.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/tags.ex
rename to sources/lib/components/station_ui/HTML/tags.ex
diff --git a/sources/lib/station_ui/HTML/toast.ex b/sources/lib/components/station_ui/HTML/toast.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/toast.ex
rename to sources/lib/components/station_ui/HTML/toast.ex
diff --git a/sources/lib/station_ui/HTML/toolbars.ex b/sources/lib/components/station_ui/HTML/toolbars.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/toolbars.ex
rename to sources/lib/components/station_ui/HTML/toolbars.ex
diff --git a/sources/lib/station_ui/HTML/tooltips.ex b/sources/lib/components/station_ui/HTML/tooltips.ex
similarity index 100%
rename from sources/lib/station_ui/HTML/tooltips.ex
rename to sources/lib/components/station_ui/HTML/tooltips.ex
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 %>
+
+
+ <%= render_slot(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"""
+
+ """
+ end
+
+ def avatar(%{variant: "initials"} = assigns) do
+ ~H"""
+
+ """
+ end
+
+ def avatar(%{variant: "placeholder"} = assigns) do
+ ~H"""
+
+ """
+ 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"""
+
+ """
+ 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