Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

# <div data-foo="from-hash" data-user-id="42" data-active="true" aria-label="Profile" aria-hidden="false"></div>
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.
Expand Down
2 changes: 1 addition & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: to_html
version: 1.6.0
version: 1.7.0
license: MIT
authors:
- Stefan Bilharz <stefan@sbsoftware.de>
Expand Down
83 changes: 83 additions & 0 deletions spec/instance_template/case_spec.cr
Original file line number Diff line number Diff line change
@@ -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
<p>A</p>
<div>
<strong>
inner-a
</strong>
</div>
HTML

MyView.new(Kind::A).to_html.should eq(expected.squish)
end

it "renders the correct HTML for Kind::B" do
expected = <<-HTML
<div>
<span>
B
</span>
</div>
<div>
<em>
inner-not-a
</em>
</div>
HTML

MyView.new(Kind::B).to_html.should eq(expected.squish)
end

it "renders the correct HTML for Kind::Other" do
expected = <<-HTML
<i>Other</i>
<div>
<em>
inner-not-a
</em>
</div>
HTML

MyView.new(Kind::Other).to_html.should eq(expected.squish)
end
end
end
end
74 changes: 74 additions & 0 deletions spec/instance_template/data_aria_attributes_spec.cr
Original file line number Diff line number Diff line change
@@ -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(%(<div data-foo="bar" aria-label="X"></div>))
end

it "serializes bool and numbers while omitting nil values" do
TypeView.new.to_html.should eq(%(<div data-enabled="true" data-count="7" data-ratio="2.5" aria-hidden="false"></div>))
end

it "merges array, tuple, to_html_attrs, and named args with last-write-wins semantics" do
MergeView.new.to_html.should eq(%(<div data-foo="named-hash-last" aria-label="named-label-last" data-baz="true" aria-hidden="false" data-count="2.5" data-prefixed="normalized" aria-current="false"></div>))
end

it "normalizes symbol and string keys for data/aria hashes" do
NormalizeView.new.to_html.should eq(%(<div data-foo-bar="snake" data-foo-baz="dash" data-qux="prefixed" aria-labelled-by="target" aria-described-by="description"></div>))
end

it "flattens nested data/aria hashes and named tuples" do
NestedView.new.to_html.should eq(%(<div data-foo-blah="baz" aria-label-text="X"></div>))
end
end
end
95 changes: 95 additions & 0 deletions spec/instance_template/iterator_blocks_spec.cr
Original file line number Diff line number Diff line change
@@ -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
<ul>
<li>
<span>
0
</span>
<small>present</small>
</li>
<li>
<em>
two
</em>
</li>
</ul>
HTML

IndexedList.new(["one", "two"]).to_html.should eq(expected.squish)
end

it "supports times with and without block args" do
expected = <<-HTML
<ol>
<li>even</li>
<li>odd</li>
<li>even</li>
<li>tail</li>
<li>tail</li>
</ol>
HTML

TimesList.new.to_html.should eq(expected.squish)
end
end
end
70 changes: 70 additions & 0 deletions src/attribute_hash.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Loading