├── 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 | Post A
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 | Post A
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 | Post A
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 | [](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 |
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 |
156 | - Item A
157 | - Item B
158 | - Item C
159 |
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 |
--------------------------------------------------------------------------------