├── .editorconfig ├── .envrc ├── .github ├── FUNDING.yml └── workflows │ └── testing.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Rakefile ├── bench.rb ├── bin ├── console ├── rake └── rubocop ├── class_variants.gemspec ├── lib ├── class_variants.rb ├── class_variants │ ├── action_view │ │ └── helpers.rb │ ├── configuration.rb │ ├── helper.rb │ ├── instance.rb │ ├── railtie.rb │ └── version.rb └── generators │ └── class_variants │ └── install │ ├── USAGE │ ├── install_generator.rb │ └── templates │ └── class_variants.rb.tt ├── logo-on-white.png ├── readme.md ├── sample.jpg ├── scripts └── build_and_push.sh └── test ├── block_test.rb ├── configuration_test.rb ├── hash_test.rb ├── helper_test.rb ├── merge_test.rb ├── process_classes_with_test.rb ├── slot_test.rb └── test_helper.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | PATH_add bin 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [adrianthedev] 4 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | on: [push, pull_request] 3 | jobs: 4 | rubocop: 5 | name: Rubocop 6 | runs-on: ubuntu-latest 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | ruby: 11 | - "3.0" 12 | - "3.1" 13 | - "3.2" 14 | - "3.3" 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Setup Ruby ${{ matrix.ruby }} 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: ${{ matrix.ruby }} 21 | bundler-cache: true 22 | - name: Run Rubocop 23 | run: bin/rubocop 24 | tests: 25 | name: Tests 26 | runs-on: ubuntu-latest 27 | strategy: 28 | fail-fast: false 29 | matrix: 30 | ruby: 31 | - "3.0" 32 | - "3.1" 33 | - "3.2" 34 | - "3.3" 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Setup Ruby ${{ matrix.ruby }} 38 | uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: ${{ matrix.ruby }} 41 | bundler-cache: true 42 | - name: Run tests 43 | run: bin/rake test 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_mode: 2 | merge: 3 | - Exclude 4 | 5 | require: 6 | - standard 7 | - standard-performance 8 | - rubocop-performance 9 | - rubocop-minitest 10 | - rubocop-rake 11 | 12 | inherit_gem: 13 | standard: config/base.yml 14 | standard-performance: config/base.yml 15 | 16 | AllCops: 17 | DisplayCopNames: true 18 | Exclude: 19 | - "**/bin/**/*" 20 | NewCops: enable 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | - Fix issue with inherited hook in helper ([#33](https://github.com/avo-hq/class_variants/pull/33)) 3 | 4 | ## 1.1.0 (2025-01-20) 5 | - Add support for merging ([#23](https://github.com/avo-hq/class_variants/pull/23)) 6 | - Add support for subclass inheritance in helper ([#24](https://github.com/avo-hq/class_variants/pull/24)) 7 | - Fix issue in view helper and readme example ([#30](https://github.com/avo-hq/class_variants/pull/30)) 8 | 9 | ## 1.0.0 (2024-11-13) 10 | - Add support for slots ([#15](https://github.com/avo-hq/class_variants/pull/15)) 11 | - Allow passing additional classes when render ([#17](https://github.com/avo-hq/class_variants/pull/17)) 12 | - Add helper module for defining variants ([#18](https://github.com/avo-hq/class_variants/pull/18)) 13 | - Add support for post process classes ([#16](https://github.com/avo-hq/class_variants/pull/16)) 14 | 15 | ## 0.0.8 (2024-10-24) 16 | - Deprecate usage of positional arguments ([#12](https://github.com/avo-hq/class_variants/pull/12)) 17 | - Deprecate compoundVariants in favor of compound_variants ([#20](https://github.com/avo-hq/class_variants/pull/20)) 18 | 19 | ## 0.0.7 (2023-12-07) 20 | - Add support for compound variants ([#8](https://github.com/avo-hq/class_variants/pull/8)) 21 | - Add basic tests ([#7](https://github.com/avo-hq/class_variants/pull/7)) 22 | 23 | ## 0.0.6 (2022-12-21) 24 | - Add support for boolean variants ([#3](https://github.com/avo-hq/class_variants/pull/3)) 25 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "benchmark-ips" 6 | gem "irb" 7 | gem "minitest" 8 | gem "rake" 9 | gem "rubocop", require: false 10 | gem "rubocop-minitest", require: false 11 | gem "rubocop-rake", require: false 12 | gem "standard", ">= 1.35.1", require: false 13 | gem "standard-performance", require: false 14 | 15 | install_if -> { !RUBY_VERSION.start_with?("3.0") } do 16 | gem "tailwind_merge" 17 | end 18 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | class_variants (1.1.0) 5 | 6 | GEM 7 | remote: https://rubygems.org/ 8 | specs: 9 | ast (2.4.2) 10 | benchmark-ips (2.14.0) 11 | io-console (0.7.2) 12 | irb (1.14.1) 13 | rdoc (>= 4.0.0) 14 | reline (>= 0.4.2) 15 | json (2.7.2) 16 | language_server-protocol (3.17.0.3) 17 | lint_roller (1.1.0) 18 | lru_redux (1.1.0) 19 | minitest (5.25.1) 20 | parallel (1.26.3) 21 | parser (3.3.5.0) 22 | ast (~> 2.4.1) 23 | racc 24 | psych (5.1.2) 25 | stringio 26 | racc (1.8.1) 27 | rainbow (3.1.1) 28 | rake (13.2.1) 29 | rdoc (6.7.0) 30 | psych (>= 4.0.0) 31 | regexp_parser (2.9.2) 32 | reline (0.5.10) 33 | io-console (~> 0.5) 34 | rubocop (1.66.1) 35 | json (~> 2.3) 36 | language_server-protocol (>= 3.17.0) 37 | parallel (~> 1.10) 38 | parser (>= 3.3.0.2) 39 | rainbow (>= 2.2.2, < 4.0) 40 | regexp_parser (>= 2.4, < 3.0) 41 | rubocop-ast (>= 1.32.2, < 2.0) 42 | ruby-progressbar (~> 1.7) 43 | unicode-display_width (>= 2.4.0, < 3.0) 44 | rubocop-ast (1.32.3) 45 | parser (>= 3.3.1.0) 46 | rubocop-minitest (0.36.0) 47 | rubocop (>= 1.61, < 2.0) 48 | rubocop-ast (>= 1.31.1, < 2.0) 49 | rubocop-performance (1.22.1) 50 | rubocop (>= 1.48.1, < 2.0) 51 | rubocop-ast (>= 1.31.1, < 2.0) 52 | rubocop-rake (0.6.0) 53 | rubocop (~> 1.0) 54 | ruby-progressbar (1.13.0) 55 | standard (1.41.0) 56 | language_server-protocol (~> 3.17.0.2) 57 | lint_roller (~> 1.0) 58 | rubocop (~> 1.66.0) 59 | standard-custom (~> 1.0.0) 60 | standard-performance (~> 1.5) 61 | standard-custom (1.0.2) 62 | lint_roller (~> 1.0) 63 | rubocop (~> 1.50) 64 | standard-performance (1.5.0) 65 | lint_roller (~> 1.1) 66 | rubocop-performance (~> 1.22.0) 67 | stringio (3.1.1) 68 | tailwind_merge (0.13.1) 69 | lru_redux (~> 1.1) 70 | unicode-display_width (2.6.0) 71 | 72 | PLATFORMS 73 | arm64-darwin-24 74 | ruby 75 | 76 | DEPENDENCIES 77 | benchmark-ips 78 | class_variants! 79 | irb 80 | minitest 81 | rake 82 | rubocop 83 | rubocop-minitest 84 | rubocop-rake 85 | standard (>= 1.35.1) 86 | standard-performance 87 | tailwind_merge 88 | 89 | BUNDLED WITH 90 | 2.5.20 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Adrian Marin 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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | require "rubocop/rake_task" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[rubocop test] 13 | -------------------------------------------------------------------------------- /bench.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "benchmark/ips" 5 | require "class_variants" 6 | 7 | RubyVM::YJIT.enable 8 | 9 | button_classes = ClassVariants.build( 10 | base: "rounded border-2 focus:ring-blue-500", 11 | variants: { 12 | size: { 13 | sm: "text-xs px-1.5 py-1", 14 | base: "text-sm px-2 py-1.5", 15 | lg: "text-base px-3 py-2" 16 | }, 17 | color: { 18 | white: "text-white bg-transparent border-white", 19 | blue: "text-white bg-blue-500 border-blue-700 hover:bg-blue-600", 20 | red: "text-white bg-red-500 border-red-700 hover:bg-red-600" 21 | }, 22 | block: "justify-center w-full", 23 | "!block": "justify-between" 24 | }, 25 | defaults: { 26 | size: :base, 27 | color: :white, 28 | block: false 29 | } 30 | ) 31 | 32 | Benchmark.ips do |x| 33 | x.warmup = 5 34 | x.time = 20 35 | 36 | x.report("rendering only defaults") do 37 | button_classes.render 38 | end 39 | 40 | x.report("rendering with overrides") do 41 | button_classes.render(size: :sm, color: :red, block: true) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "class_variants" 5 | require "irb" 6 | 7 | IRB.start 8 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rubocop", "rubocop") 28 | -------------------------------------------------------------------------------- /class_variants.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/class_variants/version" 2 | 3 | Gem::Specification.new do |s| 4 | # information 5 | s.name = "class_variants" 6 | s.version = ClassVariants::VERSION 7 | s.summary = "Easily configure styles and apply them as classes." 8 | s.description = "Easily configure styles and apply them as classes." 9 | s.authors = ["Adrian Marin"] 10 | s.email = "adrian@adrianthedev.com" 11 | s.homepage = "https://github.com/avo-hq/class_variants" 12 | s.license = "MIT" 13 | 14 | # metadata 15 | s.metadata["homepage_uri"] = s.homepage 16 | s.metadata["source_code_uri"] = s.homepage 17 | s.metadata["bug_tracker_uri"] = "#{s.homepage}/issues" 18 | s.metadata["changelog_uri"] = "#{s.homepage}/releases" 19 | 20 | # gem files 21 | s.files = Dir["lib/**/*", "LICENSE", "README.md"] 22 | 23 | # ruby minimal version 24 | s.required_ruby_version = Gem::Requirement.new(">= 3.0") 25 | end 26 | -------------------------------------------------------------------------------- /lib/class_variants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "class_variants/version" 4 | require "class_variants/action_view/helpers" 5 | require "class_variants/configuration" 6 | require "class_variants/instance" 7 | require "class_variants/helper" 8 | require "class_variants/railtie" if defined?(Rails) 9 | 10 | module ClassVariants 11 | class << self 12 | def configuration 13 | @configuration ||= Configuration.new 14 | end 15 | 16 | def configure(&block) 17 | yield(configuration) 18 | end 19 | 20 | def build(...) 21 | Instance.new(...) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/class_variants/action_view/helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ClassVariants 4 | module ActionView 5 | module Helpers 6 | def class_variants(...) 7 | ClassVariants::Instance.new(...) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/class_variants/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ClassVariants 4 | class Configuration 5 | def process_classes_with(&block) 6 | if block_given? 7 | @process_classes_with = block 8 | else 9 | @process_classes_with 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/class_variants/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ClassVariants 4 | module Helper 5 | module ClassMethods 6 | def class_variants(...) 7 | singleton_class.instance_variable_get(:@_class_variants_instance).merge(...) 8 | end 9 | end 10 | 11 | def self.included(base) 12 | base.extend(ClassMethods) 13 | base.singleton_class.instance_variable_set(:@_class_variants_instance, ClassVariants::Instance.new) 14 | 15 | def base.inherited(subclass) 16 | super if defined?(super) 17 | 18 | subclass.singleton_class.instance_variable_set( 19 | :@_class_variants_instance, singleton_class.instance_variable_get(:@_class_variants_instance).dup 20 | ) 21 | end 22 | end 23 | 24 | def class_variants(...) 25 | self.class.singleton_class.instance_variable_get(:@_class_variants_instance).render(...) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/class_variants/instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ClassVariants 4 | class Instance 5 | def initialize(...) 6 | @bases = [] 7 | @variants = [] 8 | @defaults = {} 9 | @slots = nil 10 | 11 | merge(...) 12 | end 13 | 14 | def dup 15 | self.class.new.tap do |copy| 16 | copy.instance_variable_set(:@bases, @bases.dup) 17 | copy.instance_variable_set(:@variants, @variants.dup) 18 | copy.instance_variable_set(:@defaults, @defaults.dup) 19 | end 20 | end 21 | 22 | def merge(**options, &block) 23 | raise ArgumentError, "Use of hash config and code block is not supported" if !options.empty? && block 24 | 25 | (base = options.fetch(:base, nil)) && @bases << {class: base, slot: :default} 26 | @variants += [ 27 | expand_variants(options.fetch(:variants, {})), 28 | expand_compound_variants(options.fetch(:compound_variants, [])) 29 | ].inject(:+) 30 | @defaults.merge!(options.fetch(:defaults, {})) 31 | 32 | instance_eval(&block) if block 33 | 34 | self 35 | end 36 | 37 | def render(slot = :default, **overrides) 38 | classes = overrides.delete(:class) 39 | result = [] 40 | 41 | # Start with our default classes 42 | @bases.each do |base| 43 | result << base[:class] if base[:slot] == slot 44 | end 45 | 46 | # Then merge the passed in overrides on top of the defaults 47 | criteria = @defaults.merge(overrides) 48 | 49 | @variants.each do |candidate| 50 | next unless candidate[:slot] == slot 51 | 52 | match = false 53 | 54 | candidate.each_key do |key| 55 | next if key == :class || key == :slot 56 | match = criteria[key] == candidate[key] 57 | break unless match 58 | end 59 | 60 | result << candidate[:class] if match 61 | end 62 | 63 | # add the passed in classes to the result 64 | result << classes 65 | 66 | # Compact out any nil values we may have dug up 67 | result.compact! 68 | 69 | # Return the final token list 70 | with_classess_processor(result.join(" ")) 71 | end 72 | 73 | private 74 | 75 | def base(klass = nil, &block) 76 | raise ArgumentError, "Use of positional argument and code block is not supported" if klass && block 77 | 78 | if block 79 | with_slots(&block).each do |slot| 80 | @bases << slot 81 | end 82 | else 83 | @bases << {slot: :default, class: klass} 84 | end 85 | end 86 | 87 | def variant(**options, &block) 88 | raise ArgumentError, "Use of class option and code block is not supported" if options.key?(:class) && block 89 | 90 | if block 91 | with_slots(&block).each do |slot| 92 | @variants << options.merge(slot) 93 | end 94 | else 95 | @variants << options.merge(slot: :default) 96 | end 97 | end 98 | 99 | def defaults(**options) 100 | @defaults = options 101 | end 102 | 103 | def slot(name = :default, **options) 104 | raise ArgumentError, "class option is required" unless options.key?(:class) 105 | 106 | @slots << options.merge(slot: name) 107 | end 108 | 109 | def with_slots 110 | new_slots = [] 111 | @slots = new_slots 112 | yield 113 | new_slots 114 | end 115 | 116 | def expand_variants(variants) 117 | variants.flat_map do |property, values| 118 | case values 119 | when String 120 | {property.to_s.delete_prefix("!").to_sym => !property.to_s.start_with?("!"), :class => values, :slot => :default} 121 | else 122 | values.map do |key, value| 123 | {property => key, :class => value, :slot => :default} 124 | end 125 | end 126 | end 127 | end 128 | 129 | def expand_compound_variants(compound_variants) 130 | compound_variants.map do |compound_variant| 131 | compound_variant.merge(slot: :default) 132 | end 133 | end 134 | 135 | def with_classess_processor(classes) 136 | if ClassVariants.configuration.process_classes_with.respond_to?(:call) 137 | ClassVariants.configuration.process_classes_with.call(classes) 138 | else 139 | classes 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/class_variants/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/railtie" 4 | 5 | module ClassVariants 6 | class Railtie < ::Rails::Railtie 7 | initializer "class_variants.action_view" do 8 | ActiveSupport.on_load :action_view do 9 | require "class_variants/action_view/helpers" 10 | include ClassVariants::ActionView::Helpers 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/class_variants/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ClassVariants 4 | VERSION = "1.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/class_variants/install/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generates initializer file for configure ClassVariants in your application. 3 | -------------------------------------------------------------------------------- /lib/generators/class_variants/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ClassVariants 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | source_root File.expand_path("templates", __dir__) 7 | 8 | def copy_initializer 9 | template "class_variants.rb", "config/initializers/class_variants.rb" 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/class_variants/install/templates/class_variants.rb.tt: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # ClassVariants.configure do |config| 4 | # # allow to post process classes with an external utility like TailwindMerger 5 | # config.process_classes_with do |classes| 6 | # TailwindMerge::Merger.new.merge(classes) 7 | # end 8 | # end 9 | -------------------------------------------------------------------------------- /logo-on-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avo-hq/class_variants/d9b81df1638e17e123140ab883f8a3f54aa02949/logo-on-white.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Class variants 2 | 3 | We ❤️ Tailwind CSS but sometimes it's difficult to manage the state of some elements using conditionals. `class_variants` is a tiny helper that should enable you to create, configure, and apply different variants of elements as classes. 4 | 5 | Inspired by [variant-classnames](https://github.com/mattvalleycodes/variant-classnames) ✌️ 6 | 7 | ## Quicklinks 8 | 9 | * [DRY up your tailwind CSS using this awesome gem](https://www.youtube.com/watch?v=cFcwNH6x77g) 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'class_variants' 17 | ``` 18 | 19 | And then execute: 20 | 21 | ``` 22 | $ bundle 23 | ``` 24 | 25 | Or install it yourself as: 26 | 27 | ``` 28 | $ gem install class_variants 29 | ``` 30 | 31 | ## Usage 32 | 33 | We create an object from the class or helper where we define the configuration using four arguments: 34 | 35 | 1. The `base` keyword argument with default classes that should be applied to each variant. 36 | 2. The `variants` keyword argument where we declare the variants with their option and classes. 37 | 3. The `compound_variants` keyword argument where we declare the compound variants with their conditions and classes 38 | 4. The `defaults` keyword argument (optional) where we declare the default value for each variant. 39 | 40 | Below we'll implement the [button component](https://tailwindui.com/components/application-ui/elements/buttons) from Tailwind UI. 41 | 42 | ```ruby 43 | # Define the variants and defaults 44 | button_classes = ClassVariants.build( 45 | base: "inline-flex items-center rounded border border-transparent font-medium text-white hover:text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2", 46 | variants: { 47 | color: { 48 | indigo: "bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500", 49 | red: "bg-red-600 hover:bg-red-700 focus:ring-red-500", 50 | blue: "bg-blue-600 hover:bg-blue-700 focus:ring-blue-500", 51 | }, 52 | size: { 53 | sm: "px-2.5 py-1.5 text-xs", 54 | md: "px-3 py-2 text-sm", 55 | lg: "px-4 py-2 text-sm", 56 | xl: "px-4 py-2 text-base", 57 | }, 58 | compound_variants: [ 59 | { color: :red, border: true, class: "border-red-800" }, 60 | { color: :blue, border: true, class: "border-blue-800" } 61 | ] 62 | # A variant whose value is a string will be expanded into a hash that looks 63 | # like { true => "classes" } 64 | icon: "w-full justify-center", 65 | # Unless the key starts with !, in which case it will expand into 66 | # { false => "classes" } 67 | "!icon": "w-auto", 68 | }, 69 | defaults: { 70 | size: :md, 71 | color: :indigo, 72 | icon: false 73 | } 74 | ) 75 | 76 | # Call it with our desired variants 77 | button_classes.render(color: :blue, size: :sm) 78 | button_classes.render 79 | button_classes.render(color: :red, size: :xl, icon: true) 80 | ``` 81 | 82 | ## Compound Variants 83 | 84 | ```ruby 85 | button_classes = ClassVariants.build( 86 | base: "inline-flex items-center rounded", 87 | variants: { 88 | color: { 89 | red: "bg-red-600", 90 | blue: "bg-blue-600", 91 | }, 92 | border: "border" 93 | }, 94 | compound_variants: [ 95 | { color: :red, border: true, class: "border-red-800" }, 96 | { color: :blue, border: true, class: "border-blue-800" } 97 | ] 98 | ) 99 | 100 | button_classes.render(color: :red) # => "inline-flex items-center rounded bg-red-600" 101 | button_classes.render(color: :red, border: true) # => "inline-flex items-center rounded bg-red-600 border border-red-800" 102 | ``` 103 | 104 | ## Override classes with `render` 105 | 106 | We can also override the builder classes in the `render` method. 107 | 108 | ```ruby 109 | button_classes = ClassVariants.build( 110 | base: "inline-flex items-center rounded", 111 | variants: { ... }, 112 | ) 113 | 114 | button_classes.render(color: :red, class: "block") 115 | ``` 116 | 117 | Now, the `block` class will be appended to the classes bus. 118 | 119 | If you're using the [`tailwind_merge`](#tailwind_merge) plugin it will override the `inline-flex` class. 120 | 121 | ## Block configurations 122 | 123 | You might have scenarios where you have more advanced conditionals and you'd like to configure the classes using the block notation. 124 | 125 | ```ruby 126 | alert_classes = ClassVariants.build do 127 | # base 128 | base "..." 129 | 130 | # variant 131 | variant color: :red, class: "..." 132 | 133 | # compound variant 134 | variant type: :button, color: :red, class: "..." 135 | 136 | # defaults 137 | defaults color: :red, type: :button 138 | end 139 | 140 | # usage 141 | alert_classes.render(color: :red, type: :button) 142 | ``` 143 | 144 | ## Slots 145 | 146 | You might have components which have multiple slots or places where you'd like to use conditional classes. 147 | `class_variants` supports that through slots. 148 | 149 | ```ruby 150 | # Example 151 | 152 | alert_classes = ClassVariants.build do 153 | # base with slots 154 | base do 155 | slot :head, class: "..." 156 | slot :body, class: "..." 157 | end 158 | 159 | # variant with slots 160 | variant color: :red do 161 | slot :head, class: "..." 162 | slot :body, class: "..." 163 | end 164 | 165 | # compound variant with slots 166 | variant type: :button, color: :red do 167 | slot :head, class: "..." 168 | slot :body, class: "..." 169 | end 170 | 171 | # set defaults 172 | defaults color: :red, type: :button 173 | end 174 | ``` 175 | 176 | ```erb 177 |