├── test ├── test_helper.exs └── cva_test.exs ├── .vscode ├── settings.json └── tasks.json ├── .github └── assets │ └── meta.png ├── .gitpod.yml ├── .formatter.exs ├── gitpod └── install_extensions.sh ├── .gitignore ├── LICENSE.md ├── mix.exs ├── CHANGELOG.md ├── .gitpod.Dockerfile ├── README.md ├── mix.lock └── lib ├── cva.ex └── cva └── component.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "task.allowAutomaticTasks": "on" 3 | } 4 | -------------------------------------------------------------------------------- /.github/assets/meta.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benvp/ex_cva/HEAD/.github/assets/meta.png -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | 4 | tasks: 5 | - name: "IEx Shell" 6 | init: | 7 | mix deps.get 8 | mix compile 9 | command: iex -S mix 10 | 11 | vscode: 12 | extensions: 13 | - bradlc.vscode-tailwindcss 14 | - benvp.vscode-hex-pm-intellisense 15 | - victorbjorklund.phoenix 16 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | locals_without_parens = [ 2 | variant: 3, 3 | variant: 2, 4 | compound_variant: 2 5 | ] 6 | 7 | # Used by "mix format" 8 | [ 9 | locals_without_parens: locals_without_parens, 10 | export: [locals_without_parens: locals_without_parens], 11 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 12 | ] 13 | -------------------------------------------------------------------------------- /gitpod/install_extensions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Install ElixirLS extension if we run inside gitpod and run desktop vscode. 4 | # This is a workaround until https://github.com/gitpod-io/gitpod/issues/12791 5 | # is fixed. 6 | 7 | ELIXIR_LS_VERSION=0.12.0 8 | 9 | if test $USER = "gitpod" 10 | then 11 | code --install-extension $HOME/extensions/elixir-ls-$ELIXIR_LS_VERSION.vsix 12 | else 13 | echo "Nothing to do" 14 | fi 15 | 16 | exit 0 17 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Gitpod: Install ElixirLS extension", 6 | "type": "shell", 7 | "command": "./gitpod/install_extensions.sh", 8 | "group": "none", 9 | "presentation": { 10 | "reveal": "silent", 11 | "panel": "new", 12 | "close": true 13 | }, 14 | "runOptions": { 15 | "runOn": "folderOpen" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | cva-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /test/cva_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CVATest do 2 | use ExUnit.Case 3 | 4 | import CVA 5 | 6 | describe "cx" do 7 | test "correctly merges classes 1" do 8 | class = cx([nil, ""]) 9 | assert class == "" 10 | end 11 | 12 | test "correctly merges classes 2" do 13 | class = cx([["foo", nil, "bar", "baz"]]) 14 | assert class == "foo bar baz" 15 | end 16 | 17 | test "correctly merges classes 3" do 18 | class = cx(["foo", nil, "bar", "baz"]) 19 | assert class == "foo bar baz" 20 | end 21 | 22 | test "correctly merges classes 4" do 23 | class = 24 | cx([ 25 | "foo", 26 | [ 27 | nil, 28 | ["bar"], 29 | [ 30 | [ 31 | "baz", 32 | "qux", 33 | "quux", 34 | "quuz", 35 | [[[[[[[[["corge", "grault"]]]]], "garply"]]]] 36 | ] 37 | ] 38 | ] 39 | ]) 40 | 41 | assert class == "foo bar baz qux quux quuz corge grault garply" 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Benjamin von Polheim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule CVA.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.2.2" 5 | 6 | def project do 7 | [ 8 | app: :cva, 9 | version: @version, 10 | elixir: "~> 1.13", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | package: package(), 14 | docs: docs(), 15 | name: "CVA", 16 | homepage_url: "https://github.com/benvp/ex_cva", 17 | description: """ 18 | Class Variance Authority. 19 | Easily construct classes with variant definitions. 20 | """ 21 | ] 22 | end 23 | 24 | # Run "mix help compile.app" to learn about applications. 25 | def application do 26 | [ 27 | extra_applications: [:logger] 28 | ] 29 | end 30 | 31 | # Run "mix help deps" to learn about dependencies. 32 | defp deps do 33 | [ 34 | {:phoenix_live_view, ">= 0.18.0"}, 35 | {:ex_doc, "~> 0.35", only: :dev, runtime: false} 36 | ] 37 | end 38 | 39 | defp docs do 40 | [ 41 | main: "CVA", 42 | source_ref: "v#{@version}", 43 | source_url: "https://github.com/benvp/ex_cva" 44 | ] 45 | end 46 | 47 | defp package do 48 | [ 49 | maintainers: ["Benjamin von Polheim"], 50 | licenses: ["MIT"], 51 | links: %{ 52 | Changelog: "https://hexdocs.pm/cva/changelog.html", 53 | GitHub: "https://github.com/benvp/ex_cva" 54 | }, 55 | files: 56 | ~w(lib) ++ 57 | ~w(CHANGELOG.md LICENSE.md mix.exs README.md .formatter.exs) 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [v0.2.2] (2024-12-17) 6 | 7 | ## Changed 8 | 9 | * Update required phoenix_live_view version to also support Phoenix LiveView 1.0. 10 | 11 | ## [v0.2.0] (2022-04-06) 12 | 13 | ## Improvements 14 | 15 | * Add `.formatter.exs` to files to allow `import_deps: [:cva]`. 16 | 17 | ## [v0.2.0] (2022-12-06) 18 | 19 | ### Improvements 20 | 21 | * `variant/2` now properly supports boolean values. You can now do something like the following: 22 | 23 | ```elixir 24 | variant :disabled, 25 | [true: "disabled-class", false: "enabled-class"], 26 | default: false 27 | 28 | # or 29 | 30 | variant :disabled, 31 | [true: "disabled-class"], 32 | default: nil 33 | 34 | def button(assigns) do 35 | ~H""" 36 | 39 | """ 40 | end 41 | 42 | # ... where you use that component 43 | 44 | <.button disabled>Click me 45 | 46 | # -> 47 | ``` 48 | 49 | ### Changes 50 | 51 | * `variant/2` does not automatically infer the `required` option anymore. If you want to make a variant mandatory, you have to provide the `required: true` option. 52 | 53 | * `variant/2` now allows `nil` as a valid value. 54 | 55 | ### Bugfixes 56 | 57 | * Fixes an issue where defining 3 variants would cause an `attr` already defined error. 58 | 59 | 60 | ## [v0.1.2] (2022-12-03) 61 | 62 | ### Bugfixes 63 | 64 | * Add `variant: 2` to formatter. 65 | * Fixes an issue where a `class` assign would accidentally be merged into `@cva_class` without 66 | explicitly declaring it. 67 | 68 | ## [v0.1.1] (2022-11-26) 69 | 70 | ### Bugfixes 71 | 72 | * Fix formatter exports 73 | 74 | ## [v0.1.0] (2022-11-26) 75 | 76 | 🚀 Initial release 77 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-full 2 | 3 | # Erlang dependencies 4 | RUN sudo install-packages build-essential autoconf m4 libncurses5-dev libwxgtk3.0-gtk3-dev libwxgtk-webview3.0-gtk3-dev \ 5 | libgl1-mesa-dev libglu1-mesa-dev libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils libncurses-dev openjdk-11-jdk 6 | 7 | # Phoenix Dependencies 8 | RUN sudo install-packages inotify-tools 9 | 10 | RUN brew install asdf \ 11 | && asdf plugin add erlang \ 12 | && asdf plugin add elixir \ 13 | && asdf plugin add nodejs \ 14 | && asdf install erlang 25.1.2 \ 15 | && asdf global erlang 25.1.2 \ 16 | && asdf install elixir 1.14.2-otp-25 \ 17 | && asdf global elixir 1.14.2-otp-25 \ 18 | && asdf install nodejs 16.17.1 \ 19 | && asdf global nodejs 16.17.1 \ 20 | && bash -c ". $(brew --prefix asdf)/libexec/asdf.sh \ 21 | && mix local.hex --force \ 22 | && mix local.rebar --force" \ 23 | && echo -e "\n. $(brew --prefix asdf)/libexec/asdf.sh" >> ~/.bashrc 24 | 25 | # Build vscode-elixir-ls extension 26 | # 27 | # We build this manually because ElixirLS won't show autocompletions 28 | # when using the `use` macro if ElixirLS has been compiled with a different 29 | # Erlang / Elixir combination. See https://github.com/elixir-lsp/elixir-ls/issues/193 30 | # 31 | # Aditionally, OpenVSX only contains a version published under the deprecated namespace. 32 | # This causes issues when developing locally because it would always install the wrong extension. 33 | 34 | RUN bash -c ". $(brew --prefix asdf)/libexec/asdf.sh \ 35 | && git clone --recursive --branch v0.12.0 https://github.com/elixir-lsp/vscode-elixir-ls.git /tmp/vscode-elixir-ls \ 36 | && cd /tmp/vscode-elixir-ls \ 37 | && npm install \ 38 | && cd elixir-ls \ 39 | && mix deps.get \ 40 | && cd .. \ 41 | && npx vsce package \ 42 | && mkdir -p $HOME/extensions \ 43 | && cp /tmp/vscode-elixir-ls/elixir-ls-0.12.0.vsix $HOME/extensions \ 44 | && cd $HOME \ 45 | && rm -rf /tmp/vscode-elixir-ls" 46 | 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |  2 | 3 |
Class Variance Authority for Elixir - easily construct classes with variant definitions.
6 | 7 | 18 | 19 | 20 | ## Introduction 21 | 22 | Building out core HEEx function components like `button`, `heading`, `link` in general requires 23 | some way to distinguish between different appearances of the component. This is generally achieved 24 | by concatenating class strings or by extracting them into separate functions. In addition, maintaining proper definitions of supported `attr` `:value` options is required. 25 | 26 | `ex_cva` aims to make this process convenient by providing a clean and maintainable way to define component variants. 27 | 28 | ## Installation 29 | 30 | Add the `cva` dependency to your `mix.exs` file. 31 | 32 | ```elixir 33 | def deps do 34 | [ 35 | {:cva, "~> 0.2"} 36 | ] 37 | end 38 | ``` 39 | 40 | ## Usage with HEEx function components 41 | 42 | Configure a few variants with defaults and optionally add compound variants. CVA will take care of creating the proper class names. 43 | 44 | One more goodie: it even creates compile-time checks for your variants to make sure all required 45 | variants are applied and contain correct values (thanks to `Phoenix.Component.attr/3`). 46 | 47 | ```elixir 48 | defmodule MyWeb.Components do 49 | use Phoenix.Component 50 | use CVA.Component 51 | 52 | variant :intent, [ 53 | primary: "bg-cyan-600", 54 | secondary: "bg-zinc-700", 55 | destructive: "bg-red-500" 56 | ], 57 | default: :secondary 58 | 59 | variant :size, [ 60 | xs: "rounded px-2.5 py-1.5 text-xs", 61 | sm: "rounded-md px-3 py-2 text-sm", 62 | md: "rounded-md px-4 py-2 text-sm", 63 | lg: "rounded-md px-4 py-2 text-base", 64 | xl: "rounded-md px-6 py-3 text-base" 65 | ], 66 | default: :md 67 | 68 | compound_variant "uppercase", intent: :primary, size: :xl 69 | 70 | attr :rest, :global 71 | slot :inner_block 72 | 73 | def button(assigns) do 74 | ~H""" 75 | 76 | """ 77 | end 78 | end 79 | 80 | defmodule MyWeb.SomeLive do 81 | import MyWeb.Components 82 | 83 | def render(assigns) do 84 | ~H""" 85 | <.button intent="primary">Click me 86 | """ 87 | end 88 | end 89 | ``` 90 | 91 | ## Raw `cva` usage 92 | 93 | Even though `ex_cva` shines when working with function components, you can still use the raw `cva` function to generate classes. 94 | 95 | ```elixir 96 | defmodule MyCVA do 97 | import CVA 98 | 99 | def button(props) do 100 | config = [ 101 | variants: [ 102 | intent: [ 103 | primary: "bg-cyan-600", 104 | secondary: "bg-zinc-700", 105 | destructive: "bg-red-500" 106 | ], 107 | size: [ 108 | xs: "rounded px-2.5 py-1.5 text-xs", 109 | sm: "rounded-md px-3 py-2 text-sm", 110 | md: "rounded-md px-4 py-2 text-sm", 111 | lg: "rounded-md px-4 py-2 text-base", 112 | xl: "rounded-md px-6 py-3 text-base" 113 | ] 114 | ] 115 | ] 116 | 117 | cva(config, props) 118 | end 119 | end 120 | 121 | button(intent: :primary, size: :md) # -> "bg-cyan-600 rounded-md px-4 py-2 text-sm" 122 | ``` 123 | 124 | ## Acknowledgements 125 | 126 | - [**cva**](https://github.com/joe-bell/cva) ([Joe Bell](https://github.com/joe-bell)) 127 | Thank you for providing the JavaScript implementation. It inspired me to port this over to Elixir. I hope you don't mind that I hijacked your logo. If you are not feeling well about that, feel free to shoot me a message. 128 | 129 | ## Contributing 130 | 131 | Contributions are very welcome. To get it up on your local machine, just check out the repo and run 132 | 133 | ```bash 134 | mix deps.get 135 | mix test 136 | ``` 137 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 4 | "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [: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", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"}, 5 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 8 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 10 | "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"}, 11 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 12 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"}, 13 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 14 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 15 | "phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"}, 16 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [: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", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 17 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 18 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 19 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 20 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, 21 | } 22 | -------------------------------------------------------------------------------- /lib/cva.ex: -------------------------------------------------------------------------------- 1 | defmodule CVA do 2 | @moduledoc """ 3 | Construct classes with variant definitions. 4 | 5 | A variant consists of a name and a class. These variants are defined by providing a declarative 6 | set of nested keyword lists. 7 | 8 | defmodule MyCVA do 9 | import CVA 10 | 11 | def button(props) do 12 | config = [ 13 | variants: [ 14 | intent: [ 15 | primary: "bg-cyan-600", 16 | secondary: "bg-zinc-700", 17 | destructive: "bg-red-500" 18 | ], 19 | size: [ 20 | xs: "rounded px-2.5 py-1.5 text-xs", 21 | sm: "rounded-md px-3 py-2 text-sm", 22 | md: "rounded-md px-4 py-2 text-sm", 23 | lg: "rounded-md px-4 py-2 text-base", 24 | xl: "rounded-md px-6 py-3 text-base" 25 | ] 26 | ] 27 | ] 28 | 29 | cva(config, props) 30 | end 31 | end 32 | 33 | Invoking `button/1` with the desired variants will return a class name including 34 | the classes specified in the config. 35 | 36 | button(intent: :primary, size: :md) # -> "bg-cyan-600 rounded-md px-4 py-2 text-sm" 37 | 38 | See `cva/3` for more info. 39 | 40 | 41 | ## Usage with Phoenix function components. 42 | 43 | CVA integrates nicely with Phoenix function components by providing `variant/3` and 44 | `compound_variant/2` macros. 45 | 46 | Please refer to the `CVA.Component` module. 47 | """ 48 | 49 | @doc """ 50 | Merges a list of classes into a single class string removing any nil or empty strings. 51 | Class lists can be infinitely nested. 52 | """ 53 | def cx(classes) when is_binary(classes), do: classes 54 | 55 | def cx(classes) when is_list(classes) do 56 | classes 57 | |> List.flatten() 58 | |> Enum.filter(&(!!&1 && &1 != "")) 59 | |> Enum.join(" ") 60 | end 61 | 62 | @doc """ 63 | See `cva/3`. 64 | """ 65 | def cva(config) when is_list(config), do: cva("", config, []) 66 | 67 | @doc """ 68 | See `cva/3`. 69 | """ 70 | def cva(config, props), do: cva("", config, props) 71 | 72 | def cva(base, config, props) when is_list(config) do 73 | cva( 74 | base, 75 | Enum.into(config, %{variants: nil, compound_variants: nil, default_variants: nil}), 76 | Enum.into(props, %{}) 77 | ) 78 | end 79 | 80 | def cva(base, %{variants: nil}, props), do: cx([base, props[:class]]) 81 | 82 | @doc """ 83 | Construct a class string from a variant configuration. 84 | 85 | Accepts a base class string, a variant configuration, and a list of props. 86 | 87 | config = [ 88 | variants: [ 89 | intent: [ 90 | primary: "bg-cyan-600", 91 | secondary: "bg-zinc-700", 92 | destructive: "bg-red-500" 93 | ], 94 | size: [ 95 | xs: "rounded px-2.5 py-1.5 text-xs", 96 | sm: "rounded-md px-3 py-2 text-sm", 97 | md: "rounded-md px-4 py-2 text-sm", 98 | lg: "rounded-md px-4 py-2 text-base", 99 | xl: "rounded-md px-6 py-3 text-base" 100 | ] 101 | ], 102 | default_variants: [ 103 | intent: :secondary, 104 | size: :md 105 | ], 106 | compound_variants: [ 107 | [intent: :primary, size: :xl, class: "uppercase"] 108 | ] 109 | ] 110 | 111 | cva("base", config, intent: :primary, size: :md) # -> "base bg-cyan-600 rounded-md px-4 py-2 text-sm" 112 | cva("base", config, intent: :primary, size: :xl) # -> "base bg-cyan-600 rounded-md px-6 py-3 text-base uppercase" 113 | cva("base", config, intent: :primary, size: :xl) # -> "base bg-cyan-600 rounded-md px-6 py-3 text-base uppercase" 114 | 115 | cva("base", config) # -> "base bg-zinc-700 rounded-md px-4 py-2 text-sm" 116 | cva("base", config, intent: :primary) # -> "base bg-cyan-600 rounded-md px-4 py-2 text-sm" 117 | 118 | ## Config 119 | 120 | The configuration is a keyword list with the following keys: 121 | 122 | * `:variants` - A keyword list of variants. Each variant is a keyword list of 123 | variant names and classes. 124 | * `:compound_variants` - A list of compound variants. Each compound 125 | variant is a keyword list of required variants and a `:class` key to specify the class 126 | which should be applied if all variants are present. 127 | * `:default_variants` - A keyword list of default variants. For example `[intent: :primary]`. 128 | 129 | ## Props 130 | 131 | Props define the variants to be applied. Each key in the props list must be a variant name. 132 | Values can either be an atom or a string. 133 | 134 | ### Special props 135 | 136 | * `:class` - A class string or list of classes. This class is applied last. 137 | """ 138 | def cva(base, config, props) do 139 | cx([ 140 | base, 141 | variant_class_names(config, props), 142 | compound_variant_class_names(config, props), 143 | props[:class] 144 | ]) 145 | end 146 | 147 | defp variant_class_names(_config, %{variants: nil}), do: [] 148 | 149 | defp variant_class_names(config, props) do 150 | config[:variants] 151 | |> Keyword.keys() 152 | |> Enum.map(fn variant -> 153 | if Map.has_key?(props, variant) && props[variant] == nil do 154 | nil 155 | else 156 | variant_prop = props[variant] 157 | default_variant_prop = config[:default_variants][variant] 158 | 159 | variant_key = 160 | case variant_prop do 161 | p when is_binary(p) -> String.to_existing_atom(p) 162 | p -> p 163 | end || default_variant_prop 164 | 165 | # In case we receive nil, convert it to false to properly support 166 | # boolean variants. E.g. `disabled: [true: "i-am-disabled"]` 167 | variant_key = if variant_key, do: variant_key, else: false 168 | 169 | config[:variants][variant][variant_key] 170 | end 171 | end) 172 | end 173 | 174 | defp compound_variant_class_names(config, props) do 175 | compound_variants = config[:compound_variants] 176 | 177 | if compound_variants do 178 | compound_variants 179 | |> Enum.reduce([], fn compound_variant, acc -> 180 | {class, compound} = Keyword.pop(compound_variant, :class) 181 | props_with_default = Keyword.merge(config[:default_variants] || [], Map.to_list(props)) 182 | 183 | match? = 184 | Enum.all?(compound, fn {k, v} -> 185 | p = 186 | if is_binary(props_with_default[k]), 187 | do: String.to_existing_atom(props_with_default[k]), 188 | else: props_with_default[k] 189 | 190 | p == v 191 | end) 192 | 193 | if match? do 194 | [class | acc] 195 | else 196 | acc 197 | end 198 | end) 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /lib/cva/component.ex: -------------------------------------------------------------------------------- 1 | defmodule CVA.Component do 2 | @moduledoc ~S''' 3 | Integrates CVA with Phoenix function components. 4 | 5 | The `variant/3` and `compound_variant/2` macros allow easy definition of variants and compound 6 | variants for a component. This also includes compile time checks for the specified variants. 7 | 8 | When using this module, please make sure that you add include :cva to your imports in 9 | `.formatter.exs`. 10 | 11 | ```elixir 12 | [ 13 | import_deps: [:cva], 14 | ] 15 | ``` 16 | 17 | ## Usage 18 | 19 | defmodule MyWeb.Components do 20 | use CVA.Component 21 | 22 | variant :intent, [ 23 | primary: "bg-cyan-600", 24 | secondary: "bg-zinc-700", 25 | destructive: "bg-red-500" 26 | ], 27 | default: :secondary 28 | 29 | variant :size, [ 30 | xs: "rounded px-2.5 py-1.5 text-xs", 31 | sm: "rounded-md px-3 py-2 text-sm", 32 | md: "rounded-md px-4 py-2 text-sm", 33 | lg: "rounded-md px-4 py-2 text-base", 34 | xl: "rounded-md px-6 py-3 text-base" 35 | ], 36 | default: :md 37 | 38 | compound_variant "uppercase", intent: :primary, size: :xl 39 | 40 | attr :rest, :global 41 | slot :inner_block 42 | 43 | def button(assigns) do 44 | ~H""" 45 | 46 | """ 47 | end 48 | end 49 | 50 | defmodule MyWeb.SomeLive do 51 | import MyWeb.Components 52 | 53 | def render(assigns) do 54 | ~H""" 55 | <.button intent="primary">Click me 56 | """ 57 | end 58 | end 59 | ''' 60 | 61 | import CVA 62 | import Phoenix.Component 63 | 64 | @doc ~S''' 65 | Declares a variant for HEEx function components. 66 | 67 | When declaring variants, an assign `cva_class` is added to the component. This assign contains 68 | the class according to all variant definitions of the component. This class is intended to 69 | be passed into the `class` attribute of the component. 70 | 71 | A component can have multiple variants. 72 | 73 | ## Arguments 74 | 75 | * `name` - an atom defining the name of the attribute. Note that attributes cannot define the 76 | same name as any other attributes or slots or attributes declared for the same component. 77 | 78 | * `variants` - a keyword list of variants. Supported variant values are strings and a list of strings. 79 | 80 | * `opts` - a keyword list of options. Defaults to `[]`. 81 | 82 | ## Options 83 | 84 | * `default` - the default variant. 85 | 86 | All other options will be passed to `Phoenix.Component.attr/3`. 87 | 88 | ## Example 89 | 90 | defmodule MyWeb.Components do 91 | use CVA.Component 92 | 93 | variant :intent, [ 94 | primary: "bg-cyan-600", 95 | secondary: "bg-zinc-700", 96 | destructive: "bg-red-500" 97 | ], 98 | default: :secondary 99 | 100 | variant :size, [ 101 | xs: "rounded px-2.5 py-1.5 text-xs", 102 | sm: "rounded-md px-3 py-2 text-sm", 103 | md: "rounded-md px-4 py-2 text-sm", 104 | lg: "rounded-md px-4 py-2 text-base", 105 | xl: "rounded-md px-6 py-3 text-base" 106 | ], 107 | default: :md 108 | 109 | def button(assigns) do 110 | ~H""" 111 | 112 | """ 113 | end 114 | end 115 | ''' 116 | defmacro variant(name, variants, opts \\ []) 117 | when is_atom(name) and is_list(variants) and is_list(opts) do 118 | quote do 119 | unless Module.get_attribute(__MODULE__, :__cva_variant_called__) do 120 | attr(:cva_class, :string, default: nil) 121 | end 122 | 123 | cva_variant = %{ 124 | name: unquote(name), 125 | variants: unquote(variants), 126 | default: unquote(opts[:default]), 127 | line: __ENV__.line, 128 | file: __ENV__.file 129 | } 130 | 131 | Module.put_attribute(__MODULE__, :__cva_variants__, cva_variant) 132 | Module.put_attribute(__MODULE__, :__cva_variant_called__, true) 133 | 134 | attr(unquote_splicing(CVA.Component.__attr__!({name, variants}, opts))) 135 | end 136 | end 137 | 138 | @doc ~S''' 139 | Declares a compound variant for HEEx function components. 140 | 141 | A compound variant defines a set of required variants. If all variants are present, the 142 | given class will be assigned. 143 | 144 | A component can have multiple compound variants. 145 | 146 | ## Arguments 147 | 148 | * `class` - the class to add if all variants are present. 149 | 150 | * `variants` - a keyword list of required variants. 151 | 152 | ## Example 153 | 154 | defmodule MyWeb.Components do 155 | use CVA.Component 156 | 157 | variant :intent, [ 158 | primary: "bg-cyan-600", 159 | secondary: "bg-zinc-700", 160 | ], 161 | default: :secondary 162 | 163 | variant :size, [ 164 | md: "rounded-md px-4 py-2 text-sm", 165 | xl: "rounded-md px-6 py-3 text-base" 166 | ], 167 | default: :md 168 | 169 | compound_variant "uppercase", intent: :primary, size: :xl 170 | 171 | def button(assigns) do 172 | ~H""" 173 | 174 | """ 175 | end 176 | end 177 | ''' 178 | defmacro compound_variant(class, variants) do 179 | quote do 180 | @__cva_compound_variants__ %{ 181 | variants: unquote(variants), 182 | class: unquote(class) 183 | } 184 | end 185 | end 186 | 187 | defp pop_variants(env) do 188 | variants = Module.delete_attribute(env.module, :__cva_variants__) || [] 189 | Enum.reverse(variants) 190 | end 191 | 192 | defp pop_compound_variants(env) do 193 | variants = Module.delete_attribute(env.module, :__cva_compound_variants__) || [] 194 | Enum.reverse(variants) 195 | end 196 | 197 | def __attr__!({name, variants}, opts) do 198 | values = 199 | variants 200 | |> Keyword.keys() 201 | |> Enum.map(fn 202 | v when is_boolean(v) -> v 203 | v -> Atom.to_string(v) 204 | end) 205 | 206 | cva_opts = [values: values ++ [nil]] 207 | 208 | cva_opts = 209 | cond do 210 | is_boolean(opts[:default]) -> Keyword.put(cva_opts, :default, opts[:default]) 211 | opts[:default] != nil -> Keyword.put(cva_opts, :default, Atom.to_string(opts[:default])) 212 | true -> cva_opts 213 | end 214 | 215 | opts = Keyword.merge(opts, cva_opts) 216 | 217 | type = 218 | cond do 219 | Enum.all?(values, &is_boolean/1) -> :boolean 220 | Enum.all?(values, &is_binary/1) -> :string 221 | true -> :any 222 | end 223 | 224 | [name, type, opts] 225 | end 226 | 227 | def __on_definition__(env, kind, name, _args, _guards, _body) do 228 | variant_defs = pop_variants(env) 229 | compound_variant_defs = pop_compound_variants(env) 230 | 231 | Module.put_attribute(env.module, :__cva_variant_called__, false) 232 | 233 | if not String.starts_with?(to_string(name), "__") and variant_defs != [] do 234 | default_variants = 235 | for %{name: name, default: default} <- variant_defs, 236 | not is_nil(default), 237 | do: {name, default} 238 | 239 | variants = for %{name: name, variants: v} <- variant_defs, do: {name, v} 240 | 241 | compound_variants = 242 | for %{variants: variants, class: class} <- compound_variant_defs, 243 | do: Keyword.put(variants, :class, class) 244 | 245 | configs = 246 | env.module 247 | |> Module.get_attribute(:__cva__) 248 | |> Map.put(name, %{ 249 | kind: kind, 250 | config: [ 251 | variants: variants, 252 | default_variants: default_variants, 253 | compound_variants: compound_variants 254 | ] 255 | }) 256 | 257 | Module.put_attribute(env.module, :__cva__, configs) 258 | end 259 | 260 | :ok 261 | end 262 | 263 | defmacro __before_compile__(env) do 264 | configs = Module.get_attribute(env.module, :__cva__) 265 | 266 | names_and_defs = 267 | for {name, %{kind: kind, config: config}} <- configs do 268 | variant_names = for {name, _} <- config[:variants], do: name 269 | 270 | body = 271 | quote do 272 | cva_props = Map.take(assigns, unquote(variant_names)) 273 | 274 | assigns = 275 | Phoenix.Component.assign( 276 | assigns, 277 | :cva_class, 278 | cva(unquote(config), cva_props) 279 | ) 280 | 281 | super(assigns) 282 | end 283 | 284 | full_def = 285 | quote do 286 | unquote(kind)(unquote(name)(assigns)) do 287 | unquote(body) 288 | end 289 | end 290 | 291 | {{name, 1}, full_def} 292 | end 293 | 294 | {names, defs} = Enum.unzip(names_and_defs) 295 | 296 | overridable = 297 | if names != [] do 298 | quote do 299 | defoverridable unquote(names) 300 | end 301 | end 302 | 303 | {:__block__, [], [overridable | defs]} 304 | end 305 | 306 | defmacro __using__(_opts \\ []) do 307 | quote do 308 | import CVA 309 | import unquote(__MODULE__) 310 | 311 | Module.put_attribute(__MODULE__, :before_compile, unquote(__MODULE__)) 312 | Module.put_attribute(__MODULE__, :on_definition, unquote(__MODULE__)) 313 | Module.put_attribute(__MODULE__, :__cva__, %{}) 314 | Module.put_attribute(__MODULE__, :__cva_variant_called__, false) 315 | 316 | Module.register_attribute(__MODULE__, :__cva_variants__, accumulate: true) 317 | Module.register_attribute(__MODULE__, :__cva_compound_variants__, accumulate: true) 318 | end 319 | end 320 | end 321 | --------------------------------------------------------------------------------