diff --git a/README.md b/README.md index 5962f27..76a1815 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,20 @@ ## Installation -1. **Using `salad_ui` as part of your project:** - -> This way you can install only components that you want to use or you want to edit SaladUI's component source code to fit your need. -> If you just want to use SaladUI's components, see **Using as library** below. - -- Adding `salad_ui` to your list of dependencies in `mix.exs`: - +1. Add `salad_ui` to your `mix.exs` ```elixir def deps do [ - {:salad_ui, "~> 0.13.0", only: [:dev]}, - {:tails, "~> 0.1"} + {:salad_ui, "~> 0.13.0"}, ] end ``` +2. **Using `salad_ui` as part of your project:** + +> This way you can install only components that you want to use or you want to edit SaladUI's component source code to fit your need. +> If you just want to use SaladUI's components, see **Using as library** below. + - Init Salad UI in your project ``` #> cd your_project @@ -52,18 +50,7 @@ end #> mix salad.add label button ``` -2. **Using `salad_ui` as a library:** - -- Adding `salad_ui` to your list of dependencies in `mix.exs`: - -```elixir -def deps do - [ - {:salad_ui, "~> 0.13.0", only: [:dev]} - ] -end -``` - +3. **Using `salad_ui` as a library:** - Init Salad UI in your project with option `--as-lib` ``` #> cd your_project @@ -169,6 +156,6 @@ To run the failing tests only, just run `mix test.watch --stale`. This project could not be available without these awesome works: - `tailwind css` an awesome css utility project -- `tails` for merging tailwind class +- `turboprop` I borrow code from here for merging tailwinds classes - `shadcn/ui` which this project is inspired from - `Phoenix Framework` of course diff --git a/config/config.exs b/config/config.exs index d620903..e0a4cfa 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,5 +18,3 @@ config :tailwind, ), cd: Path.expand("../assets", __DIR__) ] - -config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json") diff --git a/docs/manual_install.md b/docs/manual_install.md index 51f8bbc..e1b7ddc 100644 --- a/docs/manual_install.md +++ b/docs/manual_install.md @@ -74,17 +74,7 @@ npm i -D tailwindcss-animate yarn add -D tailwindcss-animate ``` -3. Configure `tails` -SaladUI use `tails` to properly merge Tailwindcss classes - -```elixir -# config/config.exs - -config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json") -``` - - -4. **Add javascript to handle event from server** +3. **Add javascript to handle event from server** This add ability to execute client action from server. It's similar to `JS.exec/2`. Thanks to [this post](https://fly.io/phoenix-files/server-triggered-js/) from fly.io. Add this code snippet to the end of `app.js` @@ -106,7 +96,7 @@ Then from server side, you can close an opening sheet like this. end ``` -5. Some tweaks +4. Some tweaks Thanks to @ahacking - To make dark and light mode work correctly, add following to your `app.css` diff --git a/lib/mix/tasks/salad.init.ex b/lib/mix/tasks/salad.init.ex index 5084154..0289db4 100644 --- a/lib/mix/tasks/salad.init.ex +++ b/lib/mix/tasks/salad.init.ex @@ -91,9 +91,7 @@ defmodule Mix.Tasks.Salad.Init do end defp write_config(component_path) do - with :ok <- write_dev_config(component_path) do - write_tails_config() - end + write_dev_config(component_path) end defp write_dev_config(component_path) do @@ -110,20 +108,6 @@ defmodule Mix.Tasks.Salad.Init do patch_config(dev_config_path, components_config) end - defp write_tails_config do - Mix.shell().info("Writing tails config to config.exs") - config_path = Path.join(File.cwd!(), "config/config.exs") - - tails_config = [ - tails: %{ - description: "SaladUI use tails to properly merge Tailwind CSS classes", - values: [colors_file: "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")"] - } - ] - - patch_config(config_path, tails_config) - end - defp patch_config(config_path, config) do if File.exists?(config_path) do Patcher.patch_config(config_path, config) diff --git a/lib/salad_ui.ex b/lib/salad_ui.ex index 1c858f4..9076900 100644 --- a/lib/salad_ui.ex +++ b/lib/salad_ui.ex @@ -5,10 +5,12 @@ defmodule SaladUI do use Phoenix.Component import SaladUI.Helpers - import Tails, only: [classes: 1] # alias OrangeCmsWeb.Components.LadUI.LadJS alias Phoenix.LiveView.JS + defp classes(input) do + SaladUI.Merge.merge(input) + end end end diff --git a/lib/utils/cache.ex b/lib/utils/cache.ex new file mode 100644 index 0000000..74aa202 --- /dev/null +++ b/lib/utils/cache.ex @@ -0,0 +1,44 @@ +defmodule SaladUI.Cache do + @moduledoc false + use GenServer + + alias SaladUI.Merge.ClassTree + + @default_table_name :turboprop_cache + + @doc false + def default_table_name, do: @default_table_name + + def start_link(default) when is_list(default) do + GenServer.start_link(__MODULE__, default) + end + + @impl true + def init(opts \\ []) do + table_name = Keyword.get(opts, :cache_table_name, @default_table_name) + create_table(table_name) + + insert(:class_tree, ClassTree.generate()) + + {:ok, []} + end + + def create_table(table_name \\ @default_table_name) do + :ets.new(table_name, [:set, :public, :named_table, read_concurrency: true]) + end + + def insert(key, value, table_name \\ @default_table_name) do + :ets.insert(table_name, {key, value}) + end + + def retrieve(key, table_name \\ @default_table_name) do + case :ets.lookup(table_name, key) do + [{^key, value}] -> value + [] -> nil + end + end + + def purge(table_name \\ @default_table_name) do + :ets.delete_all_objects(table_name) + end +end diff --git a/lib/utils/merge.ex b/lib/utils/merge.ex new file mode 100644 index 0000000..c8913ca --- /dev/null +++ b/lib/utils/merge.ex @@ -0,0 +1,141 @@ +defmodule SaladUI.Merge do + @moduledoc """ + SaladUI Merge adds efficient class joining and merging of TailwindCSS classes to Elixir. + + TailwindCSS class names are composable and allow specifying an infinite amount of different styles. Most components allow overriding class + names, like as passing `class` attribute that then gets merged with the existing styles. This can result in class lists such as + `text-white bg-red-500 bg-blue-300` where `text-white bg-red-500` is the preset style, and `bg-blue-300` is the override for that one + specific button that needs to look slightly different. + Styles based on class are applied according to the order _they are defined at in the stylesheet_. In this example, because TailwindCSS + orders color definitions alphabetically, the override does not work. `blue` is defined before `red`, so the `bg-red-500` class takes + precedence since it was defined later. + + In order to still allow overriding styles, SaladUI Merge traverses the entire class list, creates a list of all classes and which + conflicting groups of styles exist in them and gives precedence to the ones that were defined last _in the class list_, which, unlike the + stylesheet, is in control of the user. + + + ## Example + + ```elixir + iex> merge("text-white bg-red-500 bg-blue-300") + "text-white bg-blue-300" + + iex> merge(["px-2 py-1 bg-red hover:bg-dark-red", "p-3 bg-[#B91C1C]"]) + "hover:bg-dark-red p-3 bg-[#B91C1C]" + ``` + + ## Configuration + + SaladUI Merge does not currently support full theme configuration - that's on the roadmap! + + The limited configuration at the moment is adding Tailwind's `prefix` option. + + ```elixir + config :turboprop, + prefix: "tw-" + ``` + """ + + alias SaladUI.Cache + alias SaladUI.Merge.Class + alias SaladUI.Merge.Config + + @doc """ + Joins and merges a list of classes. + + Passes the input to `join/1` before merging. + """ + @spec merge(list(), term()) :: binary() + def merge(input, config \\ Config.config()) do + input + |> join() + |> retrieve_from_cache_or_merge(config) + end + + @doc """ + Joins a list of classes. + """ + @spec merge(binary() | list()) :: binary() + def join(input) when is_binary(input), do: input + def join(input) when is_list(input), do: do_join(input, "") + def join(_), do: "" + + defp do_join("", result), do: result + defp do_join(nil, result), do: result + defp do_join([], result), do: result + + defp do_join(string, result) when is_binary(string), do: do_join([string], result) + + defp do_join([head | tail], result) do + case to_value(head) do + "" -> do_join(tail, result) + value when result == "" -> do_join(tail, value) + value -> do_join(tail, result <> " " <> value) + end + end + + defp to_value(value) when is_binary(value), do: value + + defp to_value(values) when is_list(values) do + Enum.reduce(values, "", fn v, acc -> + case to_value(v) do + "" -> acc + resolved_value when acc == "" -> resolved_value + resolved_value -> acc <> " " <> resolved_value + end + end) + end + + defp to_value(_), do: "" + + defp retrieve_from_cache_or_merge(classes, config) do + case Cache.retrieve("merge:#{classes}") do + nil -> + merged_classes = do_merge(classes, config) + Cache.insert("merge:#{classes}", merged_classes) + merged_classes + + merged_classes -> + merged_classes + end + end + + defp do_merge(classes, config) do + classes + |> String.trim() + |> String.split(~r/\s+/) + |> Enum.map(&Class.parse/1) + |> Enum.reverse() + |> Enum.reduce(%{classes: [], groups: []}, fn class, acc -> + handle_class(class, acc, config) + end) + |> Map.get(:classes) + |> Enum.join(" ") + end + + defp handle_class(%{raw: raw, tailwind?: false}, acc, _config), do: Map.update!(acc, :classes, fn classes -> [raw | classes] end) + + defp handle_class(%{conflict_id: conflict_id} = class, acc, config) do + if Enum.member?(acc.groups, conflict_id), do: acc, else: add_class(acc, class, config) + end + + defp add_class(acc, %{raw: raw, group: group, conflict_id: conflict_id, modifier_id: modifier_id}, config) do + conflicting_groups = + group + |> conflicting_groups(config) + |> Enum.map(&"#{modifier_id}:#{&1}") + |> then(&[conflict_id | &1]) + + acc + |> Map.update!(:classes, fn classes -> [raw | classes] end) + |> Map.update!(:groups, fn groups -> groups ++ conflicting_groups end) + end + + defp conflicting_groups(group, config) do + conflicts = Map.get(config.conflicting_groups, group, []) + modifier_conflicts = Map.get(config.conflicting_group_modifiers, group, []) + + conflicts ++ modifier_conflicts + end +end diff --git a/lib/utils/merge/class.ex b/lib/utils/merge/class.ex new file mode 100644 index 0000000..045ab80 --- /dev/null +++ b/lib/utils/merge/class.ex @@ -0,0 +1,115 @@ +defmodule SaladUI.Merge.Class do + @moduledoc false + + defstruct [:raw, :tailwind?, :group, :conflict_id, :modifiers, :modifier_id, :important?, :base] + + def parse(raw) do + with {:ok, parsed, "", _, _, _} <- SaladUI.Merge.Parser.class(raw), + base when is_binary(base) <- Keyword.get(parsed, :base), + group when is_binary(group) <- group(base), + modifier_id when is_binary(modifier_id) <- modifier_id(parsed) do + %__MODULE__{ + raw: raw, + tailwind?: true, + group: group, + conflict_id: "#{modifier_id}:#{group}", + modifiers: Keyword.get_values(parsed, :modifier), + modifier_id: modifier_id, + base: base, + important?: Keyword.has_key?(parsed, :important) + } + else + _ -> + %__MODULE__{ + raw: raw, + tailwind?: false + } + end + end + + defp group(class) do + # Don't consider negation prefix. + parts = + case String.split(class, "-") do + ["" | parts] -> parts + parts -> parts + end + + group_recursive(parts, SaladUI.Merge.ClassTree.get()) || get_group_id_for_arbitrary_property(class) + end + + defp group_recursive([], %{"group" => group}), do: group + defp group_recursive([], %{"next" => %{"" => %{"group" => group}}}), do: group + + defp group_recursive([part | rest] = path, map) do + if next = Map.get(map["next"], part) do + group_recursive(rest, next) + else + if Enum.any?(map["validators"]) do + path = Enum.join(path, "-") + + case Enum.find(sort_validators(map["validators"]), fn {_key, %{"function" => validator}} -> validator.(path) end) do + nil -> nil + {_key, %{"group" => group}} -> group + end + end + end + end + + defp group_recursive(_, _), do: nil + + defp sort_validators(map) do + map + |> Enum.to_list() + |> Enum.reject(fn {key, _value} -> key == "any?" end) + |> Enum.sort() + |> append_any?(map) + end + + defp append_any?(list, map) do + case map["any?"] do + nil -> list + any? -> list ++ [{"any?", any?}] + end + end + + defp get_group_id_for_arbitrary_property(class_name) do + arbitrary_property_regex = ~r/^\[(.+)\]$/ + + if Regex.match?(arbitrary_property_regex, class_name) do + [_, arbitrary_property_class_name] = Regex.run(arbitrary_property_regex, class_name) + + property = + arbitrary_property_class_name + |> String.split(":", parts: 2) + |> List.first() + + if property do + ".." <> property + end + end + end + + defp modifier_id(parsed_class) do + modifiers = parsed_class |> Keyword.get_values(:modifier) |> sort_modifiers() |> Enum.join(":") + if Keyword.has_key?(parsed_class, :important), do: modifiers <> "!", else: modifiers + end + + @doc """ + Sort modifiers according to the following rules: + + - All known modifiers are sorted alphabetically. + - Arbitrary modifiers retain their position. + """ + def sort_modifiers(modifiers) do + modifiers + |> Enum.reduce({[], []}, fn modifier, {sorted, unsorted} -> + if String.starts_with?(modifier, "[") do + {sorted ++ Enum.sort(unsorted) ++ [modifier], []} + else + {sorted, unsorted ++ [modifier]} + end + end) + |> then(fn {sorted, unsorted} -> sorted ++ Enum.sort(unsorted) end) + end +end diff --git a/lib/utils/merge/class_tree.ex b/lib/utils/merge/class_tree.ex new file mode 100644 index 0000000..3d103e0 --- /dev/null +++ b/lib/utils/merge/class_tree.ex @@ -0,0 +1,123 @@ +defmodule SaladUI.Merge.ClassTree do + @moduledoc false + + alias SaladUI.Cache + alias SaladUI.Merge.Config + + def get do + case Cache.retrieve(:class_tree) do + nil -> + class_tree = generate() + Cache.insert(:class_tree, class_tree) + class_tree + + class_tree -> + class_tree + end + end + + def generate do + config = Config.config() + theme = Map.get(config, :theme, %{}) + prefix = Map.get(config, :prefix) + groups = Map.get(config, :groups, []) + + groups + |> Enum.reduce(%{}, fn {group, next}, acc -> + DeepMerge.deep_merge(acc, handle_next(next, theme, group)) + end) + |> add_prefix(prefix) + end + + defp handle_next(next, theme, group) do + Enum.reduce(next, %{"next" => %{}, "validators" => %{}}, fn part, acc -> + case part do + {sub_group, sub_next} -> + add_group(sub_group, sub_next, theme, group, acc) + + part -> + add_part(part, theme, group, acc) + end + end) + end + + defp add_part(part, _theme, group, acc) when is_binary(part) do + next = + part + |> String.split("-") + |> case do + [] -> %{} + [single] -> %{single => %{"group" => group, "next" => %{}, "validators" => %{}}} + multiple -> add_part_recursive(multiple, group) + end + + Map.update(acc, "next", %{}, &DeepMerge.deep_merge(&1, next)) + end + + defp add_part(part, theme, group, acc) when is_map(part) do + DeepMerge.deep_merge(acc, handle_next(part, theme, group)) + end + + defp add_part(part, theme, group, acc) when is_function(part) do + if from_theme?(part) do + flattened_theme = flatten_theme(part, theme, group) + + acc + |> Map.update("next", %{}, &DeepMerge.deep_merge(&1, flattened_theme["next"])) + |> Map.update("validators", %{}, &DeepMerge.deep_merge(&1, flattened_theme["validators"])) + else + {:name, key} = Function.info(part, :name) + + Map.update(acc, "validators", %{}, fn validators -> + Map.put(validators, to_string(key), %{"function" => part, "group" => group}) + end) + end + end + + defp add_part_recursive([single], group) do + %{single => %{"group" => group, "next" => %{}, "validators" => %{}}} + end + + defp add_part_recursive([current | rest], group) do + %{current => %{"next" => add_part_recursive(rest, group)}} + end + + defp add_group(sub_group, sub_next, theme, group, acc) do + sub_group + |> String.split("-") + |> add_nested_group(sub_next, theme, group, acc) + end + + defp add_nested_group([current | rest], sub_next, theme, group, acc) do + Map.update(acc, "next", %{}, &Map.put(&1, current, add_nested_group(rest, List.flatten(sub_next), theme, group, acc))) + end + + defp add_nested_group([], sub_next, theme, group, _acc) do + handle_next(sub_next, theme, group) + end + + defp from_theme?(function), do: Function.info(function, :module) == {:module, Config} + + defp flatten_theme(function, theme, group, acc \\ %{"next" => %{}, "validators" => %{}}) do + Enum.reduce(function.(theme), acc, fn + item, acc when is_binary(item) -> + Map.update(acc, "next", %{}, &Map.put(&1, item, %{"next" => %{}, "validators" => %{}, "group" => group})) + + item, acc when is_function(item) -> + if from_theme?(item) do + flatten_theme(item, theme, group, acc) + else + Map.update(acc, "validators", %{}, fn validators -> + {:name, key} = Function.info(item, :name) + Map.put(validators, to_string(key), %{"function" => item, "group" => group}) + end) + end + end) + end + + defp add_prefix(groups, nil), do: groups + + defp add_prefix(groups, prefix) do + %{"next" => %{String.trim_trailing(prefix, "-") => groups}, "validators" => %{}} + end +end diff --git a/lib/utils/merge/config.ex b/lib/utils/merge/config.ex new file mode 100644 index 0000000..db45d89 --- /dev/null +++ b/lib/utils/merge/config.ex @@ -0,0 +1,686 @@ +defmodule SaladUI.Merge.Config do + @moduledoc false + import SaladUI.Merge.Validator + + @theme_groups ~w(colors spacing blur brightness borderColor borderRadius borderSpacing borderWidth contrast grayscale hueRotate invert gap gradientColorStops gradientColorStopPositions inset margin opacity padding saturate scale sepia skew space translate) + + def config do + prefix = Application.get_env(:turboprop, :prefix) + theme = Application.get_env(:turboprop, :theme, default_theme()) + + Map.merge(%{prefix: prefix, theme: theme}, settings()) + end + + defp default_theme do + %{ + colors: [&any?/1], + spacing: [&length?/1, &arbitrary_length?/1], + blur: ["none", "", &tshirt_size?/1, &arbitrary_value?/1], + brightness: number(), + borderColor: [&from_theme(&1, "colors")], + borderRadius: ["none", "", "full", &tshirt_size?/1, &arbitrary_value?/1], + borderSpacing: spacing_with_arbitrary(), + borderWidth: length_with_empty_and_arbitrary(), + contrast: number(), + grayscale: zero_and_empty(), + hueRotate: number_and_arbitrary(), + invert: zero_and_empty(), + gap: spacing_with_arbitrary(), + gradientColorStops: [&from_theme(&1, "colors")], + gradientColorStopPositions: [&percent?/1, &arbitrary_length?/1], + inset: spacing_with_auto_and_arbitrary(), + margin: spacing_with_auto_and_arbitrary(), + opacity: number(), + padding: spacing_with_arbitrary(), + saturate: number(), + scale: number(), + sepia: zero_and_empty(), + skew: number_and_arbitrary(), + space: spacing_with_arbitrary(), + translate: spacing_with_arbitrary() + } + end + + defp settings do + %{ + groups: %{ + "aspect" => %{"aspect" => ["auto", "square", "video", &arbitrary_value?/1]}, + "container" => ["container"], + "columns" => %{"columns" => [&tshirt_size?/1]}, + "break-after" => %{"break-after" => breaks()}, + "break-before" => %{"break-before" => breaks()}, + "break-inside" => %{"break-inside" => ["auto", "avoid", "avoid-page", "avoid-column"]}, + "box-decoration" => %{"box-decoration" => ["slice", "clone"]}, + "box" => %{"box" => ["border", "content"]}, + "display" => [ + "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" + ], + "float" => %{"float" => ["right", "left", "none", "start", "end"]}, + "clear" => %{"clear" => ["left", "right", "both", "none", "start", "end"]}, + "isolation" => ["isolate", "isolation-auto"], + "object-fit" => %{"object" => ["contain", "cover", "fill", "none", "scale-down"]}, + "object-position" => %{"object" => [positions(), &arbitrary_value?/1]}, + "overflow" => %{"overflow" => overflow()}, + "overflow-x" => %{"overflow-x" => overflow()}, + "overflow-y" => %{"overflow-y" => overflow()}, + "overscroll" => %{"overscroll" => overscroll()}, + "overscroll-x" => %{"overscroll-x" => overscroll()}, + "overscroll-y" => %{"overscroll-y" => overscroll()}, + "position" => ["static", "fixed", "absolute", "relative", "sticky"], + "inset" => %{"inset" => [&from_theme(&1, "inset")]}, + "inset-x" => %{"inset-x" => [&from_theme(&1, "inset")]}, + "inset-y" => %{"inset-y" => [&from_theme(&1, "inset")]}, + "start" => %{"start" => [&from_theme(&1, "inset")]}, + "end" => %{"end" => [&from_theme(&1, "inset")]}, + "top" => %{"top" => [&from_theme(&1, "inset")]}, + "right" => %{"right" => [&from_theme(&1, "inset")]}, + "bottom" => %{"bottom" => [&from_theme(&1, "inset")]}, + "left" => %{"left" => [&from_theme(&1, "inset")]}, + "visibility" => ["visible", "invisible", "collapse"], + "z" => %{"z" => ["auto", &integer?/1, &arbitrary_value?/1]}, + "basis" => %{"basis" => spacing_with_auto_and_arbitrary()}, + "flex-direction" => %{"flex" => ["row", "row-reverse", "col", "col-reverse"]}, + "flex-wrap" => %{"flex" => ["wrap", "wrap-reverse", "nowrap"]}, + "flex" => %{"flex" => ["1", "auto", "initial", "none", &arbitrary_value?/1]}, + "grow" => %{"grow" => zero_and_empty()}, + "shrink" => %{"shrink" => zero_and_empty()}, + "order" => %{"order" => ["first", "last", "none", &integer?/1, &arbitrary_value?/1]}, + "grid-cols" => %{"grid-cols" => [&any?/1]}, + "col-start-end" => %{"col" => ["auto", %{"span" => ["full", &integer?/1, &arbitrary_value?/1]}, &arbitrary_value?/1]}, + "col-start" => %{"col-start" => number_with_auto_and_arbitrary()}, + "col-end" => %{"col-end" => number_with_auto_and_arbitrary()}, + "grid-rows" => %{"grid-rows" => [&any?/1]}, + "row-start-end" => %{"row" => ["auto", %{"span" => ["full", &integer?/1, &arbitrary_value?/1]}, &arbitrary_value?/1]}, + "row-start" => %{"row-start" => number_with_auto_and_arbitrary()}, + "row-end" => %{"row-end" => number_with_auto_and_arbitrary()}, + "grid-flow" => %{"grid-flow" => ["row", "col", "dense", "row-dense", "col-dense"]}, + "auto-cols" => %{"auto-cols" => ["auto", "min", "max", "fr", &arbitrary_value?/1]}, + "auto-rows" => %{"auto-rows" => ["auto", "min", "max", "fr", &arbitrary_value?/1]}, + "gap" => %{"gap" => [&from_theme(&1, "gap")]}, + "gap-x" => %{"gap-x" => [&from_theme(&1, "gap")]}, + "gap-y" => %{"gap-y" => [&from_theme(&1, "gap")]}, + "justify-content" => %{"justify" => ["normal", align()]}, + "justify-items" => %{"justify-items" => ["start", "end", "center", "stretch"]}, + "justify-self" => %{"justify-self" => ["auto", "start", "end", "center", "stretch"]}, + "align-content" => %{"content" => ["normal", align(), "baseline"]}, + "align-items" => %{"items" => ["start", "end", "center", "baseline", "stretch"]}, + "align-self" => %{"self" => ["auto", "start", "end", "center", "stretch", "baseline"]}, + "place-content" => %{"place-content" => [align(), "baseline"]}, + "place-items" => %{"place-items" => ["start", "end", "center", "baseline", "stretch"]}, + "place-self" => %{"place-self" => ["auto", "start", "end", "center", "stretch"]}, + "p" => %{"p" => [&from_theme(&1, "padding")]}, + "px" => %{"px" => [&from_theme(&1, "padding")]}, + "py" => %{"py" => [&from_theme(&1, "padding")]}, + "ps" => %{"ps" => [&from_theme(&1, "padding")]}, + "pe" => %{"pe" => [&from_theme(&1, "padding")]}, + "pt" => %{"pt" => [&from_theme(&1, "padding")]}, + "pr" => %{"pr" => [&from_theme(&1, "padding")]}, + "pb" => %{"pb" => [&from_theme(&1, "padding")]}, + "pl" => %{"pl" => [&from_theme(&1, "padding")]}, + "m" => %{"m" => [&from_theme(&1, "margin")]}, + "mx" => %{"mx" => [&from_theme(&1, "margin")]}, + "my" => %{"my" => [&from_theme(&1, "margin")]}, + "ms" => %{"ms" => [&from_theme(&1, "margin")]}, + "me" => %{"me" => [&from_theme(&1, "margin")]}, + "mt" => %{"mt" => [&from_theme(&1, "margin")]}, + "mr" => %{"mr" => [&from_theme(&1, "margin")]}, + "mb" => %{"mb" => [&from_theme(&1, "margin")]}, + "ml" => %{"ml" => [&from_theme(&1, "margin")]}, + "space-x" => %{"space-x" => [&from_theme(&1, "space")]}, + "space-x-reverse" => ["space-x-reverse"], + "space-y" => %{"space-y" => [&from_theme(&1, "space")]}, + "space-y-reverse" => ["space-y-reverse"], + "w" => %{"w" => ["auto", "min", "max", "fit", "svw", "lvw", "dvw", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "min-w" => %{"min-w" => ["min", "max", "fit", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "max-w" => %{ + "max-w" => [ + "none", + "full", + "min", + "max", + "fit", + "prose", + &tshirt_size?/1, + &arbitrary_value?/1, + &from_theme(&1, "spacing"), + %{"screen" => [&tshirt_size?/1]} + ] + }, + "h" => %{ + "h" => [ + "auto", + "min", + "max", + "fit", + "svh", + "lvh", + "dvh", + &from_theme(&1, "spacing"), + &arbitrary_value?/1 + ] + }, + "min-h" => %{"min-h" => ["min", "max", "fit", "svh", "lvh", "dvh", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "max-h" => %{"max-h" => ["min", "max", "fit", "svh", "lvh", "dvh", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "size" => %{"size" => ["auto", "min", "max", "fit", &from_theme(&1, "spacing"), &arbitrary_value?/1]}, + "font-size" => %{"text" => ["base", &tshirt_size?/1, &arbitrary_length?/1]}, + "font-smoothing" => ["antialiased", "subpixel-antialiased"], + "font-style" => ["italic", "not-italic"], + "font-weight" => %{ + "font" => [ + "thin", + "extralight", + "light", + "normal", + "medium", + "semibold", + "bold", + "extrabold", + "black", + &arbitrary_number?/1 + ] + }, + "font-family" => %{"font" => [&any?/1]}, + "fvn-normal" => ["normal-nums"], + "fvn-ordinal" => ["ordinal"], + "fvn-slashed-zero" => ["slashed-zero"], + "fvn-figure" => ["lining-nums", "oldstyle-nums"], + "fvn-spacing" => ["proportional-nums", "tabular-nums"], + "fvn-fraction" => ["diagonal-fractions", "stacked-fractions"], + "tracking" => %{ + "tracking" => [ + "tighter", + "tight", + "normal", + "wide", + "wider", + "widest", + &arbitrary_value?/1 + ] + }, + "line-clamp" => %{"line-clamp" => ["none", &number?/1, &arbitrary_number?/1]}, + "leading" => %{ + "leading" => [ + "none", + "tight", + "snug", + "normal", + "relaxed", + "loose", + &length?/1, + &arbitrary_value?/1 + ] + }, + "list-image" => %{"list-image" => ["none", &arbitrary_value?/1]}, + "list-style-type" => %{"list" => ["none", "disc", "decimal", &arbitrary_value?/1]}, + "list-style-position" => %{"list" => ["inside", "outside"]}, + "placeholder-color" => %{"placeholder" => [&from_theme(&1, "colors")]}, + "placeholder-opacity" => %{"placeholder-opacity" => [&from_theme(&1, "opacity")]}, + "text-alignment" => %{"text" => ["left", "center", "right", "justify", "start", "end"]}, + "text-color" => %{"text" => [&from_theme(&1, "colors")]}, + "text-opacity" => %{"text-opacity" => [&from_theme(&1, "opacity")]}, + "text-decoration" => ["underline", "overline", "line-through", "no-underline"], + "text-decoration-style" => %{"decoration" => [line_styles(), "wavy"]}, + "text-decoration-thickness" => %{"decoration" => ["auto", "from-font", &length?/1, &arbitrary_length?/1]}, + "underline-offset" => %{"underline-offset" => ["auto", &length?/1, &arbitrary_value?/1]}, + "text-decoration-color" => %{"decoration" => [&from_theme(&1, "colors")]}, + "text-transform" => ["uppercase", "lowercase", "capitalize", "normal-case"], + "text-overflow" => ["truncate", "text-ellipsis", "text-clip"], + "text-wrap" => %{"text" => ["wrap", "nowrap", "balance", "pretty"]}, + "indent" => %{"indent" => spacing_with_arbitrary()}, + "vertical-align" => %{ + "align" => [ + "baseline", + "top", + "middle", + "bottom", + "text-top", + "text-bottom", + "sub", + "super", + &arbitrary_value?/1 + ] + }, + "whitespace" => %{"whitespace" => ["normal", "nowrap", "pre", "pre-line", "pre-wrap", "break-spaces"]}, + "break" => %{"break" => ["normal", "words", "all", "keep"]}, + "hyphens" => %{"hyphens" => ["none", "manual", "auto"]}, + "content" => %{"content" => ["none", &arbitrary_value?/1]}, + "bg-attachment" => %{"bg" => ["fixed", "local", "scroll"]}, + "bg-clip" => %{"bg-clip" => ["border", "padding", "content", "text"]}, + "bg-opacity" => %{"bg-opacity" => [&from_theme(&1, "opacity")]}, + "bg-origin" => %{"bg-origin" => ["border", "padding", "content"]}, + "bg-position" => %{"bg" => [positions(), &arbitrary_position?/1]}, + "bg-repeat" => %{"bg" => ["no-repeat", %{"repeat" => ["", "x", "y", "round", "space"]}]}, + "bg-size" => %{"bg" => ["auto", "cover", "contain", &arbitrary_size?/1]}, + "bg-image" => %{ + "bg" => [ + "none", + %{"gradient-to" => ["t", "tr", "r", "br", "b", "bl", "l", "tl"]}, + &arbitrary_image?/1 + ] + }, + "bg-color" => %{"bg" => [&from_theme(&1, "colors")]}, + "gradient-from-pos" => %{"from" => [&from_theme(&1, "gradientColorStopPositions")]}, + "gradient-via-pos" => %{"via" => [&from_theme(&1, "gradientColorStopPositions")]}, + "gradient-to-pos" => %{"to" => [&from_theme(&1, "gradientColorStopPositions")]}, + "gradient-from" => %{"from" => [&from_theme(&1, "gradientColorStops")]}, + "gradient-via" => %{"via" => [&from_theme(&1, "gradientColorStops")]}, + "gradient-to" => %{"to" => [&from_theme(&1, "gradientColorStops")]}, + "rounded" => %{"rounded" => [&from_theme(&1, "borderRadius")]}, + "rounded-s" => %{"rounded-s" => [&from_theme(&1, "borderRadius")]}, + "rounded-e" => %{"rounded-e" => [&from_theme(&1, "borderRadius")]}, + "rounded-t" => %{"rounded-t" => [&from_theme(&1, "borderRadius")]}, + "rounded-r" => %{"rounded-r" => [&from_theme(&1, "borderRadius")]}, + "rounded-b" => %{"rounded-b" => [&from_theme(&1, "borderRadius")]}, + "rounded-l" => %{"rounded-l" => [&from_theme(&1, "borderRadius")]}, + "rounded-ss" => %{"rounded-ss" => [&from_theme(&1, "borderRadius")]}, + "rounded-se" => %{"rounded-se" => [&from_theme(&1, "borderRadius")]}, + "rounded-ee" => %{"rounded-ee" => [&from_theme(&1, "borderRadius")]}, + "rounded-es" => %{"rounded-es" => [&from_theme(&1, "borderRadius")]}, + "rounded-tl" => %{"rounded-tl" => [&from_theme(&1, "borderRadius")]}, + "rounded-tr" => %{"rounded-tr" => [&from_theme(&1, "borderRadius")]}, + "rounded-br" => %{"rounded-br" => [&from_theme(&1, "borderRadius")]}, + "rounded-bl" => %{"rounded-bl" => [&from_theme(&1, "borderRadius")]}, + "border-w" => %{"border" => [&from_theme(&1, "borderWidth")]}, + "border-w-x" => %{"border-x" => [&from_theme(&1, "borderWidth")]}, + "border-w-y" => %{"border-y" => [&from_theme(&1, "borderWidth")]}, + "border-w-s" => %{"border-s" => [&from_theme(&1, "borderWidth")]}, + "border-w-e" => %{"border-e" => [&from_theme(&1, "borderWidth")]}, + "border-w-t" => %{"border-t" => [&from_theme(&1, "borderWidth")]}, + "border-w-r" => %{"border-r" => [&from_theme(&1, "borderWidth")]}, + "border-w-b" => %{"border-b" => [&from_theme(&1, "borderWidth")]}, + "border-w-l" => %{"border-l" => [&from_theme(&1, "borderWidth")]}, + "border-opacity" => %{"border-opacity" => [&from_theme(&1, "opacity")]}, + "border-style" => %{"border" => [line_styles(), "hidden"]}, + "divide-x" => %{"divide-x" => [&from_theme(&1, "borderWidth")]}, + "divide-x-reverse" => ["divide-x-reverse"], + "divide-y" => %{"divide-y" => [&from_theme(&1, "borderWidth")]}, + "divide-y-reverse" => ["divide-y-reverse"], + "divide-opacity" => %{"divide-opacity" => [&from_theme(&1, "opacity")]}, + "divide-style" => %{"divide" => line_styles()}, + "border-color" => %{"border" => [&from_theme(&1, "borderColor")]}, + "border-color-x" => %{"border-x" => [&from_theme(&1, "borderColor")]}, + "border-color-y" => %{"border-y" => [&from_theme(&1, "borderColor")]}, + "border-color-t" => %{"border-t" => [&from_theme(&1, "borderColor")]}, + "border-color-r" => %{"border-r" => [&from_theme(&1, "borderColor")]}, + "border-color-b" => %{"border-b" => [&from_theme(&1, "borderColor")]}, + "border-color-l" => %{"border-l" => [&from_theme(&1, "borderColor")]}, + "divide-color" => %{"divide" => [&from_theme(&1, "borderColor")]}, + "outline-style" => %{"outline" => ["", line_styles()]}, + "outline-offset" => %{"outline-offset" => [&length?/1, &arbitrary_value?/1]}, + "outline-w" => %{"outline" => [&length?/1, &arbitrary_length?/1]}, + "outline-color" => %{"outline" => [&from_theme(&1, "colors")]}, + "ring-w" => %{"ring" => length_with_empty_and_arbitrary()}, + "ring-w-inset" => ["ring-inset"], + "ring-color" => %{"ring" => [&from_theme(&1, "colors")]}, + "ring-opacity" => %{"ring-opacity" => [&from_theme(&1, "opacity")]}, + "ring-offset-w" => %{"ring-offset" => [&length?/1, &arbitrary_length?/1]}, + "ring-offset-color" => %{"ring-offset" => [&from_theme(&1, "colors")]}, + "shadow" => %{"shadow" => ["", "inner", "none", &tshirt_size?/1, &arbitrary_shadow?/1]}, + "shadow-color" => %{"shadow" => [&any?/1]}, + "opacity" => %{"opacity" => [&from_theme(&1, "opacity")]}, + "mix-blend" => %{"mix-blend" => [blend_modes(), "plus-lighter", "plus-darker"]}, + "bg-blend" => %{"bg-blend" => blend_modes()}, + "filter" => %{"filter" => ["", "none"]}, + "blur" => %{"blur" => [&from_theme(&1, "blur")]}, + "brightness" => %{"brightness" => [&from_theme(&1, "brightness")]}, + "contrast" => %{"contrast" => [&from_theme(&1, "contrast")]}, + "drop-shadow" => %{"drop-shadow" => ["", "none", &tshirt_size?/1, &arbitrary_value?/1]}, + "grayscale" => %{"grayscale" => [&from_theme(&1, "grayscale")]}, + "hue-rotate" => %{"hue-rotate" => [&from_theme(&1, "hueRotate")]}, + "invert" => %{"invert" => [&from_theme(&1, "invert")]}, + "saturate" => %{"saturate" => [&from_theme(&1, "saturate")]}, + "sepia" => %{"sepia" => [&from_theme(&1, "sepia")]}, + "backdrop-filter" => %{"backdrop-filter" => ["", "none"]}, + "backdrop-blur" => %{"backdrop-blur" => [&from_theme(&1, "blur")]}, + "backdrop-brightness" => %{"backdrop-brightness" => [&from_theme(&1, "brightness")]}, + "backdrop-contrast" => %{"backdrop-contrast" => [&from_theme(&1, "contrast")]}, + "backdrop-grayscale" => %{"backdrop-grayscale" => [&from_theme(&1, "grayscale")]}, + "backdrop-hue-rotate" => %{"backdrop-hue-rotate" => [&from_theme(&1, "hueRotate")]}, + "backdrop-invert" => %{"backdrop-invert" => [&from_theme(&1, "invert")]}, + "backdrop-opacity" => %{"backdrop-opacity" => [&from_theme(&1, "opacity")]}, + "backdrop-saturate" => %{"backdrop-saturate" => [&from_theme(&1, "saturate")]}, + "backdrop-sepia" => %{"backdrop-sepia" => [&from_theme(&1, "sepia")]}, + "border-collapse" => %{"border" => ["collapse", "separate"]}, + "border-spacing" => %{"border-spacing" => [&from_theme(&1, "borderSpacing")]}, + "border-spacing-x" => %{"border-spacing-x" => [&from_theme(&1, "borderSpacing")]}, + "border-spacing-y" => %{"border-spacing-y" => [&from_theme(&1, "borderSpacing")]}, + "table-layout" => %{"table" => ["auto", "fixed"]}, + "caption" => %{"caption" => ["top", "bottom"]}, + "transition" => %{ + "transition" => [ + "none", + "all", + "", + "colors", + "opacity", + "shadow", + "transform", + &arbitrary_value?/1 + ] + }, + "duration" => %{"duration" => number_and_arbitrary()}, + "ease" => %{"ease" => ["linear", "in", "out", "in-out", &arbitrary_value?/1]}, + "delay" => %{"delay" => number_and_arbitrary()}, + "animate" => %{"animate" => ["none", "spin", "ping", "pulse", "bounce", &arbitrary_value?/1]}, + "transform" => %{"transform" => ["", "gpu", "none"]}, + "scale" => %{"scale" => [&from_theme(&1, "scale")]}, + "scale-x" => %{"scale-x" => [&from_theme(&1, "scale")]}, + "scale-y" => %{"scale-y" => [&from_theme(&1, "scale")]}, + "rotate" => %{"rotate" => [&integer?/1, &arbitrary_value?/1]}, + "translate-x" => %{"translate-x" => [&from_theme(&1, "translate")]}, + "translate-y" => %{"translate-y" => [&from_theme(&1, "translate")]}, + "skew-x" => %{"skew-x" => [&from_theme(&1, "skew")]}, + "skew-y" => %{"skew-y" => [&from_theme(&1, "skew")]}, + "transform-origin" => %{ + "origin" => [ + "center", + "top", + "top-right", + "right", + "bottom-right", + "bottom", + "bottom-left", + "left", + "top-left", + &arbitrary_value?/1 + ] + }, + "accent" => %{"accent" => ["auto", &from_theme(&1, "colors")]}, + "appearance" => %{"appearance" => ["none", "auto"]}, + "cursor" => %{ + "cursor" => [ + "auto", + "default", + "pointer", + "wait", + "text", + "move", + "help", + "not-allowed", + "none", + "context-menu", + "progress", + "cell", + "crosshair", + "vertical-text", + "alias", + "copy", + "no-drop", + "grab", + "grabbing", + "all-scroll", + "col-resize", + "row-resize", + "n-resize", + "e-resize", + "s-resize", + "w-resize", + "ne-resize", + "nw-resize", + "se-resize", + "sw-resize", + "ew-resize", + "ns-resize", + "nesw-resize", + "nwse-resize", + "zoom-in", + "zoom-out", + &arbitrary_value?/1 + ] + }, + "caret-color" => %{"caret" => [&from_theme(&1, "colors")]}, + "pointer-events" => %{"pointer-events" => ["none", "auto"]}, + "resize" => %{"resize" => ["none", "y", "x", ""]}, + "scroll-behavior" => %{"scroll" => ["auto", "smooth"]}, + "scroll-m" => %{"scroll-m" => spacing_with_arbitrary()}, + "scroll-mx" => %{"scroll-mx" => spacing_with_arbitrary()}, + "scroll-my" => %{"scroll-my" => spacing_with_arbitrary()}, + "scroll-ms" => %{"scroll-ms" => spacing_with_arbitrary()}, + "scroll-me" => %{"scroll-me" => spacing_with_arbitrary()}, + "scroll-mt" => %{"scroll-mt" => spacing_with_arbitrary()}, + "scroll-mr" => %{"scroll-mr" => spacing_with_arbitrary()}, + "scroll-mb" => %{"scroll-mb" => spacing_with_arbitrary()}, + "scroll-ml" => %{"scroll-ml" => spacing_with_arbitrary()}, + "scroll-p" => %{"scroll-p" => spacing_with_arbitrary()}, + "scroll-px" => %{"scroll-px" => spacing_with_arbitrary()}, + "scroll-py" => %{"scroll-py" => spacing_with_arbitrary()}, + "scroll-ps" => %{"scroll-ps" => spacing_with_arbitrary()}, + "scroll-pe" => %{"scroll-pe" => spacing_with_arbitrary()}, + "scroll-pt" => %{"scroll-pt" => spacing_with_arbitrary()}, + "scroll-pr" => %{"scroll-pr" => spacing_with_arbitrary()}, + "scroll-pb" => %{"scroll-pb" => spacing_with_arbitrary()}, + "scroll-pl" => %{"scroll-pl" => spacing_with_arbitrary()}, + "snap-align" => %{"snap" => ["start", "end", "center", "align-none"]}, + "snap-stop" => %{"snap" => ["normal", "always"]}, + "snap-type" => %{"snap" => ["none", "x", "y", "both"]}, + "snap-strictness" => %{"snap" => ["mandatory", "proximity"]}, + "touch" => %{"touch" => ["auto", "none", "manipulation"]}, + "touch-x" => %{"touch-pan" => ["x", "left", "right"]}, + "touch-y" => %{"touch-pan" => ["y", "up", "down"]}, + "touch-pz" => ["touch-pinch-zoom"], + "select" => %{"select" => ["none", "text", "all", "auto"]}, + "will-change" => %{"will-change" => ["auto", "scroll", "contents", "transform", &arbitrary_value?/1]}, + "fill" => %{"fill" => [&from_theme(&1, "colors"), "none"]}, + "stroke-w" => %{"stroke" => [&length?/1, &arbitrary_length?/1, &arbitrary_number?/1]}, + "stroke" => %{"stroke" => [&from_theme(&1, "colors"), "none"]}, + "sr" => ["sr-only", "not-sr-only"], + "forced-color-adjust" => %{"forced-color-adjust" => ["auto", "none"]} + }, + conflicting_groups: %{ + "overflow" => ["overflow-x", "overflow-y"], + "overscroll" => ["overscroll-x", "overscroll-y"], + "inset" => ["inset-x", "inset-y", "start", "end", "top", "right", "bottom", "left"], + "inset-x" => ["right", "left"], + "inset-y" => ["top", "bottom"], + "flex" => ["basis", "grow", "shrink"], + "gap" => ["gap-x", "gap-y"], + "p" => ["px", "py", "ps", "pe", "pt", "pr", "pb", "pl"], + "px" => ["pr", "pl"], + "py" => ["pt", "pb"], + "m" => ["mx", "my", "ms", "me", "mt", "mr", "mb", "ml"], + "mx" => ["mr", "ml"], + "my" => ["mt", "mb"], + "size" => ["w", "h"], + "font-size" => ["leading"], + "fvn-normal" => [ + "fvn-ordinal", + "fvn-slashed-zero", + "fvn-figure", + "fvn-spacing", + "fvn-fraction" + ], + "fvn-ordinal" => ["fvn-normal"], + "fvn-slashed-zero" => ["fvn-normal"], + "fvn-figure" => ["fvn-normal"], + "fvn-spacing" => ["fvn-normal"], + "fvn-fraction" => ["fvn-normal"], + "line-clamp" => ["display", "overflow"], + "rounded" => [ + "rounded-s", + "rounded-e", + "rounded-t", + "rounded-r", + "rounded-b", + "rounded-l", + "rounded-ss", + "rounded-se", + "rounded-ee", + "rounded-es", + "rounded-tl", + "rounded-tr", + "rounded-br", + "rounded-bl" + ], + "rounded-s" => ["rounded-ss", "rounded-es"], + "rounded-e" => ["rounded-se", "rounded-ee"], + "rounded-t" => ["rounded-tl", "rounded-tr"], + "rounded-r" => ["rounded-tr", "rounded-br"], + "rounded-b" => ["rounded-br", "rounded-bl"], + "rounded-l" => ["rounded-tl", "rounded-bl"], + "border-spacing" => ["border-spacing-x", "border-spacing-y"], + "border-w" => [ + "border-w-s", + "border-w-e", + "border-w-t", + "border-w-r", + "border-w-b", + "border-w-l" + ], + "border-w-x" => ["border-w-r", "border-w-l"], + "border-w-y" => ["border-w-t", "border-w-b"], + "border-color" => ["border-color-t", "border-color-r", "border-color-b", "border-color-l"], + "border-color-x" => ["border-color-r", "border-color-l"], + "border-color-y" => ["border-color-t", "border-color-b"], + "scroll-m" => [ + "scroll-mx", + "scroll-my", + "scroll-ms", + "scroll-me", + "scroll-mt", + "scroll-mr", + "scroll-mb", + "scroll-ml" + ], + "scroll-mx" => ["scroll-mr", "scroll-ml"], + "scroll-my" => ["scroll-mt", "scroll-mb"], + "scroll-p" => [ + "scroll-px", + "scroll-py", + "scroll-ps", + "scroll-pe", + "scroll-pt", + "scroll-pr", + "scroll-pb", + "scroll-pl" + ], + "scroll-px" => ["scroll-pr", "scroll-pl"], + "scroll-py" => ["scroll-pt", "scroll-pb"], + "touch" => ["touch-x", "touch-y", "touch-pz"], + "touch-x" => ["touch"], + "touch-y" => ["touch"], + "touch-pz" => ["touch"] + }, + conflicting_group_modifiers: %{ + "font-size" => ["leading"] + } + } + end + + for group <- @theme_groups do + defp from_theme(theme, unquote(group)) do + theme[String.to_existing_atom(unquote(group))] || [] + end + end + + defp overscroll do + ["auto", "contain", "none"] + end + + defp overflow do + ["auto", "hidden", "clip", "visible", "scroll"] + end + + defp spacing_with_auto_and_arbitrary do + ["auto", &arbitrary_value?/1, &from_theme(&1, "spacing")] + end + + defp spacing_with_arbitrary do + [&arbitrary_value?/1, &from_theme(&1, "spacing")] + end + + defp length_with_empty_and_arbitrary do + ["", &length?/1, &arbitrary_length?/1] + end + + defp number_with_auto_and_arbitrary do + ["auto", &number?/1, &arbitrary_value?/1] + end + + defp positions do + [ + "bottom", + "center", + "left", + "left-bottom", + "left-top", + "right", + "right-bottom", + "right-top", + "top" + ] + end + + defp line_styles do + ["solid", "dashed", "dotted", "double", "none"] + end + + defp blend_modes do + [ + "normal", + "multiply", + "screen", + "overlay", + "darken", + "lighten", + "color-dodge", + "color-burn", + "hard-light", + "soft-light", + "difference", + "exclusion", + "hue", + "saturation", + "color", + "luminosity" + ] + end + + defp align do + ["start", "end", "center", "between", "around", "evenly", "stretch"] + end + + defp zero_and_empty do + ["", "0", &arbitrary_value?/1] + end + + defp breaks do + [ + "auto", + "avoid", + "all", + "avoid-page", + "page", + "left", + "right", + "column" + ] + end + + defp number do + [&number?/1, &arbitrary_number?/1] + end + + defp number_and_arbitrary do + [&number?/1, &arbitrary_value?/1] + end +end diff --git a/lib/utils/merge/parser.ex b/lib/utils/merge/parser.ex new file mode 100644 index 0000000..a03dba9 --- /dev/null +++ b/lib/utils/merge/parser.ex @@ -0,0 +1,46 @@ +defmodule SaladUI.Merge.Parser do + @moduledoc false + import NimbleParsec + + @chars [?a..?z, ?A..?Z, ?0..?9, ?-, ?_, ?., ?,, ?@, ?{, ?}, ?(, ?), ?>, ?*, ?&, ?', ?%, ?#] + + regular_chars = ascii_string(@chars, min: 1) + + modifier = + [parsec(:arbitrary), regular_chars] + |> choice() + |> times(min: 1) + |> ignore(string(":")) + |> reduce({Enum, :join, [""]}) + |> unwrap_and_tag(:modifier) + |> times(min: 1) + + important = "!" |> string() |> unwrap_and_tag(:important) + + base = + [parsec(:arbitrary), regular_chars] + |> choice() + |> times(min: 1) + |> reduce({Enum, :join, [""]}) + |> unwrap_and_tag(:base) + + postfix = + "/" + |> string() + |> ignore() + |> ascii_string([?a..?z, ?0..?9], min: 1) + |> unwrap_and_tag(:postfix) + + defparsec :arbitrary, + "[" + |> string() + |> concat(times(choice([parsec(:arbitrary), ascii_string(@chars ++ [?:, ?/], min: 1)]), min: 1)) + |> concat(string("]")) + + defparsec :class, + modifier + |> optional() + |> concat(optional(important)) + |> concat(base) + |> concat(optional(postfix)) +end diff --git a/lib/utils/merge/validator.ex b/lib/utils/merge/validator.ex new file mode 100644 index 0000000..24023ca --- /dev/null +++ b/lib/utils/merge/validator.ex @@ -0,0 +1,102 @@ +defmodule SaladUI.Merge.Validator do + @moduledoc false + @arbitrary_value_regex ~r/^\[(?:([a-z-]+):)?(.+)\]$/i + @fraction_regex ~r/^\d+\/\d+$/ + @tshirt_size_regex ~r/^(xs|sm|md|lg|(\d+(\.\d+)?)?xl)$/ + @image_regex ~r/^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$/ + @color_function_regex ~r/^(rgba?|hsla?|hwb|(ok)?(lab|lch))\(.+\)$/ + @shadow_regex ~r/^(inset_)?-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/ + @length_unit_regex ~r/\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$/ + + def arbitrary_value?(value) do + Regex.match?(@arbitrary_value_regex, value) + end + + def integer?(value) do + case Integer.parse(value) do + {_integer, ""} -> true + _else -> false + end + end + + def number?(value) do + # Tailwind allows settings float values without leading zeroes, such as `.01`, which `Float.parse/1` does not support. + value = + if Regex.match?(~r/^\.\d+$/, value) do + "0" <> value + else + value + end + + case Float.parse(value) do + {_float, ""} -> true + _else -> false + end + end + + def length?(value) do + number?(value) or value in ~w(px full screen) or Regex.match?(@fraction_regex, value) + end + + def percent?(value) do + String.ends_with?(value, "%") and number?(String.slice(value, 0..-2//1)) + end + + def tshirt_size?(value) do + Regex.match?(@tshirt_size_regex, value) + end + + def arbitrary_length?(value) do + arbitrary_value?(value, "length", &length_only?/1) + end + + def arbitrary_number?(value) do + arbitrary_value?(value, "number", &number?/1) + end + + def arbitrary_size?(value) do + arbitrary_value?(value, ~w(length size percentage), &never?/1) + end + + def arbitrary_position?(value) do + arbitrary_value?(value, "position", &never?/1) + end + + def arbitrary_image?(value) do + arbitrary_value?(value, ~w(image url), &image?/1) + end + + def image?(value), do: Regex.match?(@image_regex, value) + + def length_only?(value), do: Regex.match?(@length_unit_regex, value) and not Regex.match?(@color_function_regex, value) + + def arbitrary_shadow?(value) do + arbitrary_value?(value, "", &shadow?/1) + end + + def any?, do: true + + def any?(_value), do: true + + def never?(_value), do: false + + def shadow?(value), do: Regex.match?(@shadow_regex, value) + + def arbitrary_value?(value, label, test_value) do + case Regex.run(@arbitrary_value_regex, value) do + [_, label_part, actual_value] -> + if is_binary(label_part) and label_part != "" do + case label do + ^label_part -> true + list when is_list(list) -> label_part in list + _ -> false + end + else + test_value.(actual_value) + end + + _ -> + false + end + end +end diff --git a/mix.exs b/mix.exs index b9d8624..85cf65d 100644 --- a/mix.exs +++ b/mix.exs @@ -59,7 +59,8 @@ defmodule SaladUI.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:tails, "~> 0.1"}, + {:nimble_parsec, "~> 1.0"}, + {:deep_merge, "~> 1.0"}, {:phoenix_live_view, "~> 0.20.1"}, {:mix_test_watch, "~> 1.2", only: [:dev, :test]}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 77cafc2..1b92bb4 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, "excoveralls": {:hex, :excoveralls, "0.18.1", "a6f547570c6b24ec13f122a5634833a063aec49218f6fff27de9df693a15588c", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d65f79db146bb20399f23046015974de0079668b9abb2f5aac074d078da60b8d"}, @@ -21,7 +22,6 @@ "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, - "tails": {:hex, :tails, "0.1.11", "112a62ff06046805c052c3cf6aba6f64fd41e8f82fe8cc1705fb446d04400607", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a908c1e3191610d606f1518eb108b7abe3e91fcc2ebda16b321e917d0d8987dd"}, "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/priv/templates/component.eex b/priv/templates/component.eex index 674d381..0950c1a 100644 --- a/priv/templates/component.eex +++ b/priv/templates/component.eex @@ -16,9 +16,12 @@ defmodule <%= @module_name %>Web.Component do use Phoenix.Component import <%= @module_name %>Web.ComponentHelpers - import Tails, only: [classes: 1] alias Phoenix.LiveView.JS + + defp classes(input) do + SaladUI.Merge.merge(input) + end end end end diff --git a/test/mix/salad.init_test.exs b/test/mix/salad.init_test.exs index 8b9fab7..9ac1dd2 100644 --- a/test/mix/salad.init_test.exs +++ b/test/mix/salad.init_test.exs @@ -48,11 +48,6 @@ defmodule Mix.Tasks.Salad.InitTest do :timer.sleep(100) assert File.exists?(@default_components_path) - - config_content = File.read!("config/config.exs") - assert config_content =~ "config :tails, colors_file:" - assert config_content =~ "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")" - dev_config_content = File.read!("config/dev.exs") assert dev_config_content =~ "config :salad_ui, components_path:" assert dev_config_content =~ "Path.join(File.cwd!(), \"#{@default_components_path}\")" diff --git a/test/salad_ui/accordion_test.exs b/test/salad_ui/accordion_test.exs index 00cd7df..f96c34b 100644 --- a/test/salad_ui/accordion_test.exs +++ b/test/salad_ui/accordion_test.exs @@ -101,8 +101,9 @@ defmodule SaladUI.AccordionTest do assert html =~ ~s(name="my-group") - assert html =~ - ~s(class="flex py-4 transition-all items-center justify-between flex-1 font-medium hover:underline custom-class") + for class <- ~w(flex py-4 transition-all items-center justify-between flex-1 font-medium hover:underline custom-class) do + assert html =~ class + end end end @@ -114,10 +115,11 @@ defmodule SaladUI.AccordionTest do inner_block: [] }) - assert html =~ - ~s(
) + for class <- ~w(text-sm overflow-hidden grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 peer-open/accordion:grid-rows-[1fr]) do + assert html =~ class + end - assert html =~ ~s(
) + assert html =~ ~s(custom-class) end end end diff --git a/test/salad_ui/alert_dialog_test.exs b/test/salad_ui/alert_dialog_test.exs index 866117d..b1ae9df 100644 --- a/test/salad_ui/alert_dialog_test.exs +++ b/test/salad_ui/alert_dialog_test.exs @@ -1,8 +1,5 @@ defmodule SaladUI.AlertDialogTest do - use ExUnit.Case - use Phoenix.Component - - import Phoenix.LiveViewTest + use ComponentCase import SaladUI.AlertDialog # Helper function to set up assigns diff --git a/test/salad_ui/alert_test.exs b/test/salad_ui/alert_test.exs index 70ea79c..45bcf7b 100644 --- a/test/salad_ui/alert_test.exs +++ b/test/salad_ui/alert_test.exs @@ -16,7 +16,9 @@ defmodule SaladUI.AlertTest do """) - assert html =~ "
" + for class <- ~w(mb-1 tracking-tight font-medium leading-none) do + assert html =~ class + end assert html =~ "Heads up!" assert html =~ "Alert Descriptions" diff --git a/test/salad_ui/avatar_test.exs b/test/salad_ui/avatar_test.exs index 5a4b67d..0b91b9c 100644 --- a/test/salad_ui/avatar_test.exs +++ b/test/salad_ui/avatar_test.exs @@ -12,7 +12,9 @@ defmodule SaladUI.AvatarTest do <.avatar_image src="https://github.com/shadcn.png" /> """) - assert html =~ "CN """) - assert html =~ "" + for class <- ~w(flex rounded-full bg-primary text-white items-center justify-center w-full h-full) do + assert html =~ class + end assert html =~ "CN" end @@ -39,7 +43,9 @@ defmodule SaladUI.AvatarTest do """) - assert html =~ "" + for class <- ~w(flex rounded-full bg-primary text-white items-center justify-center w-full h-full) do + assert html =~ class + end assert html =~ "CN" end end diff --git a/test/salad_ui/badge_test.exs b/test/salad_ui/badge_test.exs index 0320805..abc2239 100644 --- a/test/salad_ui/badge_test.exs +++ b/test/salad_ui/badge_test.exs @@ -16,8 +16,6 @@ defmodule SaladUI.BadgeTest do ~w(inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground hover:bg-primary/80) do assert html =~ class end - - assert html =~ "
rendered_to_string() |> clean_string() - assert html =~ - "
This is the content of the header
" + for class <- ~w(flex text-center flex-col space-y-1.5 sm:text-left) do + assert html =~ class + end + assert html =~ "This is the content of the header" for css_class <- ~w(flex flex-col space-y-1.5 text-center sm:text-left) do assert html =~ css_class diff --git a/test/salad_ui/form_test.exs b/test/salad_ui/form_test.exs index 22f8913..ad0c015 100644 --- a/test/salad_ui/form_test.exs +++ b/test/salad_ui/form_test.exs @@ -29,8 +29,10 @@ defmodule SaladUI.FormTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(font-medium leading-none text-sm peer-disabled:cursor-not-allowed peer-disabled:opacity-70) do + assert html =~ class + end + assert html =~ "This is a label" end test "It renders form_control" do @@ -69,7 +71,10 @@ defmodule SaladUI.FormTest do |> rendered_to_string() |> clean_string() - assert html =~ "

This is a form message

" + for class <- ~w(text-destructive font-medium text-sm) do + assert html =~ class + end + assert html =~ "This is a form message" end test "It renders an entire form correctly" do @@ -106,11 +111,14 @@ defmodule SaladUI.FormTest do |> rendered_to_string() |> clean_string() + assert html =~ "
\What is your project's name?" + for class <- ~w(font-medium leading-none text-sm peer-disabled:cursor-not-allowed peer-disabled:opacity-70) do + assert html =~ class + end + assert html =~ "What is your project's name?" assert html =~ "

This is your public display name.

" assert html =~ "Save project" diff --git a/test/salad_ui/hover_card_test.exs b/test/salad_ui/hover_card_test.exs index 80956d7..4a69d14 100644 --- a/test/salad_ui/hover_card_test.exs +++ b/test/salad_ui/hover_card_test.exs @@ -1,7 +1,6 @@ defmodule SaladUI.HoverCardTest do use ComponentCase - import SaladUI.Button import SaladUI.HoverCard describe "test hover_card" do @@ -28,8 +27,10 @@ defmodule SaladUI.HoverCardTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(absolute hidden p-4 mb-2 rounded-md bg-popover text-popover-foreground outline-none shadow-md z-50 left-1/2 bottom-full w-64 -translate-x-1/2 animate-in border data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 fade-in-0 group-hover/hover-card:block slide-in-from-left-1/2 zoom-in-95) do + assert html =~ class + end + assert html =~ "Hover Card Content" end test "It renders hover_card_content bottom correctly" do @@ -42,8 +43,11 @@ defmodule SaladUI.HoverCardTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(absolute hidden p-4 mt-2 rounded-md bg-popover text-popover-foreground outline-none shadow-md z-50 left-1/2 top-full w-64 -translate-x-1/2 animate-in border data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 fade-in-0 group-hover/hover-card:block slide-in-from-left-1/2 zoom-in-95) do + assert html =~ class + end + assert html =~ "Hover Card Content" + assert html =~ "data-side=\"bottom\"" end test "It renders hover_card_content right correctly" do @@ -56,32 +60,10 @@ defmodule SaladUI.HoverCardTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" - end - - test "It renders hover_card correctly" do - assigns = %{} - - html = - ~H""" - <.hover_card> - <.hover_card_trigger> - <.button variant="link"> - @salad_ui - - - - <.hover_card_content> - Hover card content - - - """ - |> rendered_to_string() - |> clean_string() - - assert html =~ - "
" + for class <- ~w(absolute hidden p-4 ml-2 rounded-md bg-popover text-popover-foreground outline-none shadow-md z-50 left-full top-1/2 w-64 -translate-y-1/2 animate-in border data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 fade-in-0 group-hover/hover-card:block slide-in-from-top-1/2 zoom-in-95) do + assert html =~ class + end + assert html =~ "Hover Card Content" end end end diff --git a/test/salad_ui/input_test.exs b/test/salad_ui/input_test.exs index 5ff21d1..bdef3bc 100644 --- a/test/salad_ui/input_test.exs +++ b/test/salad_ui/input_test.exs @@ -14,8 +14,11 @@ defmodule SaladUI.InputTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(flex px-3 py-2 rounded-md ring-offset-background border-input bg-background text-sm w-full h-10 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 file:border-0 file:bg-transparent file:font-medium file:text-sm border) do + assert html =~ class + end + assert html =~ "text" + assert html =~ "Enter your name" end test "It renders email input correctly" do @@ -28,8 +31,11 @@ defmodule SaladUI.InputTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(flex px-3 py-2 rounded-md ring-offset-background border-input bg-background text-sm w-full h-10 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 file:border-0 file:bg-transparent file:font-medium file:text-sm border) do + assert html =~ class + end + assert html =~ "email" + assert html =~ "Enter your email" end test "It renders password input correctly" do @@ -42,8 +48,11 @@ defmodule SaladUI.InputTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(flex px-3 py-2 rounded-md ring-offset-background border-input bg-background text-sm w-full h-10 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 file:border-0 file:bg-transparent file:font-medium file:text-sm border) do + assert html =~ class + end + assert html =~ "password" + assert html =~ "Enter your password" end test "It renders dates input correctly" do @@ -56,8 +65,12 @@ defmodule SaladUI.InputTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + + for class <- ~w(flex px-3 py-2 rounded-md ring-offset-background border-input bg-background text-sm w-full h-10 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 file:border-0 file:bg-transparent file:font-medium file:text-sm border) do + assert html =~ class + end + assert html =~ "date" + assert html =~ "yyy-mm-dd" end test "It renders datetime-local input correctly" do @@ -70,8 +83,12 @@ defmodule SaladUI.InputTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(flex px-3 py-2 rounded-md ring-offset-background border-input bg-background text-sm w-full h-10 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 file:border-0 file:bg-transparent file:font-medium file:text-sm border) do + assert html =~ class + end + + assert html =~ "datetime-local" + assert html =~ "Date time" end test "It renders file input correctly" do @@ -84,8 +101,11 @@ defmodule SaladUI.InputTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(flex px-3 py-2 rounded-md ring-offset-background border-input bg-background text-sm w-full h-10 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 file:border-0 file:bg-transparent file:font-medium file:text-sm border) do + assert html =~ class + end + assert html =~ "file" + assert html =~ "Select file" end test "It renders hidden input correctly" do @@ -98,8 +118,12 @@ defmodule SaladUI.InputTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(flex px-3 py-2 rounded-md ring-offset-background border-input bg-background text-sm w-full h-10 placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 file:border-0 file:bg-transparent file:font-medium file:text-sm border) do + assert html =~ class + end + assert html =~ "hidden" + assert html =~ "secret" + assert html =~ "hard to get in" end end end diff --git a/test/salad_ui/label_test.exs b/test/salad_ui/label_test.exs index ec95692..3ccc81a 100644 --- a/test/salad_ui/label_test.exs +++ b/test/salad_ui/label_test.exs @@ -14,8 +14,10 @@ defmodule SaladUI.LabelTest do |> rendered_to_string() |> clean_string() - assert html =~ - "" + for class <- ~w(text-green-500 font-medium leading-none text-sm peer-disabled:cursor-not-allowed peer-disabled:opacity-70) do + assert html =~ class + end + assert html =~ "Send!" end end end diff --git a/test/salad_ui/menu_test.exs b/test/salad_ui/menu_test.exs index 803ad61..6bf1d32 100644 --- a/test/salad_ui/menu_test.exs +++ b/test/salad_ui/menu_test.exs @@ -19,7 +19,7 @@ defmodule SaladUi.MenuTest do # Confirm that all classes are being rendered correctly for css_class <- - ~w("relative flex px-2 py-1.5 rounded-sm select-none cursor-default transition-colors outline-none items-center text-sm hover:bg-accent focus:bg-accent focus:text-accent-foreground data-[disabled]:opacity-50 data-[disabled]:pointer-events-none") do + ~w(relative flex px-2 py-1.5 rounded-sm select-none cursor-default transition-colors outline-none items-center text-sm hover:bg-accent focus:bg-accent focus:text-accent-foreground data-[disabled]:opacity-50 data-[disabled]:pointer-events-none) do assert html =~ css_class end end @@ -34,7 +34,10 @@ defmodule SaladUi.MenuTest do |> rendered_to_string() |> clean_string() - assert html =~ "
Account
" + for css_class <- ~w(px-2 py-1.5 font-semibold text-sm) do + assert html =~ css_class + end + assert html =~ "Account" end test "It renders menu_separator correclty" do @@ -50,7 +53,7 @@ defmodule SaladUi.MenuTest do assert html =~ "\">
" assert html =~ "
rendered_to_string() |> clean_string() - assert html =~ "⌘B" + for css_class <- ~w(tracking-widest text-xs ml-auto opacity-60) do + assert html =~ css_class + end + assert html =~ "⌘B" end test "It renders menu_Group correctly" do @@ -127,8 +133,9 @@ defmodule SaladUi.MenuTest do |> rendered_to_string() |> clean_string() - assert html =~ - "
" + for css_class <- ~w(min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md top-0 left-full) do + assert html =~ css_class + end assert html =~ "
rendered_to_string() |> clean_string() - assert html =~ "
    " assert html =~ "Content goes here" end @@ -49,7 +53,7 @@ defmodule SaladUi.PaginationTest do assert html =~ ~r/3<\/a>/ for css_class <- - ~w("inline-flex rounded-md transition-colors whitespace-nowrap items-center justify-center font-medium text-sm w-9 h-9 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground") do + ~w(inline-flex rounded-md transition-colors whitespace-nowrap items-center justify-center font-medium text-sm w-9 h-9 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground) do assert html =~ css_class end end @@ -93,40 +97,5 @@ defmodule SaladUi.PaginationTest do assert html =~ "" end - - test "It renders pagination correctly" do - assigns = %{} - - html = - ~H""" - <.pagination> - <.pagination_content> - <.pagination_item> - <.pagination_previous href="#" /> - - <.pagination_item> - <.pagination_link href="">1 - - <.pagination_item> - <.pagination_link href="" is-active="true">2 - - <.pagination_item> - <.pagination_link href="">3 - - <.pagination_item> - <.pagination_ellipsis /> - - <.pagination_item> - <.pagination_next href="#" /> - - - - """ - |> rendered_to_string() - |> clean_string() - - assert html =~ "nav arial-label=\"pagination\" role=\"pagination\" class=\"flex justify-center w-full mx-auto\"" - assert html =~ "" - end end end diff --git a/test/salad_ui/patcher/config_patcher_test.exs b/test/salad_ui/patcher/config_patcher_test.exs index a5805db..27961ee 100644 --- a/test/salad_ui/patcher/config_patcher_test.exs +++ b/test/salad_ui/patcher/config_patcher_test.exs @@ -33,27 +33,8 @@ defmodule SaladUI.Patcher.ConfigPatcherTest do assert File.read!(@config_file) =~ "components_path: Path.join(File.cwd!(), \"#{@components_path}\")" end - test "patch/2 adds tails config when it's missing" do - initial_content = """ - import Config - """ - - File.write!(@config_file, initial_content) - - configs_to_add = [ - tails: %{ - description: "SaladUI use tails to properly merge Tailwind CSS classes", - values: [colors_file: "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")"] - } - ] - ConfigPatcher.patch(@config_file, configs: configs_to_add) - - assert File.read!(@config_file) =~ "config :tails," - assert File.read!(@config_file) =~ "colors_file: Path.join(File.cwd!(), \"assets/tailwind.colors.json\")" - end - - test "patch/2 adds both salad_ui and tails configs when they're missing" do + test "patch/2 adds both salad_ui configs when they're missing" do initial_content = """ import Config """ @@ -64,10 +45,6 @@ defmodule SaladUI.Patcher.ConfigPatcherTest do salad_ui: %{ description: "Path to install SaladUI components", values: [components_path: "Path.join(File.cwd!(), \"#{@components_path}\")"] - }, - tails: %{ - description: "SaladUI use tails to properly merge Tailwind CSS classes", - values: [colors_file: "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")"] } ] @@ -75,27 +52,19 @@ defmodule SaladUI.Patcher.ConfigPatcherTest do assert File.read!(@config_file) =~ "config :salad_ui," assert File.read!(@config_file) =~ "components_path: Path.join(File.cwd!(), \"#{@components_path}\")" - assert File.read!(@config_file) =~ "config :tails," - assert File.read!(@config_file) =~ "colors_file: Path.join(File.cwd!(), \"assets/tailwind.colors.json\")" end test "patch/2 doesn't add configs when they already exist" do initial_content = """ import Config config :salad_ui, components_path: "/some/path" - config :tails, colors_file: "/some/file.json" """ configs_to_add = [ salad_ui: %{ description: "Path to install SaladUI components", values: [components_path: "Path.join(File.cwd!(), \"#{@components_path}\")"] - }, - tails: %{ - description: "SaladUI use tails to properly merge Tailwind CSS classes", - values: [colors_file: "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")"] - } - ] + } ] File.write!(@config_file, initial_content) @@ -117,18 +86,12 @@ defmodule SaladUI.Patcher.ConfigPatcherTest do salad_ui: %{ description: "Path to install SaladUI components", values: [components_path: "Path.join(File.cwd!(), \"#{@components_path}\")"] - }, - tails: %{ - description: "SaladUI use tails to properly merge Tailwind CSS classes", - values: [colors_file: "Path.join(File.cwd!(), \"assets/tailwind.colors.json\")"] - } - ] + } ] ConfigPatcher.patch(@config_file, configs: configs_to_add) content = File.read!(@config_file) assert content =~ "config :salad_ui," - assert content =~ "config :tails," assert String.ends_with?(content, "import_config \"#{Mix.env()}.exs\"\n") end end diff --git a/test/salad_ui/popover_test.exs b/test/salad_ui/popover_test.exs index b97fa17..5467367 100644 --- a/test/salad_ui/popover_test.exs +++ b/test/salad_ui/popover_test.exs @@ -1,7 +1,6 @@ defmodule SaladUI.PopoverTest do use ComponentCase - import SaladUI.Button import SaladUI.Popover describe "test popover" do @@ -28,8 +27,11 @@ defmodule SaladUI.PopoverTest do |> rendered_to_string() |> clean_string() - assert html =~ - "
    Popover Content
    " + for class <- ~w(absolute hidden p-4 mb-2 rounded-md bg-popover text-popover-foreground outline-none shadow-md z-50 left-1/2 bottom-full w-72 -translate-x-1/2 animate-in border data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:hidden fade-in-0 slide-in-from-left-1/2 zoom-in-95) do + assert html =~ class + end + assert html =~ "Popover Content" + assert html =~ "data-side=\"top\"" end test "It renders popover_content bottom correctly" do @@ -42,8 +44,10 @@ defmodule SaladUI.PopoverTest do |> rendered_to_string() |> clean_string() - assert html =~ - "
    Popover Content
    " + for class <- ~w(absolute hidden p-4 mt-2 rounded-md bg-popover text-popover-foreground outline-none shadow-md z-50 left-1/2 top-full w-72 -translate-x-1/2 animate-in border data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:hidden fade-in-0 slide-in-from-left-1/2 zoom-in-95) do + assert html =~ class + end + assert html =~ "Popover Content" end test "It renders popover_content right correctly" do @@ -56,32 +60,11 @@ defmodule SaladUI.PopoverTest do |> rendered_to_string() |> clean_string() - assert html =~ - "
    Popover Content
    " - end - - test "It renders popover correctly" do - assigns = %{} - - html = - ~H""" - <.popover> - <.popover_trigger target="content-id"> - <.button variant="link"> - @salad_ui - - - - <.popover_content id="content-id"> - Hover card content - - - """ - |> rendered_to_string() - |> clean_string() - - assert html =~ - "div class=\"relative inline-block\">
    Hover card content
" + for class <- ~w(absolute hidden p-4 ml-2 rounded-md bg-popover text-popover-foreground outline-none shadow-md z-50 left-full top-1/2 w-72 -translate-y-1/2 animate-in border data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:hidden fade-in-0 slide-in-from-top-1/2 zoom-in-95) do + assert html =~ class + end + assert html =~ "Popover Content" + assert html =~ "data-side=\"right\"" end end end diff --git a/test/salad_ui/progress_test.exs b/test/salad_ui/progress_test.exs index ee08997..892fc9b 100644 --- a/test/salad_ui/progress_test.exs +++ b/test/salad_ui/progress_test.exs @@ -14,8 +14,14 @@ defmodule SaladUi.ProgressTest do |> rendered_to_string() |> clean_string() - assert html =~ "
" end end diff --git a/test/support/component_case.ex b/test/support/component_case.ex index eb3f147..541118f 100644 --- a/test/support/component_case.ex +++ b/test/support/component_case.ex @@ -4,6 +4,7 @@ defmodule ComponentCase do setup do # This will run before each test that uses this case + SaladUI.Cache.create_table() :ok end diff --git a/test/utils/merge_test.exs b/test/utils/merge_test.exs new file mode 100644 index 0000000..9e2ef2b --- /dev/null +++ b/test/utils/merge_test.exs @@ -0,0 +1,368 @@ +# MIT License +# +# Copyright (c) 2021 Dany Castillo +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +defmodule SaladUI.MergeTest do + use ExUnit.Case, async: false + + import SaladUI.Merge + + alias SaladUI.Cache + + doctest SaladUI.Merge + + setup do + Cache.create_table() + :ok + end + + describe "non-conflicting" do + test "merges non-conflicting classes correctly" do + assert merge(["border-t", "border-white/10"]) == "border-t border-white/10" + assert merge(["border-t", "border-white"]) == "border-t border-white" + assert merge(["text-3.5xl", "text-black"]) == "text-3.5xl text-black" + end + end + + describe "colors" do + test "handles color conflicts properly" do + assert merge("bg-grey-5 bg-hotpink") == "bg-hotpink" + assert merge("hover:bg-grey-5 hover:bg-hotpink") == "hover:bg-hotpink" + assert merge(["stroke-[hsl(350_80%_0%)]", "stroke-[10px]"]) == "stroke-[hsl(350_80%_0%)] stroke-[10px]" + end + end + + describe "borders" do + test "merges classes with per-side border colors correctly" do + assert merge(["border-t-some-blue", "border-t-other-blue"]) == "border-t-other-blue" + assert merge(["border-t-some-blue", "border-some-blue"]) == "border-some-blue" + end + end + + describe "group conflicts" do + test "merges classes from same group correctly" do + assert merge("overflow-x-auto overflow-x-hidden") == "overflow-x-hidden" + assert merge("basis-full basis-auto") == "basis-auto" + assert merge("w-full w-fit") == "w-fit" + assert merge("overflow-x-auto overflow-x-hidden overflow-x-scroll") == "overflow-x-scroll" + assert merge(["overflow-x-auto", "hover:overflow-x-hidden", "overflow-x-scroll"]) == "hover:overflow-x-hidden overflow-x-scroll" + + assert merge(["overflow-x-auto", "hover:overflow-x-hidden", "hover:overflow-x-auto", "overflow-x-scroll"]) == + "hover:overflow-x-auto overflow-x-scroll" + + assert merge("col-span-1 col-span-full") == "col-span-full" + end + + test "merges classes from Font Variant Numeric section correctly" do + assert merge(["lining-nums", "tabular-nums", "diagonal-fractions"]) == "lining-nums tabular-nums diagonal-fractions" + assert merge(["normal-nums", "tabular-nums", "diagonal-fractions"]) == "tabular-nums diagonal-fractions" + assert merge(["tabular-nums", "diagonal-fractions", "normal-nums"]) == "normal-nums" + assert merge("tabular-nums proportional-nums") == "proportional-nums" + end + end + + describe "conflicts across groups" do + test "handles conflicts across class groups correctly" do + assert merge("inset-1 inset-x-1") == "inset-1 inset-x-1" + assert merge("inset-x-1 inset-1") == "inset-1" + assert merge(["inset-x-1", "left-1", "inset-1"]) == "inset-1" + assert merge(["inset-x-1", "inset-1", "left-1"]) == "inset-1 left-1" + assert merge(["inset-x-1", "right-1", "inset-1"]) == "inset-1" + assert merge(["inset-x-1", "right-1", "inset-x-1"]) == "inset-x-1" + assert merge(["inset-x-1", "right-1", "inset-y-1"]) == "inset-x-1 right-1 inset-y-1" + assert merge(["right-1", "inset-x-1", "inset-y-1"]) == "inset-x-1 inset-y-1" + assert merge(["inset-x-1", "hover:left-1", "inset-1"]) == "hover:left-1 inset-1" + end + + test "ring and shadow classes do not create conflict" do + assert merge(["ring", "shadow"]) == "ring shadow" + assert merge(["ring-2", "shadow-md"]) == "ring-2 shadow-md" + assert merge(["shadow", "ring"]) == "shadow ring" + assert merge(["shadow-md", "ring-2"]) == "shadow-md ring-2" + end + + test "touch classes do create conflicts correctly" do + assert merge("touch-pan-x touch-pan-right") == "touch-pan-right" + assert merge("touch-none touch-pan-x") == "touch-pan-x" + assert merge("touch-pan-x touch-none") == "touch-none" + assert merge(["touch-pan-x", "touch-pan-y", "touch-pinch-zoom"]) == "touch-pan-x touch-pan-y touch-pinch-zoom" + assert merge(["touch-manipulation", "touch-pan-x", "touch-pan-y", "touch-pinch-zoom"]) == "touch-pan-x touch-pan-y touch-pinch-zoom" + assert merge(["touch-pan-x", "touch-pan-y", "touch-pinch-zoom", "touch-auto"]) == "touch-auto" + end + + test "line-clamp classes do create conflicts correctly" do + assert merge(["overflow-auto", "inline", "line-clamp-1"]) == "line-clamp-1" + assert merge(["line-clamp-1", "overflow-auto", "inline"]) == "line-clamp-1 overflow-auto inline" + end + end + + describe "arbitrary values" do + test "handles simple conflicts with arbitrary values correctly" do + assert merge("m-[2px] m-[10px]") == "m-[10px]" + + assert merge([ + "m-[2px]", + "m-[11svmin]", + "m-[12in]", + "m-[13lvi]", + "m-[14vb]", + "m-[15vmax]", + "m-[16mm]", + "m-[17%]", + "m-[18em]", + "m-[19px]", + "m-[10dvh]" + ]) == "m-[10dvh]" + + assert merge(["h-[10px]", "h-[11cqw]", "h-[12cqh]", "h-[13cqi]", "h-[14cqb]", "h-[15cqmin]", "h-[16cqmax]"]) == "h-[16cqmax]" + assert merge("z-20 z-[99]") == "z-[99]" + assert merge("my-[2px] m-[10rem]") == "m-[10rem]" + assert merge("cursor-pointer cursor-[grab]") == "cursor-[grab]" + assert merge("m-[2px] m-[calc(100%-var(--arbitrary))]") == "m-[calc(100%-var(--arbitrary))]" + assert merge("m-[2px] m-[length:var(--mystery-var)]") == "m-[length:var(--mystery-var)]" + assert merge("opacity-10 opacity-[0.025]") == "opacity-[0.025]" + assert merge("scale-75 scale-[1.7]") == "scale-[1.7]" + assert merge("brightness-90 brightness-[1.75]") == "brightness-[1.75]" + assert merge("min-h-[0.5px] min-h-[0]") == "min-h-[0]" + assert merge("text-[0.5px] text-[color:0]") == "text-[0.5px] text-[color:0]" + assert merge("text-[0.5px] text-[--my-0]") == "text-[0.5px] text-[--my-0]" + end + + test "handles arbitrary length conflicts with labels and modifiers correctly" do + assert merge("hover:m-[2px] hover:m-[length:var(--c)]") == "hover:m-[length:var(--c)]" + assert merge("hover:focus:m-[2px] focus:hover:m-[length:var(--c)]") == "focus:hover:m-[length:var(--c)]" + + assert merge("border-b border-[color:rgb(var(--color-gray-500-rgb)/50%))]") == + "border-b border-[color:rgb(var(--color-gray-500-rgb)/50%))]" + + assert merge("border-[color:rgb(var(--color-gray-500-rgb)/50%))] border-b") == + "border-[color:rgb(var(--color-gray-500-rgb)/50%))] border-b" + + assert merge(["border-b", "border-[color:rgb(var(--color-gray-500-rgb)/50%))]", "border-some-coloooor"]) == + "border-b border-some-coloooor" + end + + test "handles complex arbitrary value conflicts correctly" do + assert merge("grid-rows-[1fr,auto] grid-rows-2") == "grid-rows-2" + assert merge("grid-rows-[repeat(20,minmax(0,1fr))] grid-rows-3") == "grid-rows-3" + end + + test "handles ambiguous arbitrary values correctly" do + assert merge("mt-2 mt-[calc(theme(fontSize.4xl)/1.125)]") == "mt-[calc(theme(fontSize.4xl)/1.125)]" + assert merge("p-2 p-[calc(theme(fontSize.4xl)/1.125)_10px]") == "p-[calc(theme(fontSize.4xl)/1.125)_10px]" + assert merge("mt-2 mt-[length:theme(someScale.someValue)]") == "mt-[length:theme(someScale.someValue)]" + assert merge("mt-2 mt-[theme(someScale.someValue)]") == "mt-[theme(someScale.someValue)]" + assert merge("text-2xl text-[length:theme(someScale.someValue)]") == "text-[length:theme(someScale.someValue)]" + assert merge("text-2xl text-[calc(theme(fontSize.4xl)/1.125)]") == "text-[calc(theme(fontSize.4xl)/1.125)]" + assert merge(["bg-cover", "bg-[percentage:30%]", "bg-[length:200px_100px]"]) == "bg-[length:200px_100px]" + + assert merge(["bg-none", "bg-[url(.)]", "bg-[image:.]", "bg-[url:.]", "bg-[linear-gradient(.)]", "bg-gradient-to-r"]) == + "bg-gradient-to-r" + end + end + + describe "arbitrary properties" do + test "handles arbitrary property conflicts correctly" do + assert merge("[paint-order:markers] [paint-order:normal]") == "[paint-order:normal]" + + assert merge(["[paint-order:markers]", "[--my-var:2rem]", "[paint-order:normal]", "[--my-var:4px]"]) == + "[paint-order:normal] [--my-var:4px]" + end + + test "handles arbitrary property conflicts with modifiers correctly" do + assert merge("[paint-order:markers] hover:[paint-order:normal]") == "[paint-order:markers] hover:[paint-order:normal]" + assert merge("hover:[paint-order:markers] hover:[paint-order:normal]") == "hover:[paint-order:normal]" + assert merge("hover:focus:[paint-order:markers] focus:hover:[paint-order:normal]") == "focus:hover:[paint-order:normal]" + + assert merge(["[paint-order:markers]", "[paint-order:normal]", "[--my-var:2rem]", "lg:[--my-var:4px]"]) == + "[paint-order:normal] [--my-var:2rem] lg:[--my-var:4px]" + end + + test "handles complex arbitrary property conflicts correctly" do + assert merge("[-unknown-prop:::123:::] [-unknown-prop:url(https://hi.com)]") == "[-unknown-prop:url(https://hi.com)]" + end + + test "handles important modifier correctly" do + assert merge("![some:prop] [some:other]") == "![some:prop] [some:other]" + assert merge("![some:prop] [some:other] [some:one] ![some:another]") == "[some:one] ![some:another]" + end + end + + describe "pseudo variants" do + test "handles pseudo variants conflicts properly" do + assert merge(["empty:p-2", "empty:p-3"]) == "empty:p-3" + assert merge(["hover:empty:p-2", "hover:empty:p-3"]) == "hover:empty:p-3" + assert merge(["read-only:p-2", "read-only:p-3"]) == "read-only:p-3" + end + + test "handles pseudo variant group conflicts properly" do + assert merge(["group-empty:p-2", "group-empty:p-3"]) == "group-empty:p-3" + assert merge(["peer-empty:p-2", "peer-empty:p-3"]) == "peer-empty:p-3" + assert merge(["group-empty:p-2", "peer-empty:p-3"]) == "group-empty:p-2 peer-empty:p-3" + assert merge(["hover:group-empty:p-2", "hover:group-empty:p-3"]) == "hover:group-empty:p-3" + assert merge(["group-read-only:p-2", "group-read-only:p-3"]) == "group-read-only:p-3" + end + end + + describe "arbitrary variants" do + test "basic arbitrary variants" do + assert merge("[&>*]:underline [&>*]:line-through") == "[&>*]:line-through" + assert merge(["[&>*]:underline", "[&>*]:line-through", "[&_div]:line-through"]) == "[&>*]:line-through [&_div]:line-through" + assert merge("supports-[display:grid]:flex supports-[display:grid]:grid") == "supports-[display:grid]:grid" + end + + test "arbitrary variants with modifiers" do + assert merge("dark:lg:hover:[&>*]:underline dark:lg:hover:[&>*]:line-through") == "dark:lg:hover:[&>*]:line-through" + assert merge("dark:lg:hover:[&>*]:underline dark:hover:lg:[&>*]:line-through") == "dark:hover:lg:[&>*]:line-through" + assert merge("hover:[&>*]:underline [&>*]:hover:line-through") == "hover:[&>*]:underline [&>*]:hover:line-through" + + assert merge(["hover:dark:[&>*]:underline", "dark:hover:[&>*]:underline", "dark:[&>*]:hover:line-through"]) == + "dark:hover:[&>*]:underline dark:[&>*]:hover:line-through" + end + + test "arbitrary variants with complex syntax in them" do + assert merge(["[@media_screen{@media(hover:hover)}]:underline", "[@media_screen{@media(hover:hover)}]:line-through"]) == + "[@media_screen{@media(hover:hover)}]:line-through" + + assert merge("hover:[@media_screen{@media(hover:hover)}]:underline hover:[@media_screen{@media(hover:hover)}]:line-through") == + "hover:[@media_screen{@media(hover:hover)}]:line-through" + end + + test "arbitrary variants with attribute selectors" do + assert merge("[&[data-open]]:underline [&[data-open]]:line-through") == "[&[data-open]]:line-through" + end + + test "arbitrary variants with multiple attribute selectors" do + assert merge(["[&[data-foo][data-bar]:not([data-baz])]:underline", "[&[data-foo][data-bar]:not([data-baz])]:line-through"]) == + "[&[data-foo][data-bar]:not([data-baz])]:line-through" + end + + test "multiple arbitrary variants" do + assert merge("[&>*]:[&_div]:underline [&>*]:[&_div]:line-through") == "[&>*]:[&_div]:line-through" + assert merge(["[&>*]:[&_div]:underline", "[&_div]:[&>*]:line-through"]) == "[&>*]:[&_div]:underline [&_div]:[&>*]:line-through" + + assert merge(["hover:dark:[&>*]:focus:disabled:[&_div]:underline", "dark:hover:[&>*]:disabled:focus:[&_div]:line-through"]) == + "dark:hover:[&>*]:disabled:focus:[&_div]:line-through" + + assert merge(["hover:dark:[&>*]:focus:[&_div]:disabled:underline", "dark:hover:[&>*]:disabled:focus:[&_div]:line-through"]) == + "hover:dark:[&>*]:focus:[&_div]:disabled:underline dark:hover:[&>*]:disabled:focus:[&_div]:line-through" + end + + test "arbitrary variants with arbitrary properties" do + assert merge("[&>*]:[color:red] [&>*]:[color:blue]") == "[&>*]:[color:blue]" + + assert merge([ + "[&[data-foo][data-bar]:not([data-baz])]:nod:noa:[color:red]", + "[&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]" + ]) == "[&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]" + end + end + + describe "content utilities" do + test "merges content utilities correctly" do + assert merge(["content-['hello']", "content-[attr(data-content)]"]) == "content-[attr(data-content)]" + end + end + + describe "important modifier" do + test "merges tailwind classes with important modifier correctly" do + assert merge(["!font-medium", "!font-bold"]) == "!font-bold" + assert merge(["!font-medium", "!font-bold", "font-thin"]) == "!font-bold font-thin" + assert merge(["!right-2", "!-inset-x-px"]) == "!-inset-x-px" + assert merge(["focus:!inline", "focus:!block"]) == "focus:!block" + end + end + + describe "modifiers" do + test "conflicts across prefix modifiers" do + assert merge("hover:block hover:inline") == "hover:inline" + assert merge(["hover:block", "hover:focus:inline"]) == "hover:block hover:focus:inline" + assert merge(["hover:block", "hover:focus:inline", "focus:hover:inline"]) == "hover:block focus:hover:inline" + assert merge("focus-within:inline focus-within:block") == "focus-within:block" + end + + test "conflicts across postfix modifiers" do + assert merge("text-lg/7 text-lg/8") == "text-lg/8" + assert merge(["text-lg/none", "leading-9"]) == "text-lg/none leading-9" + assert merge(["leading-9", "text-lg/none"]) == "text-lg/none" + assert merge("w-full w-1/2") == "w-1/2" + end + end + + describe "negative values" do + test "handles negative value conflicts correctly" do + assert merge(["-m-2", "-m-5"]) == "-m-5" + assert merge(["-top-12", "-top-2000"]) == "-top-2000" + end + + test "handles conflicts between positive and negative values correctly" do + assert merge(["-m-2", "m-auto"]) == "m-auto" + assert merge(["top-12", "-top-69"]) == "-top-69" + end + + test "handles conflicts across groups with negative values correctly" do + assert merge(["-right-1", "inset-x-1"]) == "inset-x-1" + assert merge(["hover:focus:-right-1", "focus:hover:inset-x-1"]) == "focus:hover:inset-x-1" + end + end + + describe "non-tailwind" do + test "does not alter non-tailwind classes" do + assert merge(["non-tailwind-class", "inline", "block"]) == "non-tailwind-class block" + assert merge(["inline", "block", "inline-1"]) == "block inline-1" + assert merge(["inline", "block", "i-inline"]) == "block i-inline" + assert merge(["focus:inline", "focus:block", "focus:inline-1"]) == "focus:block focus:inline-1" + end + end + + describe "join/1" do + test "strings" do + assert join("") == "" + assert join("foo") == "foo" + assert join(false && "foo") == "" + end + + test "strings (variadic)" do + assert join([""]) == "" + assert join(["foo", "bar"]) == "foo bar" + assert join([false && "foo", "bar", "baz", ""]) == "bar baz" + end + + test "arrays" do + assert join([]) == "" + assert join(["foo"]) == "foo" + assert join(["foo", "bar"]) == "foo bar" + end + + test "arrays (nested)" do + assert join([[[]]]) == "" + assert join([[["foo"]]]) == "foo" + assert join([false, [["foo"]]]) == "foo" + assert join(["foo", ["bar", ["", [["baz"]]]]]) == "foo bar baz" + end + + test "arrays (variadic)" do + assert join([[], []]) == "" + assert join([["foo"], ["bar"]]) == "foo bar" + assert join([["foo"], nil, ["baz", ""], false, "", []]) == "foo baz" + end + end +end diff --git a/test/utils/validator_test.exs b/test/utils/validator_test.exs new file mode 100644 index 0000000..02e801b --- /dev/null +++ b/test/utils/validator_test.exs @@ -0,0 +1,175 @@ +defmodule SaladUI.Merge.ValidatorTest do + use ExUnit.Case + + import SaladUI.Merge.Validator + + test "length?" do + assert length?("1") + assert length?("1023713") + assert length?("1.5") + assert length?("1231.503761") + assert length?("px") + assert length?("full") + assert length?("screen") + assert length?("1/2") + assert length?("123/345") + + refute length?("[3.7%]") + refute length?("[481px]") + refute length?("[19.1rem]") + refute length?("[50vw]") + refute length?("[56vh]") + refute length?("[length:var(--arbitrary)]") + refute length?("1d5") + refute length?("[1]") + refute length?("[12px") + refute length?("12px]") + refute length?("one") + end + + test "arbitrary_length?" do + assert arbitrary_length?("[3.7%]") + assert arbitrary_length?("[481px]") + assert arbitrary_length?("[19.1rem]") + assert arbitrary_length?("[50vw]") + assert arbitrary_length?("[56vh]") + assert arbitrary_length?("[length:var(--arbitrary)]") + + refute arbitrary_length?("1") + refute arbitrary_length?("3px") + refute arbitrary_length?("1d5") + refute arbitrary_length?("[1]") + refute arbitrary_length?("[12px") + refute arbitrary_length?("12px]") + refute arbitrary_length?("one") + end + + test "integer?" do + assert integer?("1") + assert integer?("123") + assert integer?("8312") + + refute integer?("[8312]") + refute integer?("[2]") + refute integer?("[8312px]") + refute integer?("[8312%]") + refute integer?("[8312rem]") + refute integer?("8312.2") + refute integer?("1.2") + refute integer?("one") + refute integer?("1/2") + refute integer?("1%") + refute integer?("1px") + end + + test "arbitrary_value?" do + assert arbitrary_value?("[1]") + assert arbitrary_value?("[bla]") + assert arbitrary_value?("[not-an-arbitrary-value?]") + assert arbitrary_value?("[auto,auto,minmax(0,1fr),calc(100vw-50%)]") + + refute arbitrary_value?("[]") + refute arbitrary_value?("[1") + refute arbitrary_value?("1]") + refute arbitrary_value?("1") + refute arbitrary_value?("one") + refute arbitrary_value?("o[n]e") + end + + test "any?" do + assert any?() + assert any?("") + assert any?("something") + end + + test "tshirt_size?" do + assert tshirt_size?("xs") + assert tshirt_size?("sm") + assert tshirt_size?("md") + assert tshirt_size?("lg") + assert tshirt_size?("xl") + assert tshirt_size?("2xl") + assert tshirt_size?("2.5xl") + assert tshirt_size?("10xl") + + refute tshirt_size?("") + refute tshirt_size?("hello") + refute tshirt_size?("1") + refute tshirt_size?("xl3") + refute tshirt_size?("2xl3") + refute tshirt_size?("-xl") + refute tshirt_size?("[sm]") + end + + test "arbitrary_size?" do + assert arbitrary_size?("[size:2px]") + assert arbitrary_size?("[size:bla]") + assert arbitrary_size?("[length:bla]") + assert arbitrary_size?("[percentage:bla]") + + refute arbitrary_size?("[2px]") + refute arbitrary_size?("[bla]") + refute arbitrary_size?("size:2px") + end + + test "arbitrary_position?" do + assert arbitrary_position?("[position:2px]") + assert arbitrary_position?("[position:bla]") + + refute arbitrary_position?("[2px]") + refute arbitrary_position?("[bla]") + refute arbitrary_position?("position:2px") + end + + test "arbitrary_image?" do + assert arbitrary_image?("[url:var(--my-url)]") + assert arbitrary_image?("[url(something)]") + assert arbitrary_image?("[url:bla]") + assert arbitrary_image?("[image:bla]") + assert arbitrary_image?("[linear-gradient(something)]") + assert arbitrary_image?("[repeating-conic-gradient(something)]") + + refute arbitrary_image?("[var(--my-url)]") + refute arbitrary_image?("[bla]") + refute arbitrary_image?("url:2px") + refute arbitrary_image?("url(2px)") + end + + test "arbitrary_number?" do + assert arbitrary_number?("[number:black]") + assert arbitrary_number?("[number:bla]") + assert arbitrary_number?("[number:230]") + assert arbitrary_number?("[450]") + + refute arbitrary_number?("[2px]") + refute arbitrary_number?("[bla]") + refute arbitrary_number?("[black]") + refute arbitrary_number?("black") + refute arbitrary_number?("450") + end + + test "arbitrary_shadow?" do + assert arbitrary_shadow?("[0_35px_60px_-15px_rgba(0,0,0,0.3)]") + assert arbitrary_shadow?("[inset_0_1px_0,inset_0_-1px_0]") + assert arbitrary_shadow?("[0_0_#00f]") + assert arbitrary_shadow?("[.5rem_0_rgba(5,5,5,5)]") + assert arbitrary_shadow?("[-.5rem_0_#123456]") + assert arbitrary_shadow?("[0.5rem_-0_#123456]") + assert arbitrary_shadow?("[0.5rem_-0.005vh_#123456]") + assert arbitrary_shadow?("[0.5rem_-0.005vh]") + + refute arbitrary_shadow?("[rgba(5,5,5,5)]") + refute arbitrary_shadow?("[#00f]") + refute arbitrary_shadow?("[something-else]") + end + + test "percent?" do + assert percent?("1%") + assert percent?("100.001%") + assert percent?(".01%") + assert percent?("0%") + + refute percent?("0") + refute percent?("one%") + end +end