diff --git a/lib/salad_ui.ex b/lib/salad_ui.ex index 9076900..1218c16 100644 --- a/lib/salad_ui.ex +++ b/lib/salad_ui.ex @@ -8,8 +8,9 @@ defmodule SaladUI do # alias OrangeCmsWeb.Components.LadUI.LadJS alias Phoenix.LiveView.JS + defp classes(input) do - SaladUI.Merge.merge(input) + TwMerge.merge(input) end end end @@ -23,33 +24,43 @@ defmodule SaladUI do defmacro __using__(_) do quote do + import SaladUI.Accordion import SaladUI.Alert + import SaladUI.AlertDialog import SaladUI.Avatar import SaladUI.Badge import SaladUI.Breadcrumb import SaladUI.Button import SaladUI.Card + import SaladUI.Chart import SaladUI.Checkbox + import SaladUI.Collapsible import SaladUI.Dialog import SaladUI.DropdownMenu import SaladUI.Form + import SaladUI.Helpers import SaladUI.HoverCard import SaladUI.Icon import SaladUI.Input import SaladUI.Label import SaladUI.Menu import SaladUI.Pagination + import SaladUI.Popover import SaladUI.Progress + import SaladUI.RadioGroup import SaladUI.ScrollArea import SaladUI.Select import SaladUI.Separator import SaladUI.Sheet + import SaladUI.Sidebar import SaladUI.Skeleton import SaladUI.Slider import SaladUI.Switch import SaladUI.Table import SaladUI.Tabs import SaladUI.Textarea + import SaladUI.Toggle + import SaladUI.ToggleGroup import SaladUI.Tooltip end end diff --git a/lib/salad_ui/alert.ex b/lib/salad_ui/alert.ex index 8356497..690a8b3 100644 --- a/lib/salad_ui/alert.ex +++ b/lib/salad_ui/alert.ex @@ -86,7 +86,8 @@ defmodule SaladUI.Alert do @variants %{ variant: %{ "default" => "bg-background text-foreground", - "destructive" => "bg-background border-destructive/50 text-destructive dark:border-destructive [&>span]:text-destructive" + "destructive" => + "bg-background border-destructive/50 text-destructive dark:border-destructive [&>span]:text-destructive" } } diff --git a/lib/salad_ui/collapsible.ex b/lib/salad_ui/collapsible.ex index ec6380f..3169496 100644 --- a/lib/salad_ui/collapsible.ex +++ b/lib/salad_ui/collapsible.ex @@ -22,24 +22,25 @@ defmodule SaladUI.Collapsible do required: true, doc: "Id to identify collapsible component, collapsible_trigger uses this id to toggle content visibility" - attr :open, :boolean, default: false, doc: "Initial state of collapsible content" + attr :open, :boolean, default: true, doc: "Initial state of collapsible content" attr :class, :string, default: nil + attr :rest, :global, include: ~w(title) slot(:inner_block, required: true) def collapsible(assigns) do assigns = - assigns - |> assign(:builder, %{open: assigns[:open], id: assigns[:id]}) - |> assign(:open, normalize_boolean(assigns[:open])) + assign(assigns, :open, normalize_boolean(assigns[:open])) ~H"""
- <%= render_slot(@inner_block, @builder) %> + <%= render_slot(@inner_block) %>
""" end @@ -47,15 +48,22 @@ defmodule SaladUI.Collapsible do @doc """ Render trigger for collapsible component. """ - attr :builder, :map, required: true, doc: "Builder instance for collapsible component" attr(:class, :string, default: nil) + attr :as_tag, :any, default: "div" + attr :rest, :global slot(:inner_block, required: true) + def collapsible_trigger(assigns) do ~H""" -
@builder.id)} class={@class}> + <.dynamic + tag={@as_tag} + onclick={exec_closest("phx-toggle-collapsible", ".collapsible-root")} + class={@class} + {@rest} + > <%= render_slot(@inner_block) %> -
+ """ end @@ -85,12 +93,14 @@ defmodule SaladUI.Collapsible do @doc """ Show collapsible content. """ - def toggle_collapsible(js \\ %JS{}, %{id: id} = _builder) do - JS.toggle(js, + def toggle_collapsible(js \\ %JS{}, id) do + js + |> JS.toggle( to: "##{id} .collapsible-content", in: {"ease-out duration-200", "opacity-0", "opacity-100"}, out: {"ease-out", "opacity-100", "opacity-70"}, time: 200 ) + |> JS.toggle_attribute({"data-state", "open", "closed"}, to: "##{id}") end end diff --git a/lib/salad_ui/dropdown_menu.ex b/lib/salad_ui/dropdown_menu.ex index 7e14efe..addf10d 100644 --- a/lib/salad_ui/dropdown_menu.ex +++ b/lib/salad_ui/dropdown_menu.ex @@ -50,12 +50,15 @@ defmodule SaladUI.DropdownMenu do end attr :class, :string, default: nil + attr :as_tag, :any, default: "div" slot :inner_block, required: true + attr :rest, :global def dropdown_menu_trigger(assigns) do ~H""" -
<%= render_slot(@inner_block) %> -
+ """ end @@ -94,6 +97,29 @@ defmodule SaladUI.DropdownMenu do """ end + @doc """ + Render + """ + attr(:class, :string, default: nil) + attr(:rest, :global) + slot(:inner_block, required: true) + + def dropdown_menu_shortcut(assigns) do + ~H""" + + <%= render_slot(@inner_block) %> + + """ + end + defp toggle(js \\ %JS{}) do JS.toggle_attribute(js, {"data-state", "open", "closed"}) end diff --git a/lib/salad_ui/helpers.ex b/lib/salad_ui/helpers.ex index 0b73c5c..ee42d2c 100644 --- a/lib/salad_ui/helpers.ex +++ b/lib/salad_ui/helpers.ex @@ -1,5 +1,7 @@ defmodule SaladUI.Helpers do @moduledoc false + use Phoenix.Component + import Phoenix.Component @doc """ @@ -48,6 +50,16 @@ defmodule SaladUI.Helpers do end end + @doc """ + Normalize id to be used in HTML id attribute + It will replace all non-alphanumeric characters with `-` and downcase the string + """ + def id(id) do + id + |> String.replace(~r/[^a-zA-Z0-9]/, "-") + |> String.downcase() + end + @doc """ Variant helper for generating classes based on side and align """ @@ -123,6 +135,170 @@ defmodule SaladUI.Helpers do "#{shared_classes} #{variation_classes}" end + @doc """ + Common function for building variant + + ## Examples + + ```elixir + config = + %{ + variants: %{ + variant: %{ + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: %{ + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", + }, + }, + default_variants: %{ + variant: "default", + size: "default", + }, + } + + class_input = %{variant: "outline", size: "lg"} + variant_class(config, class_input) + ``` + + """ + def variant_class(config, class_input) do + variants = Map.get(config, :variants, %{}) + default_variants = Map.get(config, :default_variants, %{}) + + variants + |> Map.keys() + |> Enum.map(fn variant_key -> + # Get the variant value from input or use default + variant_value = + Map.get(class_input, variant_key) || + Map.get(default_variants, variant_key) + + # Get the variant options map + variant_options = Map.get(variants, variant_key, %{}) + + # Get the CSS classes for this variant value + Map.get(variant_options, String.to_existing_atom(variant_value)) + end) + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + end + + @doc """ + This function build css style string from map of css style + + ## Examples + + ```elixir + css_style = %{ + "background-color": "red", + "color": "white", + "font-size": "16px", + } + + style(css_style) + + # => "background-color: red; color: white; font-size: 16px;" + ``` + """ + def style(items) when is_list(items) do + {acc_map, acc_list} = + Enum.reduce(items, {%{}, []}, fn item, {acc_map, acc_list} -> + cond do + is_map(item) -> + {Map.merge(acc_map, item), acc_list} + + is_binary(item) -> + {acc_map, [item | acc_list]} + + true -> + {acc_map, [item | acc_list]} + end + end) + + style = Enum.map_join(acc_map, "; ", fn {k, v} -> "#{k}: #{v}" end) <> ";" + Enum.join([style | acc_list], "; ") + end + + @doc """ + This function build js script to invoke JS stored in given attribute. + Similar to JS.exec/2 but this function target the nearest ancestor element. + + ## Examples + + ```heex + + ``` + """ + def exec_closest(attribute, ancestor_selector) do + """ + var el = this.closest("#{ancestor_selector}"); liveSocket.execJS(el, el.getAttribute("#{attribute}")); + """ + end + + @doc """ + This component is used to render dynamic tag based on the `tag` attribute. `tag` attribute can be a string or a function component. + + This is just a wrapper around `dynamic_tag` function from Phoenix LiveView which only support string tag. + + ## Examples + + ```heex + <.dynamic tag={@tag} class="bg-primary text-primary-foreground"> + Hello World + + ``` + """ + def dynamic(%{tag: name} = assigns) when is_function(name, 1) do + assigns = Map.delete(assigns, :tag) + name.(assigns) + end + + def dynamic(assigns) do + name = assigns[:tag] || "div" + + assigns = + assigns + |> Map.delete(:tag) + |> assign(:name, name) + + dynamic_tag(assigns) + end + + @doc """ + This component mimic behavior of `asChild` attribute from shadcn/ui. + It works by passing all attribute from `as_child` tag to `tag` function component, add pass `child` attribute to the `as_tag` attribute of the `tag` function component. + + The `tag` function component should accept `as_tag` attribute to render the child component. + + ## Examples + + ```heex + <.as_child tag={&dropdown_menu_trigger/1} child={&sidebar_menu_button/1} class="bg-primary text-primary-foreground"> + Hello World + + ``` + + Normally this can be archieved by using `dropdown_menu_trigger` component directly but this will fire copile warning. + + ```heex + <.dropdown_menu_trigger as_tag={&sidebar_menu_button/1} class="bg-primary text-primary-foreground"> + Hello World + + """ + def as_child(%{tag: tag, child: child_tag} = assigns) when is_function(tag, 1) do + assigns + |> Map.drop([:tag, :child]) + |> assign(:as_tag, child_tag) + |> tag.() + end + # Translate error message # borrowed from https://github.com/petalframework/petal_components/blob/main/lib/petal_components/field.ex#L414 defp translate_error({msg, opts}) do diff --git a/lib/salad_ui/popover.ex b/lib/salad_ui/popover.ex index 2759b8c..db8e9c9 100644 --- a/lib/salad_ui/popover.ex +++ b/lib/salad_ui/popover.ex @@ -71,6 +71,7 @@ defmodule SaladUI.Popover do @doc """ Render popover content """ + attr :id, :string, required: true, doc: "The id of target element to show popover, this must be the same as the target in popover_trigger" attr :class, :string, default: nil attr :side, :string, values: ~w(bottom left right top), default: "top" attr :align, :string, values: ["start", "center", "end"], default: "center" @@ -95,6 +96,7 @@ defmodule SaladUI.Popover do data-side={@side} data-state={@state} phx-click-away={hide()} + id={@id} class={ classes([ "absolute block", diff --git a/lib/salad_ui/sheet.ex b/lib/salad_ui/sheet.ex index 65e3e58..e5b5e2e 100644 --- a/lib/salad_ui/sheet.ex +++ b/lib/salad_ui/sheet.ex @@ -79,9 +79,10 @@ defmodule SaladUI.Sheet do """ end - attr :id, :string, required: true, doc: "The id of the sheet" + attr :id, :string, default: nil, doc: "The id of the sheet, this is the target of sheet_trigger" attr :class, :string, default: nil attr :side, :string, default: "right", values: ~w(left right top bottom), doc: "The side of the sheet" + attr :rest, :global slot :inner_block, required: true slot :custom_close_btn, required: false @@ -100,15 +101,16 @@ defmodule SaladUI.Sheet do
<.sheet_overlay /> <.focus_wrap - id={"sheet-" <> @id} - phx-window-keydown={JS.exec("phx-hide-sheet", to: "#" <> @id)} + id={"sheet-#{@id}"} + phx-window-keydown={@id && JS.exec("phx-hide-sheet", to: "#" <> @id)} phx-key="escape" - phx-click-away={JS.exec("phx-hide-sheet", to: "#" <> @id)} + phx-click-away={@id && JS.exec("phx-hide-sheet", to: "#" <> @id)} role="sheet" class={ classes([ diff --git a/lib/salad_ui/sidebar.ex b/lib/salad_ui/sidebar.ex new file mode 100644 index 0000000..712ba22 --- /dev/null +++ b/lib/salad_ui/sidebar.ex @@ -0,0 +1,750 @@ +defmodule SaladUI.Sidebar do + @moduledoc false + use SaladUI, :component + + import SaladUI.Input + import SaladUI.Separator + import SaladUI.Sheet + import SaladUI.Skeleton + import SaladUI.Tooltip + + @sidebar_width "16rem" + @sidebar_width_mobile "18rem" + @sidebar_width_icon "3rem" + + @doc """ + Render + """ + attr(:class, :string, default: nil) + attr :style, :map, default: %{} + attr(:rest, :global) + slot(:inner_block, required: true) + + def sidebar_provider(assigns) do + assigns = assign(assigns, %{sidebar_width: @sidebar_width, sidebar_width_icon: @sidebar_width_icon}) + + ~H""" +
+ <%= render_slot(@inner_block) %> +
+ """ + end + + @doc """ + Render + """ + + attr :id, :string, required: true, doc: "The id of the sidebar, used for the trigger to identify the target sidebar" + attr :side, :string, values: ~w(left right), default: "left" + attr :variant, :string, values: ~w(sidebar floating inset), default: "sidebar" + attr :collapsible, :string, values: ~w(offcanvas icon none), default: "offcanvas" + attr :is_mobile, :boolean, default: false + attr :state, :string, values: ~w(expanded collapsed), default: "expanded" + attr(:class, :string, default: nil) + attr :style, :map, default: %{} + attr(:rest, :global) + slot(:inner_block, required: true) + + def sidebar(%{collapsible: "none"} = assigns) do + ~H""" +
+ <%= render_slot(@inner_block) %> +
+ """ + end + + def sidebar(%{is_mobile: true} = assigns) do + assigns = assign(assigns, :sidebar_width_mobile, @sidebar_width_mobile) + + ~H""" + <.sheet> + <.sheet_content + data-sidebar="sidebar" + data-mobile="true" + class="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + style={ + style([ + %{ + "--sidebar-width": @sidebar_width_mobile + }, + @style + ]) + } + side={@side} + > +
+ <%= render_slot(@inner_block) %> +
+ + + """ + end + + def sidebar(assigns) do + ~H""" +