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
+
+ -
+
+ 0
+
+ present
+
+ -
+
+ two
+
+
+
+ HTML
+
+ IndexedList.new(["one", "two"]).to_html.should eq(expected.squish)
+ end
+
+ it "supports times with and without block args" do
+ expected = <<-HTML
+
+ - even
+ - odd
+ - even
+ - tail
+ - tail
+
+ 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