Skip to content

Commit

Permalink
feat: Add StyleBuilder
Browse files Browse the repository at this point in the history
  • Loading branch information
stephannv committed Sep 13, 2024
1 parent eff2d9b commit 4e86405
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 0 deletions.
119 changes: 119 additions & 0 deletions spec/blueprint/html/building_style_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
require "../../spec_helper"

private class ButtonComponent
include Blueprint::HTML

style_builder do
base "btn"

variants do
color {
blue "btn-blue"
red "btn-red"
}

size {
xs "btn-xs"
md "btn-md"
lg "btn-lg"
}

outline {
yes "btn-outline"
}

ghost {
yes "btn-ghost"
no "btn-normal"
}

clicked {
yes "btn-clicked"
no "btn-unclick"
}
end

defaults color: :blue, size: :md
end

private def blueprint(&)
a class: build_style(size: :xs, outline: true) do
yield
end
end
end

private class SingleVariantStyle
include Blueprint::HTML

style_builder do
variants do
color {
red "red"
blue "blue"
}
end
end
end

describe "style building" do
it "allows defining a base class" do
classes = ButtonComponent.build_style

classes.starts_with?("btn ").should be_true
end

it "allows definig default variant options" do
classes = ButtonComponent.build_style

classes.should contain "btn-blue btn-md"
end

it "allows picking variants" do
classes = ButtonComponent.build_style(color: :red, size: :lg)

classes.should eq "btn btn-red btn-lg"
end

it "allows boolean values for yes/no variants" do
classes = ButtonComponent.build_style(outline: :yes, ghost: false, clicked: true)

classes.should contain "btn-outline btn-normal btn-clicked"
end

it "raises error passing an invalid variant" do
msg = "`shadow` variant was not defined inside style_builder `variants` block."
expect_raises(Blueprint::HTML::StyleBuilder::InvalidVariantError, msg) do
ButtonComponent.build_style(shadow: :lg)
end
end

it "raises error passing an invalid variant option" do
msg = "`yellow` is an invalid option for color variant. Valid options are [:blue, :red]."
bool_msg = "`confirm` is an invalid option for ghost variant. Valid options are [:yes, true, :no, false]."

expect_raises(Blueprint::HTML::StyleBuilder::InvalidVariantOptionError, msg) do
ButtonComponent.build_style(color: :yellow)
end

expect_raises(Blueprint::HTML::StyleBuilder::InvalidVariantOptionError, bool_msg) do
ButtonComponent.build_style(ghost: :confirm)
end
end

it "allows to build style inside components" do
actual_html = ButtonComponent.new.to_html { "Build Style!" }
expected_html = normalize_html <<-HTML
<a class="btn btn-xs btn-outline btn-blue">Build Style!</a>
HTML

actual_html.should eq expected_html
end

# this tests that style_builder macro works without base, defaults or more variants
it "allows to define style build with a single variant" do
SingleVariantStyle.build_style.should eq ""

SingleVariantStyle.build_style(color: :red).should eq "red"
end
end
2 changes: 2 additions & 0 deletions src/blueprint/html.cr
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require "./html/element_renderer"
require "./html/helpers"
require "./html/renderer"
require "./html/standard_elements"
require "./html/style_builder"
require "./html/svg"
require "./html/utils"

Expand All @@ -21,6 +22,7 @@ module Blueprint::HTML
include Blueprint::HTML::Helpers
include Blueprint::HTML::Renderer
include Blueprint::HTML::StandardElements
include Blueprint::HTML::StyleBuilder
include Blueprint::HTML::SVG
include Blueprint::HTML::Utils

Expand Down
131 changes: 131 additions & 0 deletions src/blueprint/html/style_builder.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
module Blueprint::HTML::StyleBuilder
class InvalidVariantError < Exception; end

class InvalidVariantOptionError < Exception; end

macro build_style(**styles)
([
{% if @type.class.has_method?(:style_builder_base_classes) %}
{{@type}}.style_builder_base_classes,
{% end %}

{% for variant, option in styles %}
{% if @type.class.has_method?("style_builder_#{variant}_variant") %}
{{@type}}.style_builder_{{variant}}_variant({{option}}),
{% else %}
raise({{@type}}::InvalidVariantError.new("`{{variant}}` variant was not defined inside style_builder `variants` block.")),
{% end %}
{% end %}

{% if @type.has_constant?("STYLE_BUILDER_DEFAULT_VARIANT_OPTIONS") %}
{% used_variants = styles.keys %}
{% for variant, option in STYLE_BUILDER_DEFAULT_VARIANT_OPTIONS %}
{% unless used_variants.includes?(variant) %}
{{@type}}.style_builder_{{variant}}_variant({{option}}),
{% end %}
{% end %}
{% end %}
] of String).join(" ")
end

macro style_builder(&block)
__parse_style_builer_top_level_body__ do
{{ block.body }}
end
end

private macro __parse_style_builer_top_level_body__(&block)
{% node = block.body %}

{% if node.is_a?(Expressions) %}
{% for expressions in node.expressions %}
__parse_style_builer_top_level_node__ { {{ expressions }} }
{% end %}
{% elsif node.is_a?(Call) %}
__parse_style_builer_top_level_node__ { {{ node }} }
{% end %}
end

private macro __parse_style_builer_top_level_node__(&block)
{% node = block.body %}

{% if node.name == "base" %}
__parse_style_builder_base_node__ { {{ node }} }
{% elsif node.name == "variants" %}
{% variants_node = node.block.body %}
{% if variants_node.is_a?(Expressions) %}
{% for expression in variants_node.expressions %}
__parse_style_builder_variant_node__ { {{ expression }} }
{% end %}
{% elsif variants_node.is_a?(Call) %}
__parse_style_builder_variant_node__ { {{ variants_node }} }
{% end %}
{% elsif node.name == "defaults" %}
__parse_style_builder_defaults_node__ { {{ node }} }
{% end %}
end

private macro __parse_style_builder_base_node__(&block)
{% node = block.body %}

def self.style_builder_base_classes
{{ node.args.join(" ") }}
end
end

private macro __parse_style_builder_defaults_node__(&block)
{% node = block.body %}

STYLE_BUILDER_DEFAULT_VARIANT_OPTIONS = {
{% for named_argument in node.named_args %}
{{named_argument.name.id}}: {{named_argument.value}},
{% end %}
}
end

private macro __parse_style_builder_variant_node__(&block)
{% node = block.body %}
{% body = node.block.body %}

def self.style_builder_{{node.name}}_variant(value : Symbol | Bool) : String
{% valid_options = [] of Symbol | Bool %}
case value
{% if body.is_a?(Call) %}
{% valid_options << body.name.symbolize %}

{% if body.name.id == "yes" %}
{% valid_options << true %}
when :{{body.name.id}}, true
{% elsif body.name.id == "no" %}
{% valid_options << false %}
when :{{body.name.id}}, false
{% else %}
when :{{body.name.id}}
{% end %}

{{ body.args.join(" ") }}
{% elsif body.is_a?(Expressions) %}
{% for expression in body.expressions %}
{% valid_options << expression.name.symbolize %}

{% if expression.name.id == "yes" %}
{% valid_options << true %}
when :{{expression.name.id}}, true
{% elsif expression.name.id == "no" %}
{% valid_options << false %}
when :{{expression.name.id}}, false
{% else %}
when :{{expression.name.id}}
{% end %}

{{ expression.args.join(" ") }}
{% end %}
{% end %}
else
raise InvalidVariantOptionError.new <<-TXT
`#{value}` is an invalid option for {{node.name}} variant. Valid options are {{valid_options}}.
TXT
end
end
end
end

0 comments on commit 4e86405

Please sign in to comment.