├── lib └── phlex │ ├── slotable │ └── version.rb │ └── slotable.rb ├── .standard.yml ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── test ├── phlex │ ├── test_version.rb │ ├── test_single_generic_slot.rb │ ├── test_generic_slot_collection.rb │ ├── test_single_component_slot.rb │ ├── test_single_component_string_slot.rb │ ├── test_component_slot_collection.rb │ ├── test_component_string_slot_collection.rb │ ├── test_kit_compatibility.rb │ ├── test_polymorphic_slot_collection.rb │ ├── test_single_lambda_slot.rb │ ├── test_single_polymorphic_slot.rb │ └── test_lambda_slot_collection.rb └── test_helper.rb ├── Gemfile ├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── phlex-slotable.gemspec ├── CHANGELOG.md ├── benchmark └── main.rb ├── CODE_OF_CONDUCT.md └── README.md /lib/phlex/slotable/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Phlex 4 | module Slotable 5 | VERSION = "1.0.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/standardrb/standard 3 | ruby_version: 3.3 4 | format: progress 5 | parallel: true 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | 11 | # Mac OS files 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "minitest/test_task" 5 | 6 | Minitest::TestTask.create 7 | 8 | require "standard/rake" 9 | 10 | task default: %i[test standard] 11 | -------------------------------------------------------------------------------- /test/phlex/test_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestVersion < Minitest::Test 6 | def test_version 7 | refute_nil Phlex::Slotable::VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in phlex-slotable.gemspec 6 | gemspec 7 | 8 | gem "benchmark-ips", "2.14.0" 9 | gem "minitest", "5.25.4" 10 | gem "rake", "13.2.1" 11 | gem "standard", "1.45.0" 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "phlex/slotable" 5 | 6 | require "minitest/autorun" 7 | 8 | class String 9 | def join_lines 10 | gsub(/^\s+/, "").delete("\n") 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require_relative "../lib/phlex/slotable" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | schedule: 9 | - cron: 0 0 * * 1 # At 00:00 on Monday 10 | 11 | jobs: 12 | build: 13 | name: Ruby ${{ matrix.ruby }} 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | ruby: ["3.2", "3.3", "3.4", "head"] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | rubygems: latest 27 | 28 | - name: Run tests 29 | run: bundle exec rake test 30 | 31 | - name: Run linter 32 | run: bundle exec rake standard 33 | -------------------------------------------------------------------------------- /test/phlex/test_single_generic_slot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestSingleGenericSlot < Minitest::Test 6 | class Blog < Phlex::HTML 7 | include Phlex::Slotable 8 | 9 | slot :header 10 | 11 | def view_template 12 | if header_slot? 13 | div id: "header" do 14 | render header_slot 15 | end 16 | end 17 | 18 | main { "My posts" } 19 | end 20 | end 21 | 22 | def test_with_slot 23 | output = Blog.new.call do |c| 24 | c.with_header do 25 | c.h1 { "Hello World!" } 26 | end 27 | end 28 | 29 | assert_equal output, <<~HTML.join_lines 30 | 33 |
34 | My posts 35 |
36 | HTML 37 | end 38 | 39 | def test_with_no_slot 40 | output = Blog.new.call 41 | 42 | assert_equal output, "
My posts
" 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 stephann 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/phlex/test_generic_slot_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestGenericSlotCollection < Minitest::Test 6 | class Blog < Phlex::HTML 7 | include Phlex::Slotable 8 | 9 | slot :post, collection: true 10 | 11 | def view_template 12 | if post_slots? 13 | main do 14 | post_slots.each do |slot| 15 | render slot 16 | end 17 | end 18 | end 19 | 20 | footer { post_slots.size } 21 | end 22 | end 23 | 24 | def test_with_slots 25 | output = Blog.new.call do |c| 26 | c.with_post do 27 | c.p { "Post A" } 28 | end 29 | c.with_post do 30 | c.p { "Post B" } 31 | end 32 | c.with_post do 33 | c.p { "Post C" } 34 | end 35 | end 36 | 37 | assert_equal output, <<~HTML.join_lines 38 |
39 |

Post A

40 |

Post B

41 |

Post C

42 |
43 | 44 | 47 | HTML 48 | end 49 | 50 | def test_with_no_slots 51 | output = Blog.new.call 52 | 53 | assert_equal output, "" 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/phlex/test_single_component_slot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestSingleComponentSlot < Minitest::Test 6 | class HeaderComponent < Phlex::HTML 7 | def initialize(size:) 8 | @size = size 9 | end 10 | 11 | def view_template(&content) 12 | h1(class: "text-#{@size}", &content) 13 | end 14 | end 15 | 16 | class Blog < Phlex::HTML 17 | include Phlex::Slotable 18 | 19 | slot :header, HeaderComponent 20 | 21 | def view_template 22 | if header_slot? 23 | div id: "header" do 24 | render header_slot if header_slot? 25 | end 26 | end 27 | 28 | main { "My posts" } 29 | end 30 | end 31 | 32 | def test_with_slot 33 | output = Blog.new.call do |c| 34 | c.with_header(size: "lg") { "Hello World!" } 35 | end 36 | 37 | assert_equal output, <<~HTML.join_lines 38 | 41 |
42 | My posts 43 |
44 | HTML 45 | end 46 | 47 | def test_with_no_slot 48 | output = Blog.new.call 49 | 50 | assert_equal output, "
My posts
" 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/phlex/test_single_component_string_slot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestSingleComponentStringSlot < Minitest::Test 6 | class Blog < Phlex::HTML 7 | include Phlex::Slotable 8 | 9 | slot :header, "HeaderComponent" 10 | 11 | def view_template 12 | if header_slot? 13 | div id: "header" do 14 | render header_slot if header_slot? 15 | end 16 | end 17 | 18 | main { "My posts" } 19 | end 20 | 21 | private 22 | 23 | class HeaderComponent < Phlex::HTML 24 | def initialize(size:) 25 | @size = size 26 | end 27 | 28 | def view_template(&content) 29 | h1(class: "text-#{@size}", &content) 30 | end 31 | end 32 | end 33 | 34 | def test_with_slot 35 | output = Blog.new.call do |c| 36 | c.with_header(size: "lg") { "Hello World!" } 37 | end 38 | 39 | assert_equal output, <<~HTML.join_lines 40 | 43 |
44 | My posts 45 |
46 | HTML 47 | end 48 | 49 | def test_with_no_slot 50 | output = Blog.new.call 51 | 52 | assert_equal output, "
My posts
" 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/phlex/test_component_slot_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestComponentSlotCollection < Minitest::Test 6 | class PostComponent < Phlex::HTML 7 | def initialize(featured: false) 8 | @featured = featured 9 | end 10 | 11 | def view_template(&content) 12 | p(class: @featured ? "featured" : nil, &content) 13 | end 14 | end 15 | 16 | class Blog < Phlex::HTML 17 | include Phlex::Slotable 18 | 19 | slot :post, PostComponent, collection: true 20 | 21 | def view_template 22 | if post_slots? 23 | main do 24 | post_slots.each do |slot| 25 | render slot 26 | end 27 | end 28 | end 29 | 30 | footer { post_slots.size } 31 | end 32 | end 33 | 34 | def test_with_slots 35 | output = Blog.new.call do |c| 36 | c.with_post(featured: true) { "Post A" } 37 | c.with_post { "Post B" } 38 | c.with_post { "Post C" } 39 | end 40 | 41 | assert_equal output, <<~HTML.join_lines 42 |
43 | 44 |

Post B

45 |

Post C

46 |
47 | 48 | 51 | HTML 52 | end 53 | 54 | def test_with_no_slots 55 | output = Blog.new.call 56 | 57 | assert_equal output, "" 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/phlex/test_component_string_slot_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestComponentStringSlotCollection < Minitest::Test 6 | class Blog < Phlex::HTML 7 | include Phlex::Slotable 8 | 9 | slot :post, "PostComponent", collection: true 10 | 11 | def view_template 12 | if post_slots? 13 | main do 14 | post_slots.each do |slot| 15 | render slot 16 | end 17 | end 18 | end 19 | 20 | footer { post_slots.size } 21 | end 22 | 23 | private 24 | 25 | class PostComponent < Phlex::HTML 26 | def initialize(featured: false) 27 | @featured = featured 28 | end 29 | 30 | def view_template(&content) 31 | p(class: @featured ? "featured" : nil, &content) 32 | end 33 | end 34 | end 35 | 36 | def test_with_slots 37 | output = Blog.new.call do |c| 38 | c.with_post(featured: true) { "Post A" } 39 | c.with_post { "Post B" } 40 | c.with_post { "Post C" } 41 | end 42 | 43 | assert_equal output, <<~HTML.join_lines 44 |
45 | 46 |

Post B

47 |

Post C

48 |
49 | 50 | 53 | HTML 54 | end 55 | 56 | def test_with_no_slots 57 | output = Blog.new.call 58 | 59 | assert_equal output, "" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/phlex/test_kit_compatibility.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestKitCompatibility < Minitest::Test 6 | class Headline < Phlex::HTML 7 | include Phlex::Slotable 8 | 9 | slot :icon 10 | slot :title 11 | 12 | def initialize(size:, bg_color:) 13 | @size = size 14 | @bg_color = bg_color 15 | end 16 | 17 | def view_template 18 | div class: "headline text-#{@size} bg-#{@bg_color}" do 19 | render icon_slot 20 | render title_slot 21 | end 22 | end 23 | end 24 | 25 | class Header < Phlex::HTML 26 | def view_template(&) 27 | h1(&) 28 | end 29 | end 30 | 31 | module Components 32 | extend Phlex::Kit 33 | 34 | Headline = Phlex::TestKitCompatibility::Headline 35 | Header = Phlex::TestKitCompatibility::Header 36 | end 37 | 38 | class Page < Phlex::HTML 39 | include Components 40 | 41 | def view_template 42 | Headline(size: :lg, bg_color: :red) do |h| 43 | h.with_icon { h.i(class: "star") } 44 | h.with_title do 45 | Header { "Hello World!" } 46 | end 47 | end 48 | end 49 | end 50 | 51 | def test_with_slots 52 | output = Page.new.call 53 | 54 | expected_html = <<~HTML.join_lines 55 |
56 | 57 |

Hello World!

58 |
59 | HTML 60 | 61 | assert_equal expected_html, output 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /phlex-slotable.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/phlex/slotable/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "phlex-slotable" 7 | spec.version = Phlex::Slotable::VERSION 8 | spec.authors = ["stephann"] 9 | spec.email = ["3025661+stephannv@users.noreply.github.com"] 10 | 11 | spec.summary = "Enable Slot API for Phlex views" 12 | spec.homepage = "https://github.com/stephannv/phlex-slotable" 13 | spec.license = "MIT" 14 | spec.required_ruby_version = ">= 3.2" 15 | 16 | # spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'" 17 | 18 | spec.metadata["homepage_uri"] = spec.homepage 19 | # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." 20 | # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." 21 | 22 | # Specify which files should be added to the gem when it is released. 23 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 24 | spec.files = Dir.chdir(__dir__) do 25 | `git ls-files -z`.split("\x0").reject do |f| 26 | (File.expand_path(f) == __FILE__) || 27 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 28 | end 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | # Uncomment to register a new dependency of your gem 35 | spec.add_dependency "phlex", ">= 2", "< 3" 36 | 37 | # For more information and examples about making a new gem, check out our 38 | # guide at: https://bundler.io/guides/creating_gem.html 39 | end 40 | -------------------------------------------------------------------------------- /test/phlex/test_polymorphic_slot_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestPolymorphicSlotCollection < Minitest::Test 6 | class ImageComponent < Phlex::HTML 7 | def initialize(src:) 8 | @src = src 9 | end 10 | 11 | def view_template 12 | img(class: "image", src: @src) 13 | end 14 | end 15 | 16 | class UsersList < Phlex::HTML 17 | include Phlex::Slotable 18 | 19 | slot :avatar, collection: true, types: { 20 | image: ImageComponent, 21 | icon: "IconComponent", 22 | text: ->(size:, &content) do 23 | span(class: "text-#{size}", &content) 24 | end 25 | } 26 | 27 | def view_template 28 | if avatar_slots? 29 | div id: "users" do 30 | avatar_slots.each { |slot| render slot } 31 | end 32 | end 33 | 34 | span { "Users: #{avatar_slots.size}" } 35 | end 36 | 37 | private 38 | 39 | class IconComponent < Phlex::HTML 40 | def initialize(name:) 41 | @name = name 42 | end 43 | 44 | def view_template 45 | i(class: @name) 46 | end 47 | end 48 | end 49 | 50 | def test_with_slots 51 | output = UsersList.new.call do |c| 52 | c.with_image_avatar(src: "user.png") 53 | c.with_icon_avatar(name: "home") 54 | c.with_text_avatar(size: "lg") { "SV" } 55 | 56 | c.with_image_avatar(src: "user2.png") 57 | c.with_icon_avatar(name: "heart") 58 | c.with_text_avatar(size: "sm") { "TV" } 59 | end 60 | 61 | assert_equal output, <<~HTML.join_lines 62 |
63 | 64 | 65 | SV 66 | 67 | 68 | TV 69 |
70 | 71 | Users: 6 72 | 73 | HTML 74 | end 75 | 76 | def test_with_no_slots 77 | output = UsersList.new.call 78 | 79 | assert_equal output, "Users: 0" 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/phlex/test_single_lambda_slot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestSingleLambdaSlot < Minitest::Test 6 | class SubtitleComponent < Phlex::HTML 7 | include Phlex::Slotable 8 | 9 | slot :icon 10 | slot :content 11 | 12 | def initialize(size:, bg_color:) 13 | @size = size 14 | @bg_color = bg_color 15 | end 16 | 17 | def view_template(&content) 18 | h3(class: "text-#{@size} bg-#{@bg_color}") do 19 | render icon_slot 20 | render content_slot 21 | end 22 | end 23 | end 24 | 25 | class Blog < Phlex::HTML 26 | include Phlex::Slotable 27 | 28 | slot :title, ->(size:, &content) { h1(class: "text-#{size}", &content) } 29 | slot :subtitle, ->(size:, &content) do 30 | render SubtitleComponent.new(size: size, bg_color: @subtitle_bg_color), &content 31 | end 32 | 33 | def initialize(subtitle_bg_color: nil) 34 | @subtitle_bg_color = subtitle_bg_color 35 | end 36 | 37 | def view_template 38 | div id: "header" do 39 | render title_slot if title_slot? 40 | render subtitle_slot if subtitle_slot? 41 | end 42 | 43 | main { "My posts" } 44 | end 45 | end 46 | 47 | def test_with_slot 48 | output = Blog.new(subtitle_bg_color: "gray").call do |c| 49 | c.with_title(size: :lg) { "Hello World!" } 50 | c.with_subtitle(size: :sm) do |s| 51 | s.with_icon { s.i(class: "home") } 52 | s.with_content { "Welcome to your posts" } 53 | end 54 | end 55 | 56 | assert_equal output, <<~HTML.join_lines 57 | 64 |
65 | My posts 66 |
67 | HTML 68 | end 69 | 70 | def test_with_no_slot 71 | output = Blog.new.call 72 | 73 | assert_equal output, <<~HTML.join_lines 74 | 75 |
My posts
76 | HTML 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.0] - 2025-03-01 2 | - Reimplement DeferredRender module 3 | 4 | ## [0.5.0] - 2024-09-08 5 | - Prepare for Phlex 2.0 💪 6 | - Drop Ruby 2.7, 3.0 and 3.1 support 7 | - Add Phlex::Kit compatibility tests 8 | - Make `Phlex::Slotable::VERSION` available by default 9 | 10 | ## [0.4.0] - 2024-02-14 11 | - [BREAKING CHANGE] Rename `many` option to `collection`. 12 | 13 | *stephannv* 14 | 15 | - Improve generic slot performance 16 | 17 | *stephannv* 18 | 19 | ## [0.3.1] - 2024-02-14 20 | - Support Ruby 2.7 21 | 22 | *stephannv* 23 | 24 | ## [0.3.0] - 2024-02-14 25 | 26 | - Match Slotable peformance with DeferredRender 27 | ``` 28 | ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23] 29 | Warming up -------------------------------------- 30 | Deferred 26.779k i/100ms 31 | Slotable 0.2.0 21.636k i/100ms 32 | Slotable 0.3.0 27.013k i/100ms 33 | Calculating ------------------------------------- 34 | Deferred 267.884k (± 0.6%) i/s - 1.366M in 5.098391s 35 | Slotable 0.2.0 216.193k (± 0.4%) i/s - 1.082M in 5.003961s 36 | Slotable 0.3.0 270.082k (± 0.5%) i/s - 1.351M in 5.001001s 37 | ``` 38 | *stephannv* 39 | 40 | - Allow polymorphic slots 41 | ```ruby 42 | class CardComponent < Phlex::HTML 43 | include Phlex::Slotable 44 | 45 | slot :avatar, types: { icon: IconComponent, image: ImageComponent } 46 | 47 | def template 48 | if avatar_slot? 49 | render avatar_slot 50 | end 51 | end 52 | end 53 | 54 | render CardComponent.new do |card| 55 | if user 56 | card.with_image_avatar(src: user.image_url) 57 | else 58 | card.with_icon_avatar(name: :user) 59 | end 60 | end 61 | ``` 62 | 63 | *stephannv* 64 | 65 | ## [0.2.0] - 2024-02-13 66 | 67 | - Allow view slots using string as class name 68 | 69 | *stephannv* 70 | 71 | - Allow lambda slots 72 | 73 | *stephannv* 74 | 75 | ## [0.1.0] - 2024-02-12 76 | - Add single and multi slots 77 | 78 | *stephannv* 79 | 80 | - Add generic slots 81 | 82 | *stephannv* 83 | 84 | - Add view slots 85 | 86 | *stephannv* 87 | -------------------------------------------------------------------------------- /test/phlex/test_single_polymorphic_slot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestSinglePolymorphicSlot < Minitest::Test 6 | class ImageComponent < Phlex::HTML 7 | def initialize(src:) 8 | @src = src 9 | end 10 | 11 | def view_template 12 | img(class: "image", src: @src) 13 | end 14 | end 15 | 16 | class ProfileCard < Phlex::HTML 17 | include Phlex::Slotable 18 | 19 | slot :avatar, types: { 20 | image: ImageComponent, 21 | icon: "IconComponent", 22 | text: ->(size:, &content) do 23 | span(class: "text-#{size}", &content) 24 | end 25 | } 26 | 27 | def view_template 28 | if avatar_slot? 29 | div id: "avatar" do 30 | render avatar_slot 31 | end 32 | end 33 | 34 | span { "User name" } 35 | end 36 | 37 | private 38 | 39 | class IconComponent < Phlex::HTML 40 | def initialize(name:) 41 | @name = name 42 | end 43 | 44 | def view_template 45 | i(class: @name) 46 | end 47 | end 48 | end 49 | 50 | def test_component_slot 51 | output = ProfileCard.new.call do |c| 52 | c.with_image_avatar(src: "user.png") 53 | end 54 | 55 | assert_equal output, <<~HTML.join_lines 56 |
57 | 58 |
59 | 60 | User name 61 | 62 | HTML 63 | end 64 | 65 | def test_component_string_slot 66 | output = ProfileCard.new.call do |c| 67 | c.with_icon_avatar(name: "home") 68 | end 69 | 70 | assert_equal output, <<~HTML.join_lines 71 |
72 | 73 |
74 | 75 | User name 76 | 77 | HTML 78 | end 79 | 80 | def test_lambda_slot 81 | output = ProfileCard.new.call do |c| 82 | c.with_text_avatar(size: "lg") { "SV" } 83 | end 84 | 85 | assert_equal output, <<~HTML.join_lines 86 |
87 | SV 88 |
89 | 90 | User name 91 | 92 | HTML 93 | end 94 | 95 | def test_with_no_slot 96 | output = ProfileCard.new.call 97 | 98 | assert_equal output, "User name" 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /benchmark/main.rb: -------------------------------------------------------------------------------- 1 | RubyVM::YJIT.enable 2 | 3 | require "benchmark" 4 | require "benchmark/ips" 5 | require_relative "../lib/phlex/slotable" 6 | 7 | require "phlex/version" 8 | 9 | puts "Phlex #{Phlex::VERSION}" 10 | puts "Phlex::Slotable #{Phlex::Slotable::VERSION}" 11 | 12 | class DeferredList < Phlex::HTML 13 | def before_template(&) 14 | vanish(&) 15 | super 16 | end 17 | 18 | def initialize 19 | @items = [] 20 | end 21 | 22 | def view_template 23 | if @header 24 | h1(class: "header", &@header) 25 | end 26 | 27 | ul do 28 | @items.each do |item| 29 | li { render(item) } 30 | end 31 | end 32 | end 33 | 34 | def header(&block) 35 | @header = block 36 | end 37 | 38 | def with_item(&content) 39 | @items << content 40 | end 41 | end 42 | 43 | class SlotableList < Phlex::HTML 44 | include Phlex::Slotable 45 | 46 | slot :header 47 | slot :item, collection: true 48 | 49 | def view_template 50 | if header_slot 51 | h1(class: "header", &header_slot) 52 | end 53 | 54 | ul do 55 | item_slots.each do |slot| 56 | li { render(slot) } 57 | end 58 | end 59 | end 60 | end 61 | 62 | class DeferredListExample < Phlex::HTML 63 | def view_template 64 | render DeferredList.new do |list| 65 | list.header do 66 | "Header" 67 | end 68 | 69 | list.with_item do 70 | "One" 71 | end 72 | 73 | list.with_item do 74 | "two" 75 | end 76 | end 77 | end 78 | end 79 | 80 | class SlotableListExample < Phlex::HTML 81 | def view_template 82 | render SlotableList.new do |list| 83 | list.with_header do 84 | "Header" 85 | end 86 | 87 | list.with_item do 88 | "One" 89 | end 90 | 91 | list.with_item do 92 | "two" 93 | end 94 | end 95 | end 96 | end 97 | 98 | puts RUBY_DESCRIPTION 99 | 100 | deferred_list = DeferredListExample.new.call 101 | slotable_list = SlotableListExample.new.call 102 | 103 | raise unless deferred_list == slotable_list 104 | 105 | Benchmark.bmbm do |x| 106 | x.report("Deferred") { 1_000_000.times { DeferredListExample.new.call } } 107 | x.report("Slotable") { 1_000_000.times { SlotableListExample.new.call } } 108 | end 109 | 110 | puts 111 | 112 | Benchmark.ips do |x| 113 | x.report("Deferred") { DeferredListExample.new.call } 114 | x.report("Slotable") { SlotableListExample.new.call } 115 | x.compare! 116 | end 117 | -------------------------------------------------------------------------------- /test/phlex/test_lambda_slot_collection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class Phlex::TestLambdaSlotCollection < Minitest::Test 6 | class HeadlineComponent < Phlex::HTML 7 | include Phlex::Slotable 8 | 9 | slot :icon 10 | slot :title 11 | 12 | def initialize(size:, bg_color:) 13 | @size = size 14 | @bg_color = bg_color 15 | end 16 | 17 | def view_template 18 | div class: "headline text-#{@size} bg-#{@bg_color}" do 19 | render icon_slot 20 | render title_slot 21 | end 22 | end 23 | end 24 | 25 | class Blog < Phlex::HTML 26 | include Phlex::Slotable 27 | 28 | slot :post, ->(featured: false, &content) { p(class: featured ? "featured" : nil, &content) }, collection: true 29 | slot :headline, ->(size:, &content) do 30 | render HeadlineComponent.new(size: size, bg_color: @headline_bg_color), &content 31 | end, collection: true 32 | 33 | def initialize(headline_bg_color: nil) 34 | @headline_bg_color = headline_bg_color 35 | end 36 | 37 | def view_template 38 | if post_slots? 39 | main do 40 | headline_slots.each do |slot| 41 | render slot 42 | end 43 | post_slots.each do |slot| 44 | render slot 45 | end 46 | end 47 | end 48 | 49 | footer { post_slots.size } 50 | end 51 | end 52 | 53 | def test_with_slots 54 | output = Blog.new(headline_bg_color: "blue").call do |c| 55 | c.with_post(featured: true) { "Post A" } 56 | c.with_post { "Post B" } 57 | c.with_post { "Post C" } 58 | 59 | c.with_headline(size: "lg") do |h| 60 | h.with_title { "Headline A" } 61 | h.with_icon { h.i(class: "star") } 62 | end 63 | c.with_headline(size: "md") do |h| 64 | h.with_title { "Headline B" } 65 | h.with_icon { h.i(class: "heart") } 66 | end 67 | end 68 | 69 | expected_html = <<~HTML.join_lines 70 |
71 |
72 | 73 | Headline A 74 |
75 |
76 | 77 | Headline B 78 |
79 | 80 | 81 |

Post B

82 |

Post C

83 |
84 | 85 | 88 | HTML 89 | 90 | assert_equal expected_html, output 91 | end 92 | 93 | def test_with_no_slots 94 | output = Blog.new.call 95 | 96 | assert_equal output, "" 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/phlex/slotable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "phlex" 4 | 5 | module Phlex 6 | module Slotable 7 | autoload :VERSION, "./slotable/version" 8 | 9 | module DeferredRender 10 | def before_template(&) 11 | vanish(&) 12 | super 13 | end 14 | end 15 | 16 | def self.included(base) 17 | base.extend(ClassMethods) 18 | end 19 | 20 | module ClassMethods 21 | def slot(slot_name, callable = nil, types: nil, collection: false) 22 | include DeferredRender 23 | 24 | if types 25 | types.each do |type, callable| 26 | define_setter_method(slot_name, callable, collection: collection, type: type) 27 | end 28 | else 29 | define_setter_method(slot_name, callable, collection: collection) 30 | end 31 | define_predicate_method(slot_name, collection: collection) 32 | define_getter_method(slot_name, collection: collection) 33 | end 34 | 35 | private 36 | 37 | def define_setter_method(slot_name, callable, collection:, type: nil) 38 | slot_name_with_type = type ? "#{type}_#{slot_name}" : slot_name 39 | signature = callable.nil? ? "(&block)" : "(*args, **kwargs, &block)" 40 | 41 | setter_method = if collection 42 | <<-RUBY 43 | def with_#{slot_name_with_type}#{signature} 44 | @#{slot_name}_slots ||= [] 45 | @#{slot_name}_slots << #{callable_value(slot_name_with_type, callable)} 46 | end 47 | RUBY 48 | else 49 | <<-RUBY 50 | def with_#{slot_name_with_type}#{signature} 51 | @#{slot_name}_slot = #{callable_value(slot_name_with_type, callable)} 52 | end 53 | RUBY 54 | end 55 | 56 | class_eval(setter_method, __FILE__, __LINE__) 57 | define_lambda_method(slot_name_with_type, callable) if callable.is_a?(Proc) 58 | end 59 | 60 | def define_lambda_method(slot_name, callable) 61 | define_method :"__call_#{slot_name}__", &callable 62 | private :"__call_#{slot_name}__" 63 | end 64 | 65 | def define_getter_method(slot_name, collection:) 66 | getter_method = if collection 67 | <<-RUBY 68 | def #{slot_name}_slots = @#{slot_name}_slots ||= [] 69 | 70 | private :#{slot_name}_slots 71 | RUBY 72 | else 73 | <<-RUBY 74 | def #{slot_name}_slot = @#{slot_name}_slot 75 | 76 | private :#{slot_name}_slot 77 | RUBY 78 | end 79 | 80 | class_eval(getter_method, __FILE__, __LINE__) 81 | end 82 | 83 | def define_predicate_method(slot_name, collection:) 84 | predicate_method = if collection 85 | <<-RUBY 86 | def #{slot_name}_slots? = #{slot_name}_slots.any? 87 | 88 | private :#{slot_name}_slots? 89 | RUBY 90 | else 91 | <<-RUBY 92 | def #{slot_name}_slot? = !#{slot_name}_slot.nil? 93 | 94 | private :#{slot_name}_slot? 95 | RUBY 96 | end 97 | 98 | class_eval(predicate_method, __FILE__, __LINE__) 99 | end 100 | 101 | def callable_value(slot_name, callable) 102 | case callable 103 | when nil 104 | %(block) 105 | when Proc 106 | %(-> { __call_#{slot_name}__(*args, **kwargs, &block) }) 107 | else 108 | %(#{callable}.new(*args, **kwargs, &block)) 109 | end 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at 3025661+stephannv@users.noreply.github.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phlex::Slotable 2 | [![CI](https://github.com/stephannv/phlex-slotable/actions/workflows/main.yml/badge.svg)](https://github.com/stephannv/phlex-slotable/actions/workflows/main.yml) 3 | 4 | Phlex::Slotable enables slots feature to [Phlex](https://www.phlex.fun/) views. Inspired by ViewComponent. 5 | 6 | - [What is a slot?](#what-is-a-slot) 7 | - [Getting started](#getting-started) 8 | - [Generic slot](#generic-slot) 9 | - [Slot collection](#slot-collection) 10 | - [Component slot](#component-slot) 11 | - [Lambda slot](#lambda-slot) 12 | - [Polymorphic slot](#polymorphic-slot) 13 | - [Performance](#performance) 14 | - [Development](#development) 15 | - [Contributing](#contributing) 16 | 17 | ## What is a slot? 18 | 19 | In the context of view components, a **slot** serves as a placeholder inside a component that can be filled with custom content. Essentially, slots enable a component to accept external content and autonomously organize it within its structure. This abstraction allows developers to work with components without needing to understand their internals, thereby ensuring visual consistency and improving developer experience. 20 | 21 | ## Getting started 22 | 23 | Install the gem and add to the application's Gemfile by executing: 24 | 25 | $ bundle add phlex-slotable 26 | 27 | If bundler is not being used to manage dependencies, install the gem by executing: 28 | 29 | $ gem install phlex-slotable 30 | 31 | > [!TIP] 32 | > If you prefer not to add another dependency to your project, you can simply copy the [Phlex::Slotable](https://github.com/stephannv/phlex-slotable/blob/main/lib/phlex/slotable.rb) file into your project. 33 | 34 | Afterward, simply include `Phlex::Slotable` into your Phlex component and utilize `slot` macro to define the component's slots. For example: 35 | 36 | ```ruby 37 | class MyComponent < Phlex::HTML 38 | include Phlex::Slotable 39 | 40 | slot :my_slot 41 | end 42 | ``` 43 | 44 | Below, you will find a more detailed explanation of how to use the `slot` API. 45 | 46 | ## Generic slot 47 | 48 | Any content can be passed to components through generic slots, also known as passthrough slots. To define a generic slot, use `slot :{slot_name}`. For example: 49 | 50 | ```ruby 51 | class PageComponent < Phlex::HTML 52 | include Phlex::Slotable 53 | 54 | slot :title 55 | end 56 | ``` 57 | 58 | To render a slot, render the `{slot_name}_slot`: 59 | 60 | ```ruby 61 | class PageComponent < Phlex::HTML 62 | include Phlex::Slotable 63 | 64 | slot :title 65 | 66 | def template 67 | header { render title_slot } 68 | end 69 | end 70 | ``` 71 | 72 | To pass content to the component's slot, you should use `with_{slot_name}`: 73 | 74 | ```ruby 75 | PageComponent.new.call do |page| 76 | page.with_title do 77 | h1 { "Hello World!" } 78 | end 79 | end 80 | ``` 81 | 82 | Returning: 83 | 84 | ```html 85 |
86 |

Hello World!

87 |
88 | ``` 89 | 90 | You can test if a slot has been passed to the component with `{slot_name}_slot?` method. For example: 91 | 92 | ```ruby 93 | class PageComponent < Phlex::HTML 94 | include Phlex::Slotable 95 | 96 | slot :title 97 | 98 | def template 99 | if header_slot? 100 | header { render title_slot } 101 | else 102 | plain "No title" 103 | end 104 | end 105 | end 106 | ``` 107 | 108 | ## Slot collection 109 | 110 | A slot collection denotes a slot capable of being rendered multiple times within a component. It has some minor differences compared to a single slot seen previously. First, you should pass `collection: true` when defining the slot: 111 | 112 | ```ruby 113 | class ListComponent < Phlex::HTML 114 | include Phlex::Slotable 115 | 116 | slot :item, collection: true 117 | end 118 | ``` 119 | 120 | To render a collection of slots, iterate over the `{slot_name}_slots` collection and render each slot individually: 121 | 122 | ```ruby 123 | class ListComponent < Phlex::HTML 124 | include Phlex::Slotable 125 | 126 | slot :item, collection: true 127 | 128 | def template 129 | if item_slots? 130 | ul do 131 | item_slots.each do |item_slot| 132 | li { render item_slot } 133 | end 134 | end 135 | end 136 | 137 | span { "Total: #{item_slots.size}" } 138 | end 139 | end 140 | ``` 141 | 142 | To set slot content, use the `with_{slot_name}` method when rendering the component. Unlike the single slot, `with_{slot_name}` can be called multiple times: 143 | 144 | ```ruby 145 | ListComponent.new.call do |list| 146 | list.with_item { "Item A" } 147 | list.with_item { "Item B" } 148 | list.with_item { "Item C" } 149 | end 150 | ``` 151 | 152 | Returning: 153 | 154 | ```html 155 | 160 | 161 | Total: 3 162 | ``` 163 | 164 | ## Component slot 165 | 166 | Slots have the capability to render other components. When defining a slot, provide the name of a component class as the second argument to define a component slot 167 | 168 | ```ruby 169 | class ListHeaderComponent < Phlex::HTML 170 | # omitted code 171 | end 172 | 173 | class ListItemComponent < Phlex::HTML 174 | # omitted code 175 | end 176 | 177 | class ListComponent < Phlex::HTML 178 | include Phlex::Slotable 179 | 180 | slot :header, ListHeaderComponent 181 | slot :item, ListItemComponent, collection: true 182 | 183 | def template 184 | div id: "header" do 185 | render header_slot if header_slot? 186 | end 187 | 188 | ul do 189 | item_slots.each { |slot| render slot } 190 | end 191 | end 192 | end 193 | 194 | ListComponent.new.call do |list| 195 | list.with_header(size: "lg") { "Hello World!" } 196 | 197 | list.with_item(active: true) { "Item A" } 198 | list.with_item { "Item B" } 199 | list.with_item { "Item C" } 200 | end 201 | ``` 202 | 203 | Returning: 204 | 205 | ```html 206 | 215 | ``` 216 | 217 | > [!TIP] 218 | > You can also pass the component class as a string if your component class hasn't been defined yet. For example: 219 | > 220 | > ```ruby 221 | > slot :header, "HeaderComponent" 222 | > slot :item, "ItemComponent", collection: true 223 | >``` 224 | 225 | 226 | ## Lambda slot 227 | 228 | Lambda slots are valuable when you prefer not to create another component for straightforward structures or when you need to render another component with specific parameters. 229 | 230 | ```ruby 231 | class ListComponent < Phlex::HTML 232 | include Phlex::Slotable 233 | 234 | slot :header, ->(size:, &content) do 235 | render HeaderComponent.new(size: size, color: "primary") 236 | end 237 | slot :item, ->(href:, &content) { li { a(href: href, &content) } }, collection: true 238 | 239 | def template 240 | div id: "header" do 241 | render header_slot if header_slot? 242 | end 243 | 244 | ul do 245 | item_slots.each { |slot| render slot } 246 | end 247 | end 248 | end 249 | 250 | ListComponent.new.call do |list| 251 | list.with_header(size: "lg") { "Hello World!" } 252 | 253 | list.with_item(href: "/a") { "Item A" } 254 | list.with_item(href: "/b") { "Item B" } 255 | list.with_item(href: "/c") { "Item C" } 256 | end 257 | ``` 258 | 259 | Returning: 260 | 261 | ```html 262 | 271 | ``` 272 | 273 | > [!TIP] 274 | > You can access the internal component state within lambda slots. For example 275 | > 276 | > ```ruby 277 | > slot :header, ->(&content) { render HeaderComponent.new(featured: @featured), &content } 278 | > 279 | > def initialize(featured:) 280 | > @featured = feature 281 | > end 282 | > ``` 283 | 284 | ## Polymorphic slot 285 | 286 | Polymorphic slots can render one of several possible slots, allowing for flexibility in component content. This feature is particularly useful when you require a fixed structure but need to accommodate different types of content. To implement this, simply pass a types hash containing the types along with corresponding slot definitions. 287 | 288 | ```ruby 289 | class IconComponent < Phlex::HTML 290 | # omitted code 291 | end 292 | 293 | class ImageComponent < Phlex::HTML 294 | # omitted code 295 | end 296 | 297 | class CardComponent < Phlex::HTML 298 | include Phlex::Slotable 299 | 300 | slot :avatar, types: { icon: IconComponent, image: ImageComponent } 301 | 302 | def template 303 | if avatar_slot? 304 | div id: "avatar" do 305 | render avatar_slot 306 | end 307 | end 308 | end 309 | end 310 | 311 | User = Data.define(:image_url) 312 | user = User.new(image_url: "user.png") 313 | 314 | CardComponent.new.call do |card| 315 | if user.image_url 316 | card.with_image_avatar(src: user.image_url) 317 | else 318 | card.with_icon_avatar(name: :user) 319 | end 320 | end 321 | ``` 322 | 323 | Returning: 324 | 325 | ```html 326 |
327 | 328 |
329 | ``` 330 | 331 | Note that you need to use `with_{type}_{slot_name}` to set slot content. In the example above, it was used `with_image_avatar` and `with_icon_avatar`. 332 | 333 | > [!TIP] 334 | > You can take advantage of all the previously introduced features, such as lambda slot and slot collection: 335 | > 336 | > ```ruby 337 | > slot :avatar, collection: true, types: { 338 | > icon: IconComponent, 339 | > image: "ImageComponent", 340 | > text: ->(&content) { span(class: "avatar", &content) } 341 | > } 342 | > ``` 343 | 344 | ## Performance 345 | Using Phlex::Slotable you don't suffer a performance penalty compared to using Phlex::DeferredRender, sometimes it can even be a little faster. 346 | 347 | ``` 348 | Generated using `ruby benchmark/main.rb` 349 | 350 | Phlex 1.11.0 351 | Phlex::Slotable 0.5.0 352 | 353 | ruby 3.3.5 (2024-09-03 revision ef084cc8f4) [arm64-darwin23] 354 | Warming up -------------------------------------- 355 | Deferred 22.176k i/100ms 356 | Slotable 23.516k i/100ms 357 | Calculating ------------------------------------- 358 | Deferred 222.727k (± 0.8%) i/s (4.49 μs/i) - 1.131M in 5.078157s 359 | Slotable 237.405k (± 0.6%) i/s (4.21 μs/i) - 1.199M in 5.051936s 360 | 361 | Comparison: 362 | Slotable: 237405.0 i/s 363 | Deferred: 222726.8 i/s - 1.07x slower 364 | ``` 365 | 366 | 367 | ## Development 368 | 369 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 370 | 371 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 372 | 373 | ## Contributing 374 | 375 | Bug reports and pull requests are welcome on GitHub at https://github.com/stephannv/phlex-slotable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/stephannv/phlex-slotable/blob/master/CODE_OF_CONDUCT.md). 376 | 377 | ## License 378 | 379 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 380 | 381 | ## Code of Conduct 382 | 383 | Everyone interacting in the Phlex::Slot project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/stephannv/phlex-slotable/blob/master/CODE_OF_CONDUCT.md). 384 | --------------------------------------------------------------------------------