├── .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 |
178 |
179 | Head of alert 180 |
181 |
182 | Body of alert 183 |
184 |
185 | ``` 186 | 187 | ## Merge definitions 188 | 189 | ```ruby 190 | alert_classes = ClassVariants.build(base: "bg-white") 191 | alert_classes.merge(base: "text-black") 192 | alert_classes.render # => "bg-white text-black" 193 | ``` 194 | 195 | ## Full API 196 | 197 | ```ruby 198 | # Configuration 199 | alert_classes = ClassVariants.build( 200 | base: "...", 201 | variants: { 202 | color: { 203 | red: "...", 204 | black: "..." 205 | }, 206 | type: { 207 | button: "...", 208 | link: "..." 209 | } 210 | }, 211 | compound_variants: [], 212 | defaults: { 213 | color: :red, 214 | type: :button 215 | } 216 | ) do 217 | # base without slots 218 | base "..." 219 | 220 | # base with slots 221 | base do 222 | slot :head, class: "..." 223 | slot :body, class: "..." 224 | end 225 | 226 | # variant without slots 227 | variant color: :red, class: "..." 228 | 229 | # variant with slots 230 | variant color: :red do 231 | slot :head, class: "..." 232 | slot :body, class: "..." 233 | end 234 | 235 | # compound variant without slots 236 | variant type: :button, color: :red, class: "..." 237 | 238 | # compound variant with slots 239 | variant type: :button, color: :red do 240 | slot :head, class: "..." 241 | slot :body, class: "..." 242 | end 243 | 244 | # option 1 (my favorite) 245 | defaults color: :red, type: :button 246 | 247 | # option 2 248 | defaults do 249 | color :red 250 | type :button 251 | end 252 | end 253 | 254 | # Usage 255 | 256 | # renders the defaults 257 | alert_classes.render 258 | 259 | # render default slot with custom variants 260 | alert_classes.render(color: :red) 261 | 262 | # render slot with defaults variants 263 | alert_classes.render(:body) 264 | 265 | # render slot with custom variants 266 | alert_classes.render(:body, color: :red) 267 | 268 | # if slot not exist, throw error? return empty classes? 269 | alert_classes.render(:non_existent_slot, color: :red) 270 | 271 | # render default slot with custom class (will be merged) 272 | alert_classes.render(class: "...") 273 | 274 | # render slot with custom class (will be merged) 275 | alert_classes.render(:body, class: "...") 276 | ``` 277 | 278 | ## Use with Rails 279 | 280 | ```ruby 281 | # Somewhere in your helpers 282 | def button_classes 283 | class_variants( 284 | 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", 285 | variants: { 286 | size: { 287 | sm: "px-2.5 py-1.5 text-xs", 288 | md: "px-3 py-2 text-sm", 289 | lg: "px-4 py-2 text-sm", 290 | xl: "px-4 py-2 text-base", 291 | }, 292 | color: { 293 | indigo: "bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500", 294 | red: "bg-red-600 hover:bg-red-700 focus:ring-red-500", 295 | blue: "bg-blue-600 hover:bg-blue-700 focus:ring-blue-500", 296 | }, 297 | }, 298 | defaults: { 299 | size: :md, 300 | color: :indigo, 301 | } 302 | ) 303 | end 304 | ``` 305 | 306 | ```erb 307 | 308 | <%= link_to :Avo, "https://avohq.io", class: button_classes.render(color: :blue, size: :sm) %> 309 | <%= link_to :Avo, "https://avohq.io", class: button_classes.render %> 310 | <%= link_to :Avo, "https://avohq.io", class: button_classes.render(color: :red, size: :xl) %> 311 | ``` 312 | 313 | ### Output 314 | 315 | ### ![](sample.jpg) 316 | 317 | ## Helper module 318 | 319 | If you're developing something more complex you might want to use composition more. You might want to use the helper module for that. 320 | 321 | ```ruby 322 | class MyClass 323 | include ClassVariants::Helper 324 | 325 | class_variants( 326 | base: "bg-white", 327 | variants: { 328 | color: { 329 | red: "text-red", 330 | blue: "text-blue" 331 | } 332 | } 333 | ) 334 | end 335 | 336 | MyClass.new.class_variants(color: :red, class: "shadow") # => "bg-white text-red shadow" 337 | ``` 338 | 339 | This helper supports class inheritance, so that the subclass receives a copy of the class_variants config that the parent class had at the time of inheritance. From that point on, the settings are kept separate for both. Successive calls to class_variants helper method, will cause the configuration to be merged. 340 | 341 | ```ruby 342 | class A 343 | include ClassVariants::Helper 344 | 345 | class_variants(base: "bg-red") 346 | end 347 | 348 | class B < A 349 | class_variants(base: "text-black") 350 | end 351 | 352 | A.class_variants(base: "text-white") 353 | 354 | A.new.class_variants # => "bg-red text-white" 355 | B.new.class_variants # => "bg-red text-black" 356 | ``` 357 | 358 | ## `tailwind_merge` 359 | 360 | By default, the classes are merged using `concat`, but you can use the awesome [TailwindMerge](https://github.com/gjtorikian/tailwind_merge) gem. 361 | Install the gem using `bundle add tailwind_merge` and use this configuration to enable it. If you're using Rails, you can put this in an initializer. 362 | 363 | ```ruby 364 | ClassVariants.configure do |config| 365 | merger = TailwindMerge::Merger.new 366 | config.process_classes_with do |classes| 367 | merger.merge(classes) 368 | end 369 | end 370 | ``` 371 | 372 | ## Other packages 373 | 374 | - [`active_storage-blurhash`](https://github.com/avo-hq/active_storage-blurhash) - A plug-n-play [blurhash](https://blurha.sh/) integration for images stored in ActiveStorage 375 | - [`avo`](https://github.com/avo-hq/avo) - Build Content management systems with Ruby on Rails 376 | - [`prop_initializer`](https://github.com/avo-hq/prop_initializer) - A flexible tool for defining properties on Ruby classes. 377 | - [`stimulus-confetti`](https://github.com/avo-hq/stimulus-confetti) - The easiest way to add confetti to your StimulusJS app 378 | 379 | ## Try Avo ⭐️ 380 | 381 | If you enjoyed this gem try out [Avo](https://github.com/avo-hq/avo). It helps developers build Internal Tools, Admin Panels, CMSes, CRMs, and any other type of Business Apps 10x faster on top of Ruby on Rails. 382 | 383 | [![](./logo-on-white.png)](https://github.com/avo-hq/avo) 384 | 385 | ## Articles 386 | 387 | [TIL: How to use `class_variants` with Phlex](https://henrikbjorn.medium.com/til-how-to-use-class-variants-with-phlex-8042bd4407f1) 388 | 389 | ## Contributing 390 | 391 | 1. Fork it `git clone https://github.com/avo-hq/class_variants` 392 | 2. Create your feature branch `git checkout -b my-new-feature` 393 | 3. Commit your changes `git commit -am 'Add some feature'` 394 | 4. Push to the branch `git push origin my-new-feature` 395 | 5. Create new Pull Request 396 | 397 | ## License 398 | 399 | This package is available as open source under the terms of the MIT License. 400 | 401 | ## Cutting a release 402 | 403 | ```bash 404 | # Build 405 | gem build class_variants.gemspec -o latest.gem 406 | # Publish 407 | gem push --host https://rubygems.org/ ./latest.gem 408 | # Cut a tag 409 | git tag v0.0.6 -a -m "Version 0.0.6" 410 | # Push tag to repo 411 | git push --follow-tags 412 | # Go to the repo and generate release from tag 413 | ``` 414 | -------------------------------------------------------------------------------- /sample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avo-hq/class_variants/d9b81df1638e17e123140ab883f8a3f54aa02949/sample.jpg -------------------------------------------------------------------------------- /scripts/build_and_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -f class_variants-latest.gem 6 | 7 | gem build class_variants.gemspec -o class_variants-latest.gem 8 | 9 | gem push --host https://rubygems.org/ ./class_variants-latest.gem -------------------------------------------------------------------------------- /test/block_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class BlockTest < Minitest::Test 4 | def setup 5 | @cv = ClassVariants.build do 6 | base "text-white py-1 px-3 rounded-full" 7 | 8 | variant color: :primary, class: "bg-blue-500" 9 | variant color: :secondary, class: "bg-purple-500" 10 | variant color: :success, class: "bg-green-500" 11 | 12 | variant size: :sm, class: "py-1 px-3 text-xs" 13 | variant size: :md, class: "py-1.5 px-4 text-sm" 14 | variant size: :lg, class: "py-2 px-6 text-md" 15 | 16 | variant disabled: true, class: "opacity-50 bg-gray-500" 17 | variant visible: false, class: "hidden" 18 | 19 | variant color: :success, disabled: true, class: "bg-green-100 text-green-700" 20 | 21 | defaults size: :sm 22 | end 23 | end 24 | 25 | def test_render_with_defaults 26 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs", @cv.render 27 | end 28 | 29 | def test_render_with_size 30 | assert_equal "text-white py-1 px-3 rounded-full py-1.5 px-4 text-sm", @cv.render(size: :md) 31 | end 32 | 33 | def test_render_with_size_and_color 34 | assert_equal( 35 | "text-white py-1 px-3 rounded-full bg-green-500 py-1 px-3 text-xs", 36 | @cv.render(size: :sm, color: :success) 37 | ) 38 | end 39 | 40 | def test_boolean_variants 41 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs", @cv.render(visible: true) 42 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs hidden", @cv.render(visible: false) 43 | end 44 | 45 | def test_compound_variants 46 | assert_equal( 47 | "text-white py-1 px-3 rounded-full bg-green-500 py-1 px-3 text-xs opacity-50 bg-gray-500 bg-green-100 text-green-700", 48 | @cv.render(color: :success, disabled: true) 49 | ) 50 | end 51 | 52 | def test_additional_classes 53 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs text-black", @cv.render(class: "text-black") 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/configuration_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConfigurationTest < Minitest::Test 4 | def teardown 5 | ClassVariants.configuration.instance_variable_set(:@process_classes_with, nil) 6 | end 7 | 8 | def test_configuration_process_classes_with_default 9 | refute ClassVariants.configuration.process_classes_with 10 | end 11 | 12 | def test_configure 13 | ClassVariants.configure do |config| 14 | config.process_classes_with { |classes| classes } 15 | end 16 | 17 | assert_respond_to ClassVariants.configuration.process_classes_with, :call 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/hash_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class HashTest < Minitest::Test 4 | def setup 5 | @cv = ClassVariants.build( 6 | base: "text-white py-1 px-3 rounded-full", 7 | variants: { 8 | color: { 9 | primary: "bg-blue-500", 10 | secondary: "bg-purple-500", 11 | success: "bg-green-500" 12 | }, 13 | size: { 14 | sm: "py-1 px-3 text-xs", 15 | md: "py-1.5 px-4 text-sm", 16 | lg: "py-2 px-6 text-md" 17 | }, 18 | disabled: "opacity-50 bg-gray-500", 19 | "!visible": "hidden" 20 | }, 21 | compound_variants: [ 22 | {color: :success, disabled: true, class: "bg-green-100 text-green-700"} 23 | ], 24 | defaults: { 25 | size: :sm 26 | } 27 | ) 28 | end 29 | 30 | def test_render_with_defaults 31 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs", @cv.render 32 | end 33 | 34 | def test_render_with_size 35 | assert_equal "text-white py-1 px-3 rounded-full py-1.5 px-4 text-sm", @cv.render(size: :md) 36 | end 37 | 38 | def test_render_with_size_and_color 39 | assert_equal( 40 | "text-white py-1 px-3 rounded-full bg-green-500 py-1 px-3 text-xs", 41 | @cv.render(size: :sm, color: :success) 42 | ) 43 | end 44 | 45 | def test_boolean_variants 46 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs", @cv.render(visible: true) 47 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs hidden", @cv.render(visible: false) 48 | end 49 | 50 | def test_compound_variants 51 | assert_equal( 52 | "text-white py-1 px-3 rounded-full bg-green-500 py-1 px-3 text-xs opacity-50 bg-gray-500 bg-green-100 text-green-700", 53 | @cv.render(color: :success, disabled: true) 54 | ) 55 | end 56 | 57 | def test_additional_classes 58 | assert_equal "text-white py-1 px-3 rounded-full py-1 px-3 text-xs text-black", @cv.render(class: "text-black") 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/helper_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class HelperTest < Minitest::Test 4 | class BaseClass 5 | end 6 | 7 | class DemoClass < BaseClass 8 | include ClassVariants::Helper 9 | 10 | class_variants base: "rounded border" 11 | end 12 | 13 | class SubClass < DemoClass 14 | class_variants base: "bg-black" 15 | end 16 | 17 | def test_inherited 18 | mock = Minitest::Mock.new 19 | mock.expect(:call, nil, [Class]) 20 | 21 | BaseClass.stub(:inherited, mock) do 22 | Class.new(DemoClass) 23 | end 24 | 25 | mock.verify 26 | end 27 | 28 | def test_call_from_instance 29 | assert_equal "rounded border", DemoClass.new.class_variants 30 | end 31 | 32 | def test_call_from_subclass 33 | assert_equal "rounded border bg-black", SubClass.new.class_variants 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/merge_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class MergeTest < Minitest::Test 4 | def test_hash_merge 5 | cv = ClassVariants.build( 6 | base: "rounded", 7 | variants: { 8 | color: { 9 | primary: "bg-blue-500", 10 | secondary: "bg-purple-500", 11 | success: "bg-green-500" 12 | } 13 | }, 14 | default: { 15 | color: :primary 16 | } 17 | ) 18 | cv.merge( 19 | base: "border", 20 | variants: { 21 | color: { 22 | primary: "bg-blue-700", 23 | secondary: "bg-purple-700" 24 | } 25 | }, 26 | defaults: { 27 | color: :secondary 28 | } 29 | ) 30 | 31 | assert_equal "rounded border bg-purple-500 bg-purple-700", cv.render 32 | end 33 | 34 | def test_block_merge 35 | cv = ClassVariants.build do 36 | base do 37 | slot :root, class: "rounded" 38 | slot :title, class: "font-bold" 39 | end 40 | 41 | variant variant: :outlined do 42 | slot :root, class: "border-red-700" 43 | slot :title, class: "text-red-700" 44 | end 45 | 46 | variant variant: :filled do 47 | slot :root, class: "bg-red-100" 48 | slot :title, class: "text-red-900" 49 | end 50 | 51 | defaults variant: :outlined 52 | end 53 | 54 | cv.merge do 55 | base do 56 | slot :root, class: "mb-4" 57 | slot :title, class: "mb-1" 58 | end 59 | 60 | variant variant: :filled do 61 | slot :root, class: "dark:bg-red-800" 62 | slot :title, class: "dark:text-red-50" 63 | end 64 | 65 | defaults variant: :filled 66 | end 67 | 68 | assert_equal "rounded mb-4 bg-red-100 dark:bg-red-800", cv.render(:root) 69 | assert_equal "font-bold mb-1 text-red-900 dark:text-red-50", cv.render(:title) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/process_classes_with_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ProcessClassesWithTest < Minitest::Test 4 | def teardown 5 | ClassVariants.configuration.instance_variable_set(:@process_classes_with, nil) 6 | end 7 | 8 | def test_without_classes_processor 9 | assert_equal "border rounded px-2 py-1 p-5", ClassVariants.build(base: "border rounded px-2 py-1 p-5").render 10 | end 11 | 12 | def test_with_classes_processor 13 | ClassVariants.configure do |config| 14 | config.process_classes_with do |classes| 15 | merger.merge(classes) 16 | end 17 | end 18 | 19 | assert_equal "border rounded p-5", ClassVariants.build(base: "border rounded px-2 py-1 p-5").render 20 | end 21 | 22 | private 23 | 24 | def merger 25 | require "tailwind_merge" 26 | TailwindMerge::Merger.new 27 | rescue LoadError 28 | Class.new do 29 | def merge(classes) 30 | classes.gsub("px-2 py-1 ", "") 31 | end 32 | end.new 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/slot_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SlotTest < Minitest::Test 4 | def setup 5 | @cv = ClassVariants.build do 6 | base do 7 | slot :root, class: "rounded py-3 px-5 mb-4" 8 | slot :title, class: "font-bold mb-1" 9 | end 10 | 11 | variant variant: :outlined do 12 | slot :root, class: "border" 13 | end 14 | 15 | variant variant: :outlined, severity: :error do 16 | slot :root, class: "border-red-700 dark:border-red-500" 17 | slot :title, class: "text-red-700 dark:text-red-500" 18 | slot :message, class: "text-red-600 dark:text-red-500" 19 | end 20 | 21 | variant variant: :outlined, severity: :success do 22 | slot :root, class: "border-green-700 dark:border-green-500" 23 | slot :title, class: "text-green-700 dark:text-green-500" 24 | slot :message, class: "text-green-600 dark:text-green-500" 25 | end 26 | 27 | variant variant: :filled, severity: :error do 28 | slot :root, class: "bg-red-100 dark:bg-red-800" 29 | slot :title, class: "text-red-900 dark:text-red-50" 30 | slot :message, class: "text-red-700 dark:text-red-200" 31 | end 32 | 33 | variant variant: :filled, severity: :success do 34 | slot :root, class: "bg-green-100 dark:bg-green-800" 35 | slot :title, class: "text-green-900 dark:text-green-50" 36 | slot :message, class: "text-green-700 dark:text-green-200" 37 | end 38 | 39 | defaults variant: :filled, severity: :success 40 | end 41 | end 42 | 43 | def test_render_default_slot 44 | assert_equal "", @cv.render 45 | end 46 | 47 | def test_render_nonexistent_slot 48 | assert_equal "", @cv.render(:nonexistent) 49 | end 50 | 51 | def test_render_slot_with_defaults 52 | assert_equal "rounded py-3 px-5 mb-4 bg-green-100 dark:bg-green-800", @cv.render(:root) 53 | end 54 | 55 | def test_render_slot_with_variant 56 | assert_equal "rounded py-3 px-5 mb-4 border border-green-700 dark:border-green-500", @cv.render(:root, variant: :outlined) 57 | end 58 | 59 | def test_render_slot_without_base 60 | assert_equal "text-green-700 dark:text-green-200", @cv.render(:message) 61 | end 62 | 63 | def test_render_slot_with_unused_variant 64 | assert_equal( 65 | "rounded py-3 px-5 mb-4 border border-green-700 dark:border-green-500", 66 | @cv.render(:root, variant: :outlined, type: :button) 67 | ) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 2 | require "class_variants" 3 | 4 | require "minitest/autorun" 5 | --------------------------------------------------------------------------------