diff --git a/README.md b/README.md index 7cd897a..2ed496c 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,29 @@ end puts NamedArgumentsView.new.to_html ``` +#### `data` / `aria` Helpers + +You can define `data-*` and `aria-*` attributes via `data:` / `aria:` hashes or named tuples. +When multiple sources assign the same `data-*` / `aria-*` key, the last assignment wins. + +```crystal +require "to_html" + +class DataAriaView + ToHtml.instance_template do + div( + {"data-foo", "from-tuple"}, + data_foo: "from-explicit", + data: {foo: "from-hash", user_id: 42, active: true, ignored: nil}, + aria: {label: "Profile", hidden: false} + ) + end +end + +#
+puts DataAriaView.new.to_html +``` + #### Object Interface Another way to add attributes is via objects that implement `#to_html_attrs`. The easiest way to do so is via the `ToHtml.instance_tag_attrs`/`ToHtml.class_tag_attrs` macros. diff --git a/shard.yml b/shard.yml index 6951038..89a3494 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: to_html -version: 1.6.0 +version: 1.7.0 license: MIT authors: - Stefan Bilharz diff --git a/spec/instance_template/case_spec.cr b/spec/instance_template/case_spec.cr new file mode 100644 index 0000000..09d2cc3 --- /dev/null +++ b/spec/instance_template/case_spec.cr @@ -0,0 +1,83 @@ +require "../spec_helper" + +module ToHtml::InstanceTemplate::CaseSpec + enum Kind + A + B + Other + end + + class MyView + getter kind : Kind + + def initialize(@kind) + end + + ToHtml.instance_template do + case kind + when Kind::A + p { "A" } + when Kind::B + div do + span { "B" } + end + else + i { "Other" } + end + + div do + case + when kind == Kind::A + strong { "inner-a" } + else + em { "inner-not-a" } + end + end + end + + describe "MyView#to_html" do + it "renders the correct HTML for Kind::A" do + expected = <<-HTML +

A

+
+ + inner-a + +
+ HTML + + MyView.new(Kind::A).to_html.should eq(expected.squish) + end + + it "renders the correct HTML for Kind::B" do + expected = <<-HTML +
+ + B + +
+
+ + inner-not-a + +
+ HTML + + MyView.new(Kind::B).to_html.should eq(expected.squish) + end + + it "renders the correct HTML for Kind::Other" do + expected = <<-HTML + Other +
+ + inner-not-a + +
+ HTML + + MyView.new(Kind::Other).to_html.should eq(expected.squish) + end + end + end +end diff --git a/spec/instance_template/data_aria_attributes_spec.cr b/spec/instance_template/data_aria_attributes_spec.cr new file mode 100644 index 0000000..4a2296a --- /dev/null +++ b/spec/instance_template/data_aria_attributes_spec.cr @@ -0,0 +1,74 @@ +require "../spec_helper" + +module ToHtml::InstanceTemplate::DataAriaAttributesSpec + class BasicView + ToHtml.instance_template do + div data: {foo: "bar"}, aria: {label: "X"} + end + end + + class TypeView + ToHtml.instance_template do + div data: {enabled: true, count: 7, ratio: 2.5, ignored: nil}, aria: {hidden: false, current: nil} + end + end + + class ProviderAttrs + ToHtml.class_tag_attrs do + data = {foo: "provider-hash", bar: 1} + aria = {label: "provider-label"} + data_foo = "provider-explicit" + aria_label = "provider-explicit-label" + end + end + + class MergeView + ToHtml.instance_template do + attrs = [ + ProviderAttrs, + {data: {foo: "array-hash", baz: true}, aria: {label: "array-label", hidden: false}}, + ] + + div attrs, {"data-foo", "tuple-explicit"}, {"aria-label", "tuple-explicit-label"}, + data_foo: "named-explicit", + aria_label: "named-explicit-label", + data: {"foo" => "named-hash-last", "count" => 2.5, "bar" => nil, "data-prefixed" => "normalized"}, + aria: {"label" => "named-label-last", "current" => false, "omitted" => nil} + end + end + + class NormalizeView + ToHtml.instance_template do + div data: {"foo_bar" => "snake", :"foo-baz" => "dash", :"data-qux" => "prefixed"}, + aria: {"labelled_by" => "target", :"aria-described-by" => "description"} + end + end + + class NestedView + ToHtml.instance_template do + div data: {foo: {blah: "baz"}}, aria: {label: {text: "X"}} + end + end + + describe "data/aria attribute expansion" do + it "expands data and aria named tuple attributes" do + BasicView.new.to_html.should eq(%(
)) + end + + it "serializes bool and numbers while omitting nil values" do + TypeView.new.to_html.should eq(%(
)) + end + + it "merges array, tuple, to_html_attrs, and named args with last-write-wins semantics" do + MergeView.new.to_html.should eq(%(
)) + end + + it "normalizes symbol and string keys for data/aria hashes" do + NormalizeView.new.to_html.should eq(%(
)) + end + + it "flattens nested data/aria hashes and named tuples" do + NestedView.new.to_html.should eq(%(
)) + end + end +end diff --git a/spec/instance_template/iterator_blocks_spec.cr b/spec/instance_template/iterator_blocks_spec.cr new file mode 100644 index 0000000..303e61b --- /dev/null +++ b/spec/instance_template/iterator_blocks_spec.cr @@ -0,0 +1,95 @@ +require "../spec_helper" + +module ToHtml::InstanceTemplate::IteratorBlocksSpec + class IndexedItem + getter value : String + + def initialize(@value) + end + + ToHtml.instance_template do + em { value } + end + end + + class IndexedList + getter items : Array(String) + + def initialize(@items) + end + + ToHtml.instance_template do + ul do + items.each_with_index do |item, idx| + li do + if idx.even? + span { idx.to_s } + else + IndexedItem.new(item) + end + + unless idx == items.size - 1 + small { "present" } + end + end + end + end + end + end + + class TimesList + ToHtml.instance_template do + ol do + 3.times do |i| + li do + if i.even? + "even" + else + "odd" + end + end + end + + 2.times do + li { "tail" } + end + end + end + end + + describe "iterator blocks" do + it "supports each_with_index with nested template control flow" do + expected = <<-HTML + + HTML + + IndexedList.new(["one", "two"]).to_html.should eq(expected.squish) + end + + it "supports times with and without block args" do + expected = <<-HTML +
    +
  1. even
  2. +
  3. odd
  4. +
  5. even
  6. +
  7. tail
  8. +
  9. tail
  10. +
+ HTML + + TimesList.new.to_html.should eq(expected.squish) + end + end +end diff --git a/src/attribute_hash.cr b/src/attribute_hash.cr index 9a1b288..17a01fd 100644 --- a/src/attribute_hash.cr +++ b/src/attribute_hash.cr @@ -10,12 +10,30 @@ module ToHtml end def []=(key, value : Bool) + key = key.to_s + + if prefixed_key = normalize_explicit_prefixed_key(key) + attributes[prefixed_key] = value.to_s + return + end + return unless value boolean_attributes << key end def []=(key, value) + key = key.to_s + + if prefixed_key = normalize_explicit_prefixed_key(key) + assign_prefixed_value(prefixed_key, value) + return + end + + if key == "data" || key == "aria" + return if assign_prefixed_hash(key, value) + end + if attributes.has_key?(key) attributes[key] += " #{value}" if value else @@ -38,5 +56,57 @@ module ToHtml io << " " if boolean_attributes.any? boolean_attributes.join(io, " ") end + + private def assign_prefixed_hash(prefix : String, value : Hash | NamedTuple) : Bool + prefix_with_separator = "#{prefix}-" + value.each do |raw_key, raw_value| + key = raw_key.to_s.strip.gsub("_", "-") + next if key.empty? || key == prefix + + if key.starts_with?(prefix_with_separator) + next if key.size == prefix_with_separator.size + + assign_prefixed_value(key, raw_value) + else + assign_prefixed_value("#{prefix_with_separator}#{key}", raw_value) + end + end + + true + end + + private def assign_prefixed_hash(_prefix : String, _value) : Bool + false + end + + private def normalize_explicit_prefixed_key(key : String) : String? + key = key.gsub("_", "-") + + if key.starts_with?("data-") + return if key == "data-" + key + elsif key.starts_with?("aria-") + return if key == "aria-" + key + end + end + + # Nested data/aria maps are flattened recursively into hyphen-separated keys. + private def assign_prefixed_value(key : String, value : Hash | NamedTuple) + value.each do |raw_key, raw_value| + nested_key_part = raw_key.to_s.strip.gsub("_", "-") + next if nested_key_part.empty? + + assign_prefixed_value("#{key}-#{nested_key_part}", raw_value) + end + end + + private def assign_prefixed_value(key : String, _value : Nil) + attributes.delete(key) + end + + private def assign_prefixed_value(key : String, value) + attributes[key] = value.to_s + end end end diff --git a/src/instance_template.cr b/src/instance_template.cr index c02dcef..4f05b9b 100644 --- a/src/instance_template.cr +++ b/src/instance_template.cr @@ -83,14 +83,44 @@ module ToHtml ToHtml.to_html_add_tag({{io}}, {{indent_level}}, {{break_line}}, {{blk.body}}) {% elsif blk.body.is_a?(Call) && blk.body.receiver.nil? && blk.body.name.stringify == "doctype" %} {{io}} << "" - {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.name.stringify == "each" %} - {{blk.body.receiver}}.each_with_index do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}, %index| {% end %} + {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.block && blk.body.name.stringify == "each" %} + {% if flag?(:to_html_pretty) %} + %to_html_each_break_line = false + {% end %} + {{blk.body.receiver}}.each do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}| {% end %} + {% if flag?(:to_html_pretty) %} + {{io}} << "\n" if %to_html_each_break_line + %to_html_each_break_line = true + {% end %} ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do {{blk.body.block.body}} end + end + {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.block && blk.body.name.stringify == "each_with_index" %} + {% if flag?(:to_html_pretty) %} + %to_html_each_with_index_break_line = false + {% end %} + {{blk.body.receiver}}.each_with_index do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}| {% end %} {% if flag?(:to_html_pretty) %} - {{io}} << "\n" unless %index == {{blk.body.receiver}}.size - 1 + {{io}} << "\n" if %to_html_each_with_index_break_line + %to_html_each_with_index_break_line = true {% end %} + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{blk.body.block.body}} + end + end + {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.block && blk.body.name.stringify == "times" %} + {% if flag?(:to_html_pretty) %} + %to_html_times_break_line = false + {% end %} + {{blk.body.receiver}}.times do {% if !blk.body.block.args.empty? %} |{{blk.body.block.args.splat}}| {% end %} + {% if flag?(:to_html_pretty) %} + {{io}} << "\n" if %to_html_times_break_line + %to_html_times_break_line = true + {% end %} + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{blk.body.block.body}} + end end {% elsif blk.body.is_a?(Call) && blk.body.receiver && blk.body.name.stringify == "to_html" && blk.body.block %} {{blk.body.receiver}}.to_html({{io}}, {{indent_level}}) do |%io, %indent_level| @@ -130,6 +160,27 @@ module ToHtml {% end %} {% end %} end + {% elsif blk.body.is_a?(Case) %} + case {{blk.body.cond}} + {% for w in blk.body.whens %} + when {{w.conds.splat}} + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{w.body}} + end + {% if flag?(:to_html_pretty) && break_line %} + {{io}} << "\n" + {% end %} + {% end %} + {% if !blk.body.else.is_a?(Nop) %} + else + ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do + {{blk.body.else}} + end + {% if flag?(:to_html_pretty) && break_line %} + {{io}} << "\n" + {% end %} + {% end %} + end {% elsif blk.body.is_a?(MacroIf) %} \{% if {{blk.body.cond}} %} ToHtml.to_html_eval_exps({{io}}, {{indent_level}}) do @@ -251,7 +302,7 @@ module ToHtml %attr_hash[{{arg}}.first] = {{arg}}.last {% else %} %arg = {{arg}} - if %arg.is_a?(Array) + if %arg.is_a?(Array) || %arg.is_a?(Tuple) %arg.each do |item| item.to_html_attrs({{call.name.stringify}}, %attr_hash) end