Skip to content

Lesson 1 — Base classes and the inheritance chain

Why a base class?

In Module 2 every component inherited directly from Phlex::HTML. That works for isolated scripts, but as soon as you have a library of components you want to share behaviour across all of them — helper methods, property declarations, a consistent inheritance root. The base class is where that shared behaviour lives.

The base class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/components/base.rb
require "phlex"
require "date"
require "literal"

module Components
  class Base < Phlex::HTML
    extend Literal::Properties

    private

    def class_names(*classes)
      classes.flatten.compact.reject(&:empty?).join(" ")
    end
  end
end

Three things are happening here:

extend Literal::Properties gives every component the prop macro for declaring its interface. Instead of writing:

1
2
3
4
def initialize(title:, variant: :primary)
  @title   = title
  @variant = variant
end

We write:

1
2
prop :title,   String
prop :variant, Symbol, default: :primary

Literal generates the initialize method, validates types at instantiation, and assigns instance variables automatically. If you pass the wrong type, Literal raises Literal::TypeError at instantiation — catching bugs at the call site rather than deep inside a template.

class_names is a private helper that builds a class string from multiple sources, filtering out nil and empty strings:

1
2
class_names("btn", "secondary", nil, "")
# => "btn secondary"

The inheritance chain every component will follow:

Phlex::HTML
  └── Components::Base        (shared behaviour)
        ├── Components::Button
        ├── Components::Badge
        ├── Components::Card
        └── ... every other component

Prop types

Literal supports any Ruby class as a type, plus built-in type constructors:

1
2
3
4
5
6
7
8
9
prop :label,    String                          # required String
prop :count,    Integer,   default: 0           # optional Integer
prop :price,    Float,     default: 0.0         # optional Float
prop :tags,     _Array(String)                  # Array of Strings
prop :metadata, Hash,      default: {}          # any Hash
prop :nickname, _Nilable(String), default: nil  # String or nil
prop :variant,  Symbol,    default: :primary
prop :disabled, Literal::Boolean, default: false
prop :level,    _Union(1, 2, 3, 4, 5, 6), default: 1

Exercise — Components::Heading

Create app/components/heading.rb. Build a Components::Heading component that accepts a text: prop (String) and a level: prop restricted to integers 1–6, defaulting to 1. It should render the correct heading tag based on the level.

Expected output:

1
2
3
4
5
puts Components::Heading.new(text: "Page title").call
# => <h1>Page title</h1>

puts Components::Heading.new(text: "Section", level: 2).call
# => <h2>Section</h2>

Hint: tag methods are just Ruby methods — call them dynamically with send(:"h#{@level}") { @text }.

Hint: use _Union(1, 2, 3, 4, 5, 6) to restrict the level to valid values. Passing level: 7 should raise Literal::TypeError.


Solution to Exercise 1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# app/components/heading.rb
require_relative "base"

module Components
  class Heading < Base
    prop :text,  String
    prop :level, _Union(1, 2, 3, 4, 5, 6), default: 1

    def view_template
      send(:"h#{@level}") { @text }
    end
  end
end

The demo page — first version

Now that we have a component, we can create demo.rb and demo.css. At this stage we use explicit render Components::Heading.new(...) syntax — we will replace this with the cleaner Kit syntax in Lesson 2.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# demo.rb
require_relative "app/components/base"
require_relative "app/components/heading"

class DemoPage < Phlex::HTML
  def view_template
    doctype
    html(lang: "en") do
      head do
        meta(charset: "utf-8")
        meta(name: "viewport", content: "width=device-width, initial-scale=1")
        title { "Phlex::UI Demo" }
        link(
          rel:  "stylesheet",
          href: "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
        )
        style { raw safe(File.read(File.join(__dir__, "demo.css"))) }
      end
      body do
        main(class: "container") do
          show_note
          show_headings
        end
      end
    end
  end

  private

  def show_note
    blockquote do
      p { strong { "Why Pico CSS?" } }
      p do
        plain  "This demo uses Pico CSS rather than Tailwind. Module 3 is " 
        plain  "about component structure — props, slots, and composition. "
        plain  "Pico gives us clean output with zero configuration so the " 
        plain  "component code stays readable. Tailwind is introduced " 
        plain  "properly in Module 4 inside a real Rails app." 
      end
    end
  end

  def show_headings
    section_header("Headings")
    render Components::Heading.new(text: "Heading level 1", level: 1)
    render Components::Heading.new(text: "Heading level 2", level: 2)
    render Components::Heading.new(text: "Heading level 3", level: 3)
  end

  def section_header(title)
    hr
    h2 { title }
  end
end

File.write("demo.html", DemoPage.new.call)
system("open demo.html")
puts "Demo written to demo.html"

Don’t forget to download You will need to download the demo CSS file

Run it:

1
ruby demo.rb

Your browser opens with three headings, correctly sized and styled by Pico. This is the beginning of the component showcase.