345 lines
9.4 KiB
Elixir
345 lines
9.4 KiB
Elixir
defmodule RiverConnectWeb.Components.UI.Helpers do
|
|
@moduledoc false
|
|
use Phoenix.Component
|
|
|
|
import Phoenix.Component
|
|
|
|
@doc """
|
|
Prepare input assigns for use in a form. Extract required attribute from the Form.Field struct and update current assigns.
|
|
"""
|
|
def prepare_assign(%{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(
|
|
:name,
|
|
assigns[:name] || if(assigns[:multiple], do: field.name <> "[]", else: field.name)
|
|
)
|
|
|> assign(:value, assigns[:value] || field.value)
|
|
|> prepare_assign()
|
|
end
|
|
|
|
# use default value if value is not provided or empty
|
|
def prepare_assign(assigns) do
|
|
value =
|
|
if assigns[:value] in [nil, "", []] do
|
|
assigns[:"default-value"]
|
|
else
|
|
assigns[:value]
|
|
end
|
|
|
|
assign(assigns, value: value)
|
|
end
|
|
|
|
@doc """
|
|
This function return list of error message from form field
|
|
"""
|
|
def field_errors(%Phoenix.HTML.FormField{} = field) do
|
|
Enum.map(field.errors, &translate_error(&1))
|
|
end
|
|
|
|
def field_errors(_), do: []
|
|
|
|
@doc """
|
|
This function return true if the field has error
|
|
"""
|
|
def has_error?(%Phoenix.HTML.FormField{} = field) do
|
|
not Enum.empty?(field.errors)
|
|
end
|
|
|
|
def has_error?(_field), do: false
|
|
|
|
# Helper function to add event mappings
|
|
def add_event_mapping(map \\ %{}, assigns, event, key) do
|
|
if assigns[key] do
|
|
Map.put(map, event, assigns[key])
|
|
else
|
|
map
|
|
end
|
|
end
|
|
|
|
# Helper to encode data to JSON
|
|
def json(data) do
|
|
Phoenix.json_library().encode!(data)
|
|
end
|
|
|
|
# normalize_integer
|
|
def normalize_integer(value) when is_integer(value), do: value
|
|
|
|
def normalize_integer(value) when is_binary(value) do
|
|
case Integer.parse(value) do
|
|
{integer, _} -> integer
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
def normalize_integer(_), do: nil
|
|
|
|
def normalize_boolean(value) do
|
|
case value do
|
|
"true" -> true
|
|
"false" -> false
|
|
true -> true
|
|
false -> false
|
|
_ -> false
|
|
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
|
|
|
|
@variants %{
|
|
variant: %{
|
|
"default" => "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
|
"destructive" =>
|
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
|
"outline" =>
|
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
|
"secondary" => "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
|
"ghost" => "hover:bg-accent hover:text-accent-foreground",
|
|
"link" => "text-primary underline-offset-4 hover:underline"
|
|
},
|
|
size: %{
|
|
"default" => "h-9 px-4 py-2",
|
|
"sm" => "h-8 rounded-md px-3 text-xs",
|
|
"lg" => "h-10 rounded-md px-8",
|
|
"icon" => "h-9 w-9"
|
|
}
|
|
}
|
|
|
|
@default_variants %{
|
|
variant: "default",
|
|
size: "default"
|
|
}
|
|
|
|
@doc """
|
|
Reuseable button variant helper. Support 2 variant
|
|
- size: `default|sm|lg|icon`
|
|
- variant: `default|destructive|outline|secondary|ghost|link`
|
|
"""
|
|
def button_variant(props \\ %{}) do
|
|
variants = Map.take(props, ~w(variant size)a)
|
|
variants = Map.merge(@default_variants, variants)
|
|
|
|
variation_classes = Enum.map_join(variants, " ", fn {key, value} -> @variants[key][value] end)
|
|
|
|
shared_classes =
|
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50"
|
|
|
|
"#{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
|
|
<button click={exec_closest("phx-hide-sheet", ".ancestor_class")}>
|
|
Close
|
|
</button>
|
|
```
|
|
"""
|
|
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
|
|
</.dynamic>
|
|
```
|
|
"""
|
|
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(:tag_name, name)
|
|
|> 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` 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
|
|
</.as_child>
|
|
```
|
|
|
|
Normally this can be archieved by using `dropdown_menu_trigger` component directly but this will fire copile warning.
|
|
|
|
```heex
|
|
<.dropdown_menu_trigger as={&sidebar_menu_button/1} class="bg-primary text-primary-foreground">
|
|
Hello World
|
|
</.dropdown_menu_trigger>
|
|
"""
|
|
def as_child(%{tag: tag, child: child_tag} = assigns) when is_function(tag, 1) do
|
|
assigns
|
|
|> Map.drop([:tag, :child])
|
|
|> assign(:as, 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
|
|
config_translator = get_translator_from_config() || (&fallback_translate_error/1)
|
|
|
|
config_translator.({msg, opts})
|
|
end
|
|
|
|
defp fallback_translate_error({msg, opts}) do
|
|
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
|
try do
|
|
String.replace(acc, "%{#{key}}", to_string(value))
|
|
rescue
|
|
e ->
|
|
IO.warn(
|
|
"""
|
|
the fallback message translator for the form_field_error function cannot handle the given value.
|
|
|
|
Hint: you can set up the `error_translator_function` to route all errors to your application helpers:
|
|
|
|
config :salad_ui, :error_translator_function, {MyAppWeb.CoreComponents, :translate_error}
|
|
|
|
Given value: #{inspect(value)}
|
|
|
|
Exception: #{Exception.message(e)}
|
|
""",
|
|
__STACKTRACE__
|
|
)
|
|
|
|
"invalid value"
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp get_translator_from_config do
|
|
case Application.get_env(:salad_ui, :error_translator_function) do
|
|
{module, function} -> &apply(module, function, [&1])
|
|
nil -> nil
|
|
end
|
|
end
|
|
end
|