Current section

Files

Jump to
tails lib custom.ex
Raw

lib/custom.ex

defmodule Tails.Custom do
@moduledoc """
Use to create a custom tails module, that can be configured with `config :your_app, YourTails`
Additionally, themes can be passed at runtime.
Example Usage:
```elixir
@themes %{
default: %{...},
dark: %{...}
}
use Tails.Custom, otp_app: :my_app, themes: @themes
```
"""
defmacro __using__(opts) do
quote generated: true, bind_quoted: [otp_app: opts[:otp_app], themes: opts[:themes]] do
require Tails.Custom
otp_app = otp_app || :tails
if otp_app == :tails do
@colors_file Application.compile_env(otp_app, :colors_file)
@no_merge_classes Application.compile_env(otp_app, :no_merge_classes) || []
@themes themes || Application.compile_env(otp_app, :themes)
else
@colors_file Application.compile_env(otp_app, __MODULE__)[:colors_file]
@no_merge_classes Application.compile_env(otp_app, __MODULE__)[:no_merge_classes] || []
@themes themes || Application.compile_env(otp_app, __MODULE__)[:themes]
end
@colors (if @colors_file do
@external_resource @colors_file
custom =
@colors_file
|> Path.expand()
|> File.read!()
|> Jason.decode!()
Map.merge(Tails.Colors.builtin_colors(), custom)
else
Tails.Colors.builtin_colors()
end)
@all_colors Tails.Colors.all_color_classes(@colors)
@modifiers ~w(
hover focus focus-within focus-visible active visited target first last only odd
even first-of-type last-of-type only-of-type empty disabled enabled checked
indeterminate default required valid invalid in-range out-of-range placeholder-shown
autofill read-only open before after first-letter first-line marker selection file
backdrop placeholder sm md lg xl 2xl dark portrait landscape motion-safe motion-reduce
contrast-more contrast-less rtl ltr
)
@font_weights ~w(thin extralight light normal medium semibold bold extrabold black)
@font_styles ~w(thin extralight light normal medium semibold bold extrabold black)
@positions ~w(static fixed absolute relative sticky)
@display ~w(
block inline-block inline flex inline-flex table inline-table table-caption table-cell
table-column table-column-group table-footer-group table-header-group table-row-group
table-row flow-root grid inline-grid contents list-item hidden)
@bg_sizes ~w(auto cover contain)
@bg_repeats ~w(repeat no-repeat repeat-x repeat-y repeat-round repeat-space)
@bg_positions ~w(bottom center left left-bottom left-top right right-bottom right-top top)
@bg_blends ~w(normal multiply screen overlay darken lighten color-dodge color-burn hard-light soft-light difference exclusion hue saturation color luminosity)
@bg_origins ~w(border padding content)
@bg_attachments ~w(fixed local scroll)
@bg_clips ~w(border padding content text)
@bg_images ~w(none gradient-to-t gradient-to-tr gradient-to-r gradient-to-br gradient-to-b gradient-to-bl gradient-to-l gradient-to-tl)
@outline_styles ~w(none dashed dotted double)
@text_overflow ~w(truncate text-ellipsis text-clip)
@prefixed_with_values [
font_weight: %{prefix: "font", values: @font_weights},
font_styles: %{prefix: "font", values: @font_styles},
outline_style: %{prefix: "outline", values: @outline_styles, naked?: true},
outline_color: %{prefix: "outline", values: @all_colors, doc_values_placeholder: "colors"},
bg_size: %{prefix: "bg", values: @bg_sizes},
bg_repeat: %{prefix: "bg", values: @bg_repeats},
bg_positions: %{prefix: "bg", values: @bg_positions},
bg_blend: %{prefix: "bg-blend", values: @bg_blends},
bg_origin: %{prefix: "bg-origin", values: @bg_origins},
bg_clip: %{prefix: "bg-clip", values: @bg_clips},
bg_image: %{prefix: "bg", values: @bg_images},
bg: %{prefix: "bg", values: @all_colors, doc_values_placeholder: "colors"},
text_color: %{prefix: "text", values: @all_colors, doc_values_placeholder: "colors"},
bg_attachment: %{prefix: "bg", values: @bg_attachments}
]
@with_values [
position: %{values: @positions},
display: %{values: @display},
text_overflow: %{values: @text_overflow}
]
@prefixed [
animate: %{prefix: "animate"},
text: %{prefix: "text"},
outline_width: %{prefix: "outline"},
outline_offset: %{prefix: "outline-offset"},
grid_cols: %{prefix: "grid-cols"},
grid_rows: %{prefix: "grid-rows"},
width: %{prefix: "w"},
height: %{prefix: "h"}
]
@directional [p: %{prefix: "p"}, m: %{prefix: "m"}]
@moduledoc """
Tailwind class utilities like class merging.
In most cases, only classes that we know all of the values for are merged.
In some cases, we merge anything starting with a given prefix. Eventually,
we will have tools to read your tailwind config to determine additional classes
to merge, or have explicit configuration on additional merge logic.
The preferred way to use tails classes the `classes/1` function. This gives you:
1. conditional classes
2. list merging (from left to right)
3. arbitrary nesting
For example:
`classes(["mt-1 mx-2", ["pt-2": var1, "pb-4", var2], "mt-12": var3])`
Will merge all classes from left to right, flattening the lists and conditionally
including the classes where the associated value is true.
## What classes are currently merged?
### Directional classes
These classes support x/y/l/r suffix merging.
- Padding - `p`
- Margin - `m`
### Only explicitly known values
Only explicitly known values will be merged, all others will be retained.
#{Tails.Doc.doc_with_values(@with_values)}
#{Tails.Doc.doc_prefix_with_values(@prefixed_with_values)}
### Any values matching prefix
Any values matching the following prefixes will be merged with eachother respectively
#{Tails.Doc.doc_prefixed(@prefixed)}
"""
defstruct Keyword.keys(@directional) ++
Keyword.keys(@prefixed) ++
Keyword.keys(@with_values) ++
Keyword.keys(@prefixed_with_values) ++
[
classes: MapSet.new(),
variants: %{},
variant: nil,
theme: :default
]
defmodule Directions do
@moduledoc false
defstruct [:l, :r, :t, :b, :tl, :tr, :br, :bl, :x, :y, :all]
@type t :: %__MODULE__{
l: String.t(),
r: String.t(),
t: String.t(),
b: String.t(),
tl: String.t(),
tr: String.t(),
br: String.t(),
bl: String.t(),
x: String.t(),
y: String.t()
}
end
@type t :: %__MODULE__{}
@doc """
Builds a class string out of a mixed list of inputs or a string.
If the value is a string, we make a new `Tails` with it (essentially deduplicating it).
If the value is a list, then for each item in the list:
- If the value is a list, we call `classes/1` on it.
- If it is a tuple, we discard it unless the second element is truthy.
- Otherwise, we `to_string` it
And then we merge the whole list up into one class string.
This allows for conditional class rendering, arbitrarily nested. For example:
iex> classes(["a", "b"])
"a b"
iex> classes([a: false, b: true])
"b"
iex> classes([[a: true, b: false], [c: false, d: true]])
"a d"
"""
def classes(nil), do: nil
def classes(classes) when is_list(classes) do
classes
|> flatten_classes()
|> Enum.reduce("", fn class, acc ->
merge(acc, class)
end)
|> to_string()
end
def classes(classes) when is_binary(classes) do
classes
|> new()
|> to_string()
end
defp new(classes) do
merge(%__MODULE__{}, classes)
end
defp flatten_classes(classes) do
classes
|> Enum.filter(fn
{_classes, condition} ->
condition
_ ->
true
end)
|> Enum.map(fn
{classes, _} ->
classes
classes ->
classes
end)
|> Enum.flat_map(fn classes ->
if is_list(classes) do
flatten_classes(classes)
else
List.wrap(classes)
end
end)
|> Enum.map(&to_string/1)
end
@doc """
Semantically merges two lists of tailwind classes, treating the first as a base and the second as overrides
Generally, instead of calling `merge/2` you will call `classes/1` with a list of classes.
See the module documentation for what classes will be merged/retained.
Examples
iex> merge("p-4", "p-2") |> to_string()
"p-2"
iex> merge("p-2", "p-4") |> to_string()
"p-4"
iex> merge("p-4", "px-2") |> to_string()
"px-2 py-4"
iex> merge("font-bold", "font-thin") |> to_string()
"font-thin"
iex> merge("block absolute", "fixed hidden") |> to_string()
"fixed hidden"
iex> merge("bg-blue-500", "bg-auto") |> to_string()
"bg-auto bg-blue-500"
iex> merge("bg-auto", "bg-repeat-x") |> to_string()
"bg-auto bg-repeat-x"
iex> merge("bg-blue-500", "bg-red-400") |> to_string()
"bg-red-400"
iex> merge("grid grid-cols-2 lg:grid-cols-3", "grid-cols-3 lg:grid-cols-4") |> to_string()
"grid grid-cols-3 lg:grid-cols-4"
iex> merge("font-normal text-black hover:text-blue-300", "text-gray-600 dark:text-red-400 font-bold") |> to_string()
"font-bold text-gray-600 hover:text-blue-300 dark:text-red-400"
Classes can be removed
iex> merge("font-normal text-black", "remove:font-normal grid") |> to_string()
"grid text-black"
All preceeding classes can be removed
iex> merge("font-normal text-black", "remove:* grid") |> to_string()
"grid"
Classes can be explicitly kept
iex> merge("font-normal text-black", "remove:font-normal grid") |> to_string()
"grid text-black"
"""
def merge(tailwind, nil) when is_binary(tailwind), do: new(tailwind)
def merge(%__MODULE__{} = tailwind, nil), do: new(tailwind)
def merge(tailwind, classes) when is_list(tailwind) do
merge(classes(tailwind), classes)
end
def merge(tailwind, classes) when is_list(classes) do
merge(tailwind, classes(classes))
end
def merge(tailwind, classes) when is_binary(tailwind) do
merge(new(tailwind), classes)
end
def merge(tailwind, classes) when is_binary(classes) do
classes
|> String.split()
|> Enum.reduce(tailwind, &merge_class(&2, &1))
end
def merge(tailwind, %__MODULE__{} = classes) do
merge(tailwind, to_string(classes))
end
def merge(_tailwind, value) do
raise "Cannot merge #{inspect(value)}"
end
@doc "Merges a list of class strings. See `merge/2` for more"
def merge(list) when is_list(list) do
Enum.reduce(list, &merge(&2, &1))
end
defp set_theme(tailwind, theme) do
%{
tailwind
| theme: theme,
variants:
Map.new(tailwind.variants, fn {key, value} ->
{key, set_theme(value, theme)}
end)
}
end
@doc false
def merge_class(tailwind, "keep:" <> class) do
%{tailwind | classes: MapSet.put(tailwind.classes, class)}
end
def merge_class(%{theme: theme}, "remove:*") do
%__MODULE__{theme: theme}
end
def merge_class(tailwind, "remove:" <> class) do
remove(tailwind, class)
end
for {theme, replacements} <- @themes || [] do
def merge_class(tailwind, "theme:" <> unquote(to_string(theme))) do
set_theme(tailwind, unquote(theme))
end
for {key, replacement} <- Tails.Custom.replacements(replacements) do
def merge_class(%{theme: unquote(theme)} = tailwind, unquote(to_string(key))) do
merge(tailwind, classes(unquote(replacement)))
end
def merge_class(%{theme: unquote(to_string(theme))} = tailwind, unquote(to_string(key))) do
merge(tailwind, classes(unquote(replacement)))
end
if theme == :default do
# fallback to default
def merge_class(tailwind, unquote(to_string(key))) do
merge(tailwind, classes(unquote(replacement)))
end
end
end
end
for class <- @no_merge_classes || [] do
def merge_class(tailwind, unquote(class)) do
%{tailwind | classes: MapSet.put(tailwind.classes, class)}
end
end
for modifier <- @modifiers do
def merge_class(tailwind, unquote(modifier) <> ":" <> rest) do
rest = String.split(rest, ":")
last = List.last(rest)
modifiers = :lists.droplast(rest)
key = Enum.sort([unquote(modifier) | modifiers])
if Map.has_key?(tailwind.variants, key) do
%{tailwind | variants: Map.update!(tailwind.variants, key, &merge_class(&1, last))}
else
%{
tailwind
| variants:
Map.put(tailwind.variants, key, %{new(last) | variant: Enum.join(key, ":")})
}
end
end
end
for {class, %{prefix: string_class}} <- @directional do
def merge_class(tailwind, "-" <> unquote(string_class) <> "-" <> value) do
Map.put(tailwind, unquote(class), "-#{value}")
end
def merge_class(tailwind, unquote(string_class) <> "-" <> value) do
Map.put(tailwind, unquote(class), value)
end
def merge_class(
%{unquote(class) => nil} = tailwind,
"-" <> unquote(string_class) <> "x-" <> value
) do
Map.put(tailwind, unquote(class), %Directions{x: "-#{value}"})
end
def merge_class(
%{unquote(class) => nil} = tailwind,
unquote(string_class) <> "x-" <> value
) do
Map.put(tailwind, unquote(class), %Directions{x: value})
end
def merge_class(
%{unquote(class) => all} = tailwind,
"-" <> unquote(string_class) <> "x-" <> value
)
when is_binary(all) do
Map.put(tailwind, unquote(class), %Directions{y: all, x: "-#{value}"})
end
def merge_class(
%{unquote(class) => all} = tailwind,
unquote(string_class) <> "x-" <> value
)
when is_binary(all) do
Map.put(tailwind, unquote(class), %Directions{y: all, x: value})
end
def merge_class(
%{unquote(class) => %Directions{} = directions} = tailwind,
"-" <> unquote(string_class) <> "x-" <> value
) do
Map.put(tailwind, unquote(class), %{directions | x: "-#{value}", l: nil, r: nil})
end
def merge_class(
%{unquote(class) => %Directions{} = directions} = tailwind,
unquote(string_class) <> "x-" <> value
) do
Map.put(tailwind, unquote(class), %{directions | x: value, l: nil, r: nil})
end
def merge_class(
%{unquote(class) => nil} = tailwind,
"-" <> unquote(string_class) <> "y-" <> value
) do
Map.put(tailwind, unquote(class), %Directions{y: "-#{value}"})
end
def merge_class(
%{unquote(class) => nil} = tailwind,
unquote(string_class) <> "y-" <> value
) do
Map.put(tailwind, unquote(class), %Directions{y: value})
end
def merge_class(
%{unquote(class) => all} = tailwind,
"-" <> unquote(string_class) <> "y-" <> value
)
when is_binary(all) do
Map.put(tailwind, unquote(class), %Directions{x: all, y: "-#{value}"})
end
def merge_class(
%{unquote(class) => all} = tailwind,
unquote(string_class) <> "y-" <> value
)
when is_binary(all) do
Map.put(tailwind, unquote(class), %Directions{x: all, y: value})
end
def merge_class(
%{unquote(class) => %Directions{} = directions} = tailwind,
"-" <> unquote(string_class) <> "y-" <> value
) do
Map.put(tailwind, unquote(class), %{directions | y: "-#{value}", t: nil, b: nil})
end
def merge_class(
%{unquote(class) => %Directions{} = directions} = tailwind,
unquote(string_class) <> "y-" <> value
) do
Map.put(tailwind, unquote(class), %{directions | y: value, t: nil, b: nil})
end
# We can't actually do this because the required classes don't show up in text anywhere for tailwind to pick it up
# for dir <- ~w(t b l r)a do
# {split, to} =
# if dir in [:t, :b] do
# to = if dir == :t, do: :b, else: :t
# {:y, to}
# else
# to = if dir == :l, do: :l, else: :r
# {:x, to}
# end
# def merge_class(
# %{unquote(class) => nil} = tailwind,
# "-" <> unquote(string_class) <> unquote(to_string(dir)) <> "-" <> value
# ) do
# Map.put(tailwind, unquote(class), %Directions{} |> Map.put(unquote(dir), "-#{value}"))
# end
# def merge_class(
# %{unquote(class) => nil} = tailwind,
# unquote(string_class) <> unquote(to_string(dir)) <> "-" <> value
# ) do
# Map.put(tailwind, unquote(class), %Directions{} |> Map.put(unquote(dir), value))
# end
# def merge_class(
# %{unquote(class) => %Directions{unquote(split) => split_value} = directions} =
# tailwind,
# "-" <> unquote(string_class) <> unquote(to_string(dir)) <> "-" <> value
# )
# when not is_nil(split_value) do
# Map.put(
# tailwind,
# unquote(class),
# directions
# |> Map.put(unquote(dir), "-#{value}")
# |> Map.put(unquote(to), split_value)
# |> Map.put(unquote(split), nil)
# )
# end
# def merge_class(
# %{unquote(class) => %Directions{unquote(split) => split_value} = directions} =
# tailwind,
# unquote(string_class) <> unquote(to_string(dir)) <> "-" <> value
# )
# when not is_nil(split_value) do
# Map.put(
# tailwind,
# unquote(class),
# directions
# |> Map.put(unquote(dir), value)
# |> Map.put(unquote(to), split_value)
# |> Map.put(unquote(split), nil)
# )
# end
# def merge_class(
# %{unquote(class) => %Directions{} = directions} = tailwind,
# "-" <> unquote(string_class) <> unquote(to_string(dir)) <> "-" <> value
# ) do
# Map.put(tailwind, unquote(class), Map.put(directions, unquote(dir), "-#{value}"))
# end
# def merge_class(
# %{unquote(class) => %Directions{} = directions} = tailwind,
# unquote(string_class) <> unquote(to_string(dir)) <> "-" <> value
# ) do
# Map.put(tailwind, unquote(class), Map.put(directions, unquote(dir), value))
# end
# end
end
for {key, %{values: values, prefix: prefix} = config} <- @prefixed_with_values do
if config[:naked?] do
def merge_class(tailwind, unquote(prefix)) do
Map.put(tailwind, unquote(key), "")
end
end
unless config[:no_arbitrary?] do
def merge_class(tailwind, unquote(prefix) <> "-" <> "[" <> _ = new_value) do
Map.put(tailwind, unquote(key), new_value)
end
end
def merge_class(tailwind, unquote(prefix) <> "-" <> new_value)
when new_value in unquote(values) do
Map.put(tailwind, unquote(key), new_value)
end
end
for {key, %{values: values}} <- @with_values do
def merge_class(tailwind, new_value) when new_value in unquote(values) do
Map.put(tailwind, unquote(key), new_value)
end
end
for {key, %{prefix: prefix}} <- @prefixed do
def merge_class(tailwind, unquote(prefix) <> "-" <> new_value) do
Map.put(tailwind, unquote(key), new_value)
end
end
def merge_class(tailwind, class) do
%{tailwind | classes: MapSet.put(tailwind.classes, class)}
end
@doc """
Removes the given class from the class list.
"""
for class <- @no_merge_classes || [] do
def remove(tailwind, unquote(class)) do
%{tailwind | classes: MapSet.delete(tailwind.classes, class)}
end
end
for modifier <- @modifiers do
def remove(tailwind, unquote(modifier) <> ":" <> rest) do
rest = String.split(rest, ":")
last = List.last(rest)
modifiers = :lists.droplast(rest)
key = Enum.sort([unquote(modifier) | modifiers])
if Map.has_key?(tailwind.variants, key) do
%{tailwind | variants: Map.update!(tailwind.variants, key, &remove(&1, last))}
else
%{
tailwind
| variants:
Map.put(tailwind.variants, key, %{new(last) | variant: Enum.join(key, ":")})
}
end
end
end
def remove(tails, class) do
if MapSet.member?(tails.classes, class) do
%{tails | classes: MapSet.delete(tails.classes, class)}
else
# This is a bit of a hack, could maybe be optimized or fixed later
# explicitly kept classes using `keep:` are in `classes`, so we keep them separate. The rest are classes we don't know about
# so won't be affected by the `new/1` call below anyway.
keep = tails.classes
tails
|> Map.put(:classes, MapSet.new())
|> to_string()
|> String.split(" ")
|> Kernel.--([class])
|> Enum.join(" ")
|> new()
|> Map.put(:classes, keep)
end
end
@doc false
def to_iodata(tailwind) do
[
Enum.map(@with_values, fn {key, _} ->
simple(Map.get(tailwind, key), tailwind.variant)
end),
Enum.map(@directional, fn {key, %{prefix: prefix}} ->
directional(Map.get(tailwind, key), prefix, tailwind.variant)
end),
Enum.map(@prefixed_with_values, fn {key, %{prefix: prefix} = config} ->
prefix(prefix, Map.get(tailwind, key), tailwind.variant, config[:naked?])
end),
Enum.map(@prefixed, fn {key, %{prefix: prefix}} ->
prefix(prefix, Map.get(tailwind, key), tailwind.variant)
end)
]
|> add_variants(tailwind)
|> add_classes(tailwind, tailwind.variant)
end
defp add_variants(iodata, tailwind) do
[
iodata,
tailwind.variants
|> Kernel.||(%{})
|> Enum.sort_by(&elem(&1, 1))
|> Enum.map(fn {_key, value} ->
to_iodata(value)
end)
]
end
defp add_classes(iodata, tailwind, variant) do
Enum.concat(
iodata,
case tailwind.classes do
nil ->
[]
"" ->
[]
classes ->
if Enum.empty?(classes) do
[]
else
if variant do
[" " | Enum.intersperse(Enum.map(tailwind.classes, &[variant, ":", &1]), " ")]
else
[" " | Enum.intersperse(tailwind.classes, " ")]
end
end
end
)
end
defp simple(nil, _), do: ""
defp simple(value, nil), do: [" ", value]
defp simple(value, variant), do: [" ", variant, ":", value]
defp prefix(prefix, value, variant, naked? \\ false)
defp prefix(_prefix, nil, _, _), do: ""
defp prefix(prefix, value, nil, true), do: [" ", prefix]
defp prefix(prefix, value, nil, _), do: [" ", prefix, "-", value]
defp prefix(prefix, value, variant, true), do: [" ", variant, ":", prefix]
defp prefix(prefix, value, variant, _), do: [" ", variant, ":", prefix, "-", value]
defp directional(nil, _key, _), do: ""
defp directional("-" <> value, key, nil) do
[" -", key, "-", value]
end
defp directional(value, key, nil) when is_binary(value) do
[" ", key, "-", value]
end
defp directional("-" <> value, key, variant) do
[" ", variant, ":-", key, "-", value]
end
defp directional(value, key, variant) when is_binary(value) do
[" ", variant, ":", key, "-", value]
end
defp directional(%Directions{l: l, r: r, t: t, b: b, x: x, y: y}, key, variant) do
[
direction(t, "t", key, variant),
direction(b, "b", key, variant),
direction(l, "l", key, variant),
direction(r, "r", key, variant),
direction(x, "x", key, variant),
direction(y, "y", key, variant)
]
|> Enum.filter(& &1)
end
defp direction(nil, _, _, _), do: ""
defp direction("-" <> value, suffix, prefix, nil),
do: [" -", prefix, suffix, "-", value]
defp direction(value, suffix, prefix, nil),
do: [" ", prefix, suffix, "-", value]
defp direction("-" <> value, suffix, prefix, variant),
do: [" ", variant, ":-", prefix, suffix, "-", value]
defp direction(value, suffix, prefix, variant),
do: [" ", variant, ":", prefix, suffix, "-", value]
defimpl String.Chars do
def to_string(tailwind) do
tailwind
|> to_iodata()
|> IO.iodata_to_binary()
|> case do
" " <> rest -> rest
value -> value
end
end
defp to_iodata(%mod{} = tailwind) do
mod.to_iodata(tailwind)
end
end
defimpl Inspect do
def inspect(tailwind, _opts) do
"Tails.classes(\"#{to_string(tailwind)}\")"
end
end
for {key, value} when is_binary(value) <- @colors do
# sobelow_skip ["DOS.BinToAtom"]
def unquote(:"#{String.replace(key, "-", "_")}")() do
unquote(value)
end
end
for {key, value} when is_map(value) <- @colors do
for {suffix, value} when is_binary(value) <- value do
if suffix == "DEFAULT" do
# sobelow_skip ["DOS.BinToAtom"]
def unquote(:"#{String.replace(key, "-", "_")}")() do
unquote(value)
end
else
# sobelow_skip ["DOS.BinToAtom"]
def unquote(:"#{String.replace(key, "-", "_")}_#{String.replace(suffix, "-", "_")}")() do
unquote(value)
end
end
end
end
end
end
@doc false
def replacements(replacements, prefix \\ "") do
Enum.flat_map(replacements, fn
{key, value} when is_binary(value) ->
[{add_to_prefix(prefix, key), value}]
{key, value} when is_list(value) ->
replacements(value, add_to_prefix(prefix, key))
end)
end
defp add_to_prefix("", value), do: to_string(value)
defp add_to_prefix(prefix, value), do: to_string(prefix) <> "-" <> to_string(value)
end