Current section
Files
Jump to
Current section
Files
lib/phx_component_helpers.ex
defmodule PhxComponentHelpers do
@moduledoc """
`PhxComponentHelpers` are helper functions meant to be used within Phoenix
LiveView live_components to make your components more configurable and extensible
from your templates.
It provides following features:
* set html attributes from component assigns
* set data attributes from component assigns
* set phx_* attributes from component assigns
* encode attributes as JSON from an Elixir structure assign
* validate mandatory attributes
* set and extend css classes from component assigns
"""
import Phoenix.HTML, only: [html_escape: 1]
@json_library Jason
@doc ~S"""
Extends assigns with html_* attributes that can be interpolated within
your component markup.
## Parameters
* `assigns` - your component assigns
* `attributes` - a list of attributes (atoms) that will be fetched from assigns
## Options
* `:init` - a list of attributes that will be initialized if absent from assigns
* `:required` - raises if required attributes are absent from assigns
* `:json` - when true, will JSON encode the assign value
## Example
```
assigns
|> set_component_attributes([:id, :name, :label], required: [:id, :name])
|> set_component_attributes([:value], json: true)
```
`assigns` now contains `@html_id`, `@html_name`, `@html_label` and `@html_value`.
"""
def set_component_attributes(assigns, attributes, opts \\ []) do
assigns
|> set_attributes(attributes, &html_attribute/1, opts)
|> set_empty_attributes(opts[:init])
|> validate_required_attributes(opts[:required])
end
@doc ~S"""
Extends assigns with html_* data-attributes that can be interpolated within
your component markup.
Behaves exactly like `set_component_attributes/3` excepted the output `@html_attr`
assigns contain data-attributes markup.
## Example
```
assigns
|> set_data_attributes([:key, :text], required: [:key])
|> set_data_attributes([:document], json: true)
```
`assigns` now contains `@html_key`, `@html_text` and `@html_document`.
"""
def set_data_attributes(assigns, attributes, opts \\ []) do
assigns
|> set_attributes(attributes, &data_attribute/1, opts)
|> set_empty_attributes(opts[:init])
|> validate_required_attributes(opts[:required])
end
@doc ~S"""
Extends assigns with phx* attributes that can be interpolated within
your component markup. It will automatically detect any attribute prefixed by
`phx_` from input assigns.
## Parameters
* `assigns` - your component assigns
## Options
* `:init` - a list of attributes that will be initialized if absent from assigns
* `:required` - raises if required attributes are absent from assigns
## Example
```
assigns
|> set_phx_attributes([:phx_change, :phx_submit], required: [:phx_submit], init: [:phx_change])
```
`assigns` now contains `@html_phx_change` and `@html_phx_submit`.
"""
def set_phx_attributes(assigns, opts \\ []) do
phx_attributes = find_assigns_with_prefix(assigns, "phx_")
assigns
|> set_attributes(phx_attributes, &html_attribute/1)
|> set_empty_attributes(opts[:init])
|> validate_required_attributes(opts[:required])
end
@doc ~S"""
Extends assigns with class attributes.
The class attribute will take provided `default_classes` as a default value and will
be extended, on a class-by-class basis, by your assigns.
## Parameters
* `assigns` - your component assigns
* `class_attribute_name` - the class attribute you want to define, `:class` by default
* `default_classes` - the css classed that will put by default
## Example
```
assigns
|> extend_class("bg-blue-500 mt-8")
|> extend_class(:wrapper_class, "py-4 px-2 divide-y-8 divide-gray-200")
```
`assigns` now contains `@html_class` and `@html_wrapper_class`.
If your input assigns were `%{class: "mt-2", wrapper_class: "divide-none"}` then:
* `@html_class` would contain `"bg-blue-500 mt-2"`
* `@html_wrapper_class` would contain `"py-4 px-2 divide-none"`
"""
def extend_class(assigns, class_attribute_name \\ :class, default_classes) do
default_classes = String.split(default_classes, [" ", "\n"], trim: true)
assigns_class = Map.get(assigns, class_attribute_name, "")
extend_classes = String.split(assigns_class, [" ", "\n"], trim: true)
class =
for class <- default_classes, reduce: extend_classes do
acc ->
[class_prefix | _] = String.split(class, "-")
if Enum.any?(extend_classes, &String.starts_with?(&1, "#{class_prefix}-")) do
acc
else
[class | acc]
end
end
html_class = class |> Enum.join(" ") |> escaped()
Map.put(assigns, :"html_#{class_attribute_name}", {:safe, "class=#{html_class}"})
end
defp set_attributes(assigns, attributes, attribute_fun, opts \\ []) do
for attr <- attributes, reduce: assigns do
acc ->
html_attr = :"html_#{attr}"
case Map.get(assigns, attr) do
nil -> acc
val -> Map.put(acc, html_attr, {:safe, "#{attribute_fun.(attr)}=#{escaped(val, opts)}"})
end
end
end
defp set_empty_attributes(assigns, nil), do: assigns
defp set_empty_attributes(assigns, attributes) do
for attr <- attributes, reduce: assigns do
acc -> Map.put_new(acc, :"html_#{attr}", {:safe, ""})
end
end
defp validate_required_attributes(assigns, nil), do: assigns
defp validate_required_attributes(assigns, required) do
if Enum.all?(required, &Map.has_key?(assigns, &1)) do
assigns
else
raise ArgumentError, "missing required attributes"
end
end
defp html_attribute(attr), do: attr |> to_string() |> String.replace("_", "-")
defp data_attribute(attr), do: "data-#{html_attribute(attr)}"
defp escaped(val, opts \\ [])
defp escaped(val, json: true) do
case Code.ensure_compiled(@json_library) do
{:module, mod} ->
{:safe, escaped_val} = val |> mod.encode!() |> html_escape()
"\"#{escaped_val}\""
_ ->
raise ArgumentError, "#{@json_library} is not available"
end
end
defp escaped(val, _) do
{:safe, escaped_val} = html_escape(val)
"\"#{escaped_val}\""
end
defp find_assigns_with_prefix(assigns, prefix) do
for key <- Map.keys(assigns),
key_s = to_string(key),
String.starts_with?(key_s, prefix),
do: key
end
end