diff --git a/README.md b/README.md index 557aa2f..f874c44 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,33 @@ class Spacing < CSS::Stylesheet end ``` +### Modern Patterns (`clamp`, `@supports`, `@layer`) + +Use helpers for common modern CSS patterns while keeping the existing DSL style. + +```crystal +class Modern < CSS::Stylesheet + rule h1 do + font_size clamp(1.rem, calc(2.vw + 1.rem), 3.rem) + end + + supports(decl(:display, :grid) & decl(:gap, 1.rem)) do + rule ".grid" do + display :grid + gap 1.rem + end + end + + layer_order :reset, :base + + layer :base do + rule body do + margin 0 + end + end +end +``` + ### Backgrounds and Gradients Compose layered backgrounds, linear/radial gradients, and opacity values with readable builders. diff --git a/spec/clamp_spec.cr b/spec/clamp_spec.cr new file mode 100644 index 0000000..a90f03d --- /dev/null +++ b/spec/clamp_spec.cr @@ -0,0 +1,19 @@ +require "./spec_helper" + +class ClampStyle < CSS::Stylesheet + rule div do + width clamp(0, 50.percent, 100.px) + end +end + +describe "ClampStyle.to_s" do + it "should render clamp() function values" do + expected = <<-CSS + div { + width: clamp(0, 50%, 100px); + } + CSS + + ClampStyle.to_s.should eq(expected) + end +end diff --git a/spec/layer_spec.cr b/spec/layer_spec.cr new file mode 100644 index 0000000..01882de --- /dev/null +++ b/spec/layer_spec.cr @@ -0,0 +1,55 @@ +require "./spec_helper" + +class LayerStyle < CSS::Stylesheet + layer_order :reset, :base_layer + + layer :base_layer do + rule body do + margin 0 + end + + supports(decl(:display, :grid)) do + rule ".grid" do + display :grid + end + end + end + + media(max_width 600.px) do + layer do + rule body do + margin 10.px + end + end + end +end + +describe "LayerStyle.to_s" do + it "should return the correct CSS" do + expected = <<-CSS + @layer reset, base-layer; + + @layer base-layer { + body { + margin: 0; + } + + @supports (display: grid) { + .grid { + display: grid; + } + } + } + + @media (max-width: 600px) { + @layer { + body { + margin: 10px; + } + } + } + CSS + + LayerStyle.to_s.should eq(expected) + end +end diff --git a/spec/modern_patterns_spec.cr b/spec/modern_patterns_spec.cr new file mode 100644 index 0000000..f9d52a2 --- /dev/null +++ b/spec/modern_patterns_spec.cr @@ -0,0 +1,61 @@ +require "./spec_helper" + +class ModernPatternsStyle < CSS::Stylesheet + rule h1 do + font_size clamp(1.rem, calc(2.vw + 1.rem), 3.rem) + end + + supports(decl(:display, :grid) & decl(:gap, 1.rem)) do + rule div do + display :grid + gap 1.rem + end + + media(max_width 600.px) do + rule div do + display :block + end + end + end + + layer_order :reset, :base + + layer :base do + rule body do + margin 0 + end + end +end + +describe "ModernPatternsStyle.to_s" do + it "should return the correct CSS" do + expected = <<-CSS + h1 { + font-size: clamp(1rem, calc(2vw + 1rem), 3rem); + } + + @supports (display: grid) and (gap: 1rem) { + div { + display: grid; + gap: 1rem; + } + + @media (max-width: 600px) { + div { + display: block; + } + } + } + + @layer reset, base; + + @layer base { + body { + margin: 0; + } + } + CSS + + ModernPatternsStyle.to_s.should eq(expected) + end +end diff --git a/spec/supports_spec.cr b/spec/supports_spec.cr new file mode 100644 index 0000000..0795526 --- /dev/null +++ b/spec/supports_spec.cr @@ -0,0 +1,75 @@ +require "./spec_helper" + +class SupportsStyle < CSS::Stylesheet + supports(selector("a > b")) do + rule a do + color :red + end + end + + supports(raw("(display: grid)")) do + rule div do + display :grid + end + end + + supports(group(decl(:display, :grid) | decl(:display, :flex)) & decl(:gap, 1.rem)) do + rule ".x" do + opacity 1 + end + end + + supports(negate(decl(:display, :grid) & decl(:gap, 1.rem))) do + rule ".n" do + display :block + end + end + + supports(decl(:display, :grid)) do + supports(decl(:gap, 1.rem)) do + rule div do + gap 1.rem + end + end + end +end + +describe "SupportsStyle.to_s" do + it "should return the correct CSS" do + expected = <<-CSS + @supports selector(a > b) { + a { + color: red; + } + } + + @supports (display: grid) { + div { + display: grid; + } + } + + @supports ((display: grid) or (display: flex)) and (gap: 1rem) { + .x { + opacity: 1; + } + } + + @supports not ((display: grid) and (gap: 1rem)) { + .n { + display: block; + } + } + + @supports (display: grid) { + @supports (gap: 1rem) { + div { + gap: 1rem; + } + } + } + CSS + + SupportsStyle.to_s.should eq(expected) + end +end diff --git a/src/css/clamp_function_call.cr b/src/css/clamp_function_call.cr new file mode 100644 index 0000000..7a33b96 --- /dev/null +++ b/src/css/clamp_function_call.cr @@ -0,0 +1,35 @@ +require "./function_call" + +module CSS + # Represents a `clamp()` function call. + # + # Typically used with length/percentage values (for example for responsive font sizes). + struct ClampFunctionCall + include FunctionCall + + getter min : String + getter preferred : String + getter max : String + + def initialize(min, preferred, max) + @min = format(min) + @preferred = format(preferred) + @max = format(max) + end + + def function_name : String + "clamp" + end + + def arguments : String + "#{min}, #{preferred}, #{max}" + end + + private def format(value) : String + return value if value.is_a?(String) + return value.to_css_value.to_s if value.responds_to?(:to_css_value) + + value.to_s + end + end +end diff --git a/src/css/layer_stylesheet.cr b/src/css/layer_stylesheet.cr new file mode 100644 index 0000000..d3a0b19 --- /dev/null +++ b/src/css/layer_stylesheet.cr @@ -0,0 +1,60 @@ +require "../stylesheet" + +module CSS + abstract class LayerStylesheet < CSS::Stylesheet + module ClassMethods + abstract def layer_name : String? + end + + extend ClassMethods + + def self.format_layer_name(name : String) : String + name + end + + def self.format_layer_name(name : Symbol) : String + name.to_s.gsub('_', '-') + end + + macro inherited + macro finished + def self.to_s(io : IO) + io << "@layer" + if (name = layer_name) + io << " " + io << name + end + io << " {\n" + previous_def + io << "\n}" + end + end + end + + macro rule(*selector_expressions, &blk) + def self.to_s(io : IO) + {% if @type.class.methods.map(&.name.stringify).includes?("to_s") %} + previous_def + io << "\n\n" + {% end %} + + make_rule(io, { {% for selector_expression in selector_expressions %} make_selector({{selector_expression}}), {% end %} }, 1) {{blk}} + end + end + + macro embed(klass_name) + def self.to_s(io : IO) + {% if @type.class.methods.map(&.name.stringify).includes?("to_s") %} + previous_def + io << "\n\n" + {% end %} + + %embedded = String.build do |%embedded_io| + {{klass_name}}.to_s(%embedded_io) + end + + write_indented(io, %embedded, 1) + end + end + end +end diff --git a/src/css/media_stylesheet.cr b/src/css/media_stylesheet.cr index 1386e07..fc96cee 100644 --- a/src/css/media_stylesheet.cr +++ b/src/css/media_stylesheet.cr @@ -31,5 +31,20 @@ module CSS make_rule(io, { {% for selector_expression in selector_expressions %} make_selector({{selector_expression}}), {% end %} }, 1) {{blk}} end end + + macro embed(klass_name) + def self.to_s(io : IO) + {% if @type.class.methods.map(&.name.stringify).includes?("to_s") %} + previous_def + io << "\n\n" + {% end %} + + %embedded = String.build do |%embedded_io| + {{klass_name}}.to_s(%embedded_io) + end + + write_indented(io, %embedded, 1) + end + end end end diff --git a/src/css/supports_condition.cr b/src/css/supports_condition.cr new file mode 100644 index 0000000..0acc029 --- /dev/null +++ b/src/css/supports_condition.cr @@ -0,0 +1,149 @@ +module CSS + abstract class SupportsCondition + def &(other : SupportsCondition) : SupportsCondition + SupportsAndCondition.new(self, other) + end + + def |(other : SupportsCondition) : SupportsCondition + SupportsOrCondition.new(self, other) + end + + def negate : SupportsCondition + SupportsNotCondition.new(self) + end + + def grouped : SupportsCondition + SupportsGroupedCondition.new(self) + end + + def needs_grouping? : Bool + false + end + end + + class SupportsDeclarationCondition < SupportsCondition + getter name : String + getter value : String + + def initialize(name, value) + @name = format_ident(name) + @value = format_value(value) + end + + def to_s(io : IO) + io << "(" + io << name + io << ": " + io << value + io << ")" + end + + private def format_ident(value) : String + return value.to_s.gsub('_', '-') if value.is_a?(Symbol) + value.to_s + end + + private def format_value(value) : String + return value if value.is_a?(String) + return value.to_s.gsub('_', '-') if value.is_a?(Symbol) + return value.to_css_value.to_s if value.responds_to?(:to_css_value) + + value.to_s + end + end + + class SupportsSelectorCondition < SupportsCondition + getter selector : String + + def initialize(@selector : String) + end + + def to_s(io : IO) + io << "selector(" + io << selector + io << ")" + end + end + + class SupportsRawCondition < SupportsCondition + getter value : String + + def initialize(@value : String) + end + + def to_s(io : IO) + io << value + end + end + + class SupportsAndCondition < SupportsCondition + getter first : SupportsCondition + getter second : SupportsCondition + + def initialize(@first : SupportsCondition, @second : SupportsCondition) + end + + def needs_grouping? : Bool + true + end + + def to_s(io : IO) + io << first + io << " and " + io << second + end + end + + class SupportsOrCondition < SupportsCondition + getter first : SupportsCondition + getter second : SupportsCondition + + def initialize(@first : SupportsCondition, @second : SupportsCondition) + end + + def needs_grouping? : Bool + true + end + + def to_s(io : IO) + io << first + io << " or " + io << second + end + end + + class SupportsNotCondition < SupportsCondition + getter condition : SupportsCondition + + def initialize(@condition : SupportsCondition) + end + + def needs_grouping? : Bool + true + end + + def to_s(io : IO) + io << "not " + if condition.needs_grouping? + io << "(" + io << condition + io << ")" + else + io << condition + end + end + end + + class SupportsGroupedCondition < SupportsCondition + getter condition : SupportsCondition + + def initialize(@condition : SupportsCondition) + end + + def to_s(io : IO) + io << "(" + io << condition + io << ")" + end + end +end diff --git a/src/css/supports_condition_evaluator.cr b/src/css/supports_condition_evaluator.cr new file mode 100644 index 0000000..ec532ab --- /dev/null +++ b/src/css/supports_condition_evaluator.cr @@ -0,0 +1,34 @@ +require "./supports_condition" +require "./selector" + +module CSS + module SupportsConditionEvaluator + def self.evaluate(&) + with self yield + end + + def self.decl(name, value) : CSS::SupportsCondition + SupportsDeclarationCondition.new(name, value) + end + + def self.selector(value : CSS::Selector) : CSS::SupportsCondition + SupportsSelectorCondition.new(value.to_s) + end + + def self.selector(value : String) : CSS::SupportsCondition + SupportsSelectorCondition.new(value) + end + + def self.raw(value : String) : CSS::SupportsCondition + SupportsRawCondition.new(value) + end + + def self.group(condition : CSS::SupportsCondition) : CSS::SupportsCondition + SupportsGroupedCondition.new(condition) + end + + def self.negate(condition : CSS::SupportsCondition) : CSS::SupportsCondition + SupportsNotCondition.new(condition) + end + end +end diff --git a/src/css/supports_stylesheet.cr b/src/css/supports_stylesheet.cr new file mode 100644 index 0000000..f7e64fb --- /dev/null +++ b/src/css/supports_stylesheet.cr @@ -0,0 +1,50 @@ +require "../stylesheet" +require "./supports_condition_evaluator" + +module CSS + abstract class SupportsStylesheet < CSS::Stylesheet + module ClassMethods + abstract def supports_condition : CSS::SupportsCondition + end + + extend ClassMethods + + macro inherited + macro finished + def self.to_s(io : IO) + io << "@supports " + io << supports_condition + io << " {\n" + previous_def + io << "\n}" + end + end + end + + macro rule(*selector_expressions, &blk) + def self.to_s(io : IO) + {% if @type.class.methods.map(&.name.stringify).includes?("to_s") %} + previous_def + io << "\n\n" + {% end %} + + make_rule(io, { {% for selector_expression in selector_expressions %} make_selector({{selector_expression}}), {% end %} }, 1) {{blk}} + end + end + + macro embed(klass_name) + def self.to_s(io : IO) + {% if @type.class.methods.map(&.name.stringify).includes?("to_s") %} + previous_def + io << "\n\n" + {% end %} + + %embedded = String.build do |%embedded_io| + {{klass_name}}.to_s(%embedded_io) + end + + write_indented(io, %embedded, 1) + end + end + end +end diff --git a/src/stylesheet.cr b/src/stylesheet.cr index f3f7e21..5dad1ca 100644 --- a/src/stylesheet.cr +++ b/src/stylesheet.cr @@ -14,6 +14,7 @@ require "./css/linear_gradient_function_call" require "./css/radial_gradient_at" require "./css/radial_gradient_function_call" require "./css/min_function_call" +require "./css/clamp_function_call" require "./css/url_function_call" require "./css/transform_functions" require "./css/transform_function_call" @@ -27,6 +28,23 @@ module CSS CSS::CalcFunctionCall.new(calculation) end + # Type-safe helper for length/percentage `clamp()` values. + # + # Note: this helper enforces units for non-zero number literals (same as property setters). + macro clamp(min, preferred, max) + {% for value in [min, preferred, max] %} + {% if value.is_a?(NumberLiteral) && value != 0 %} + {{ value.raise "Non-zero number values have to be specified with a unit, for example: #{value}.px" }} + {% end %} + {% end %} + + _clamp({{min}}, {{preferred}}, {{max}}) + end + + def self._clamp(min : CSS::LengthPercentage, preferred : CSS::LengthPercentage, max : CSS::LengthPercentage) + CSS::ClampFunctionCall.new(min, preferred, max) + end + macro rule(*selector_expressions, &blk) def self.to_s(io : IO) {% if @type.class.methods.map(&.name.stringify).includes?("to_s") %} @@ -1625,6 +1643,66 @@ module CSS embed({{klass_name}}) end + macro supports(condition, &blk) + class SupportsStyle{{@caller.first.line_number}} < CSS::SupportsStylesheet + def self.supports_condition : CSS::SupportsCondition + CSS::SupportsConditionEvaluator.evaluate do + {{condition}} + end + end + + {{blk.body}} + end + + embed SupportsStyle{{@caller.first.line_number}} + end + + macro layer(name, &blk) + class LayerStyle{{@caller.first.line_number}} < CSS::LayerStylesheet + def self.layer_name : String? + CSS::LayerStylesheet.format_layer_name({{name}}) + end + + {{blk.body}} + end + + embed LayerStyle{{@caller.first.line_number}} + end + + macro layer(&blk) + class LayerStyle{{@caller.first.line_number}} < CSS::LayerStylesheet + def self.layer_name : String? + nil + end + + {{blk.body}} + end + + embed LayerStyle{{@caller.first.line_number}} + end + + macro layer_order(*names) + {% if names.empty? %} + {{ raise "layer_order requires at least one layer name" }} + {% end %} + + def self.to_s(io : IO) + {% if @type.class.methods.map(&.name.stringify).includes?("to_s") %} + previous_def + io << "\n\n" + {% end %} + + io << "@layer " + {% for name, i in names %} + io << CSS::LayerStylesheet.format_layer_name({{name}}) + {% if i < names.size - 1 %} + io << ", " + {% end %} + {% end %} + io << ";" + end + end + macro media(queries, &blk) class MediaStyle{{@caller.first.line_number}} < CSS::MediaStylesheet def self.media_queries @@ -1649,7 +1727,24 @@ module CSS {{klass_name}}.to_s(io) end end + + # Writes `content` to `io` and prefixes each non-empty line with `level` indentation steps. + protected def self.write_indented(io : IO, content : String, level : Int32) + prefix = " " * level + at_line_start = true + + content.each_char do |ch| + if at_line_start && ch != '\n' + io << prefix + end + + io << ch + at_line_start = (ch == '\n') + end + end end end require "./css/media_stylesheet" +require "./css/supports_stylesheet" +require "./css/layer_stylesheet" diff --git a/src/units.cr b/src/units.cr index f840331..7955388 100644 --- a/src/units.cr +++ b/src/units.cr @@ -170,8 +170,8 @@ module CSS alias CalcOperand = CalcNumeric | PercentValue | CalcUnit | Calculation | CalcFunctionCall alias CalcNonNumeric = Calculation | PercentValue | CalcUnit | CalcFunctionCall - alias Length = CmValue | MmValue | InValue | PxValue | PtValue | PcValue | EmValue | RemValue | ExValue | ChValue | LhValue | RlhValue | VhValue | VwValue | VmaxValue | VminValue | SvwValue | LvwValue | LvhValue | DvwValue | DvhValue | FrValue | Int32 | CalcFunctionCall + alias Length = CmValue | MmValue | InValue | PxValue | PtValue | PcValue | EmValue | RemValue | ExValue | ChValue | LhValue | RlhValue | VhValue | VwValue | VmaxValue | VminValue | SvwValue | LvwValue | LvhValue | DvwValue | DvhValue | FrValue | Int32 | CalcFunctionCall | ClampFunctionCall alias LengthPercentage = Length | PercentValue | CalcFunctionCall - alias Angle = DegValue | RadValue | GradValue | TurnValue | CalcFunctionCall - alias NumberPercentage = Int32 | Float32 | PercentValue | CalcFunctionCall + alias Angle = DegValue | RadValue | GradValue | TurnValue | CalcFunctionCall | ClampFunctionCall + alias NumberPercentage = Int32 | Float32 | PercentValue | CalcFunctionCall | ClampFunctionCall end