Skip to content

Commit cc0b0db

Browse files
committed
Add richer icon rendering
1 parent e897b67 commit cc0b0db

5 files changed

Lines changed: 178 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v0.3.0
4+
5+
- Replace SVG IDs during rendering to avoid duplicate ID collisions
6+
- Add Iconify-style dimension calculation and `1em` defaults
7+
- Add `color`, `inline`, and `mask`/`bg` render modes
8+
39
## v0.2.0
410

511
- Store discovered icons in a readable JSON manifest

README.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Most Iconify integrations load icons in JavaScript. PhoenixIconify keeps icons o
1717
- Icons are discovered from HEEx at compile time
1818
- Only icons you use are fetched and stored
1919
- Rendering is plain inline SVG
20+
- SVG IDs are rewritten to avoid duplicate gradient/mask collisions
2021
- No browser-side icon loader
2122
- Works with LiveView diffs and `phx-*` attributes
2223
- Dynamic icons can be pre-registered in config
@@ -93,6 +94,14 @@ Global attributes are forwarded to the SVG, including `phx-*`, `data-*`, and `ar
9394
<.icon name="lucide:x" class="size-4" phx-click="close" data-testid="close" />
9495
```
9596

97+
Use `color` for currentColor icon sets and `inline` when an icon should align with text:
98+
99+
```heex
100+
<span>
101+
Saved <.icon name="lucide:check" color="green" inline />
102+
</span>
103+
```
104+
96105
## Accessibility
97106

98107
Icons are decorative by default and render with `aria-hidden="true"`:
@@ -116,14 +125,15 @@ Use Tailwind's `size-*` utilities when possible:
116125
<.icon name="lucide:settings" class="size-5" />
117126
```
118127

119-
Or set SVG dimensions directly:
128+
PhoenixIconify follows Iconify's dimension behavior. Icons default to `1em` high and preserve their aspect ratio. Set one dimension and the other is calculated from the viewBox:
120129

121130
```heex
122131
<.icon name="lucide:settings" size="20" />
123-
<.icon name="lucide:settings" width="1em" height="1em" />
132+
<.icon name="lucide:settings" height="1em" />
133+
<.icon name="lucide:settings" width="unset" />
124134
```
125135

126-
## Transformations
136+
## Transformations and render modes
127137

128138
Iconify aliases can include transformations, and you can transform at render time:
129139

@@ -134,6 +144,15 @@ Iconify aliases can include transformations, and you can transform at render tim
134144
<.icon name="lucide:arrow-right" v_flip />
135145
```
136146

147+
SVG mode is the default. CSS mask/background modes are available for Iconify-style CSS rendering:
148+
149+
```heex
150+
<.icon name="lucide:settings" mode="mask" class="size-5" />
151+
<.icon name="logos:elixir" mode="bg" class="size-5" />
152+
```
153+
154+
SVG IDs are replaced automatically, so icons with gradients, masks, clip paths, or animation references can be rendered multiple times on the same page.
155+
137156
## How it works
138157

139158
1. You write `<.icon name="lucide:settings" />`

lib/phoenix_iconify.ex

Lines changed: 111 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ defmodule PhoenixIconify do
2626
attr(:size, :any, default: nil, doc: "Width and height to apply together")
2727
attr(:width, :any, default: nil, doc: "SVG width attribute")
2828
attr(:height, :any, default: nil, doc: "SVG height attribute")
29+
attr(:color, :string, default: nil, doc: "CSS color for currentColor icons")
30+
attr(:inline, :boolean, default: false, doc: "Align icon with text baseline")
31+
attr(:mode, :string, default: "svg", doc: "Render mode: svg, mask, or bg")
2932
attr(:rotate, :integer, default: 0, doc: "Additional 90-degree rotations")
3033
attr(:flip, :string, default: nil, doc: "Flip direction: horizontal, vertical, or both")
3134
attr(:h_flip, :boolean, default: false, doc: "Apply horizontal flip")
@@ -35,15 +38,17 @@ defmodule PhoenixIconify do
3538
def icon(assigns) do
3639
icon_data = get_icon(assigns.name)
3740
render_data = render_data(icon_data, assigns)
38-
svg_attrs = svg_attrs(assigns, render_data)
3941

4042
assigns =
4143
assigns
4244
|> assign(:body, render_data.body)
43-
|> assign(:svg_attrs, svg_attrs)
45+
|> assign(:svg_attrs, svg_attrs(assigns, render_data))
46+
|> assign(:span_attrs, span_attrs(assigns, render_data))
47+
|> assign(:svg_mode?, svg_mode?(assigns.mode))
4448

4549
~H"""
46-
<svg {@svg_attrs}><%= if @title do %><title><%= @title %></title><% end %><%= HTML.raw(@body) %></svg>
50+
<svg :if={@svg_mode?} {@svg_attrs}><%= if @title do %><title><%= @title %></title><% end %><%= HTML.raw(@body) %></svg>
51+
<span :if={!@svg_mode?} {@span_attrs}></span>
4752
"""
4853
end
4954

@@ -105,10 +110,13 @@ defmodule PhoenixIconify do
105110
defp render_data(%Iconify.Icon{} = icon, assigns) do
106111
{h_flip, v_flip} = flip_options(assigns.flip, assigns.h_flip, assigns.v_flip)
107112

108-
{body, viewbox} =
109-
Iconify.SVG.build_body(icon, rotate: assigns.rotate, h_flip: h_flip, v_flip: v_flip)
110-
111-
%{body: body, viewbox: viewbox}
113+
Iconify.SVG.build_data(icon,
114+
width: assigns.width || assigns.size,
115+
height: assigns.height || assigns.size,
116+
rotate: assigns.rotate,
117+
h_flip: h_flip,
118+
v_flip: v_flip
119+
)
112120
end
113121

114122
defp flip_options(flip, h_flip, v_flip) do
@@ -125,16 +133,38 @@ defmodule PhoenixIconify do
125133
xmlns: "http://www.w3.org/2000/svg",
126134
viewBox: render_data.viewbox,
127135
fill: "currentColor",
128-
class: assigns.class
136+
class: assigns.class,
137+
width: render_data.width,
138+
height: render_data.height,
139+
style: style(assigns.color, assigns.inline)
129140
}
130141

131142
base
132-
|> maybe_put(:width, assigns.width || assigns.size)
133-
|> maybe_put(:height, assigns.height || assigns.size)
143+
|> Map.merge(accessibility_attrs(assigns))
144+
|> Map.merge(assigns.rest)
145+
|> Enum.reject(fn {_key, value} -> is_nil(value) or unset_keyword?(value) end)
146+
|> Map.new()
147+
end
148+
149+
defp span_attrs(%{mode: "svg"}, _render_data), do: %{}
150+
151+
defp span_attrs(assigns, render_data) do
152+
style =
153+
assigns.mode
154+
|> String.to_existing_atom()
155+
|> span_style(render_data)
156+
|> style(assigns.color, assigns.inline)
157+
158+
%{
159+
class: assigns.class,
160+
style: style
161+
}
134162
|> Map.merge(accessibility_attrs(assigns))
135163
|> Map.merge(assigns.rest)
136164
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
137165
|> Map.new()
166+
rescue
167+
ArgumentError -> %{}
138168
end
139169

140170
defp accessibility_attrs(%{label: label, title: title})
@@ -147,8 +177,77 @@ defmodule PhoenixIconify do
147177

148178
defp accessibility_attrs(_assigns), do: %{"aria-hidden": "true"}
149179

150-
defp maybe_put(map, _key, nil), do: map
151-
defp maybe_put(map, key, value), do: Map.put(map, key, value)
180+
defp style(color, inline) do
181+
nil
182+
|> maybe_style("color", color)
183+
|> maybe_style("vertical-align", if(inline, do: "-0.125em"))
184+
end
185+
186+
defp style(style, color, inline) do
187+
style
188+
|> maybe_style("color", color)
189+
|> maybe_style("vertical-align", if(inline, do: "-0.125em"))
190+
end
191+
192+
defp span_style(:mask, render_data) do
193+
svg_url = svg_url(render_data)
194+
195+
[
196+
"display:inline-block",
197+
"width:#{format_size(render_data.width)}",
198+
"height:#{format_size(render_data.height)}",
199+
"background-color:currentColor",
200+
"mask:var(--svg) no-repeat 50% 50% / 100% 100%",
201+
"-webkit-mask:var(--svg) no-repeat 50% 50% / 100% 100%",
202+
"--svg:url(\"#{svg_url}\")"
203+
]
204+
|> Enum.join(";")
205+
end
206+
207+
defp span_style(:bg, render_data) do
208+
svg_url = svg_url(render_data)
209+
210+
[
211+
"display:inline-block",
212+
"width:#{format_size(render_data.width)}",
213+
"height:#{format_size(render_data.height)}",
214+
"background:transparent var(--svg) no-repeat 50% 50% / 100% 100%",
215+
"--svg:url(\"#{svg_url}\")"
216+
]
217+
|> Enum.join(";")
218+
end
219+
220+
defp maybe_style(style, _key, nil), do: style
221+
defp maybe_style(nil, key, value), do: "#{key}:#{value}"
222+
defp maybe_style(style, key, value), do: style <> ";#{key}:#{value}"
223+
224+
defp svg_url(render_data) do
225+
[
226+
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"#{render_data.viewbox}\" width=\"#{render_data.width}\" height=\"#{render_data.height}\">",
227+
render_data.body,
228+
"</svg>"
229+
]
230+
|> IO.iodata_to_binary()
231+
|> svg_to_url()
232+
end
233+
234+
defp svg_to_url(svg) do
235+
svg
236+
|> String.replace("%", "%25")
237+
|> String.replace("#", "%23")
238+
|> String.replace("<", "%3C")
239+
|> String.replace(">", "%3E")
240+
|> String.replace("\"", "'")
241+
|> String.replace("&", "%26")
242+
end
243+
244+
defp format_size(value) when is_number(value), do: to_string(value) <> "px"
245+
defp format_size(value), do: value
246+
247+
defp svg_mode?(mode), do: to_string(mode) == "svg"
248+
249+
defp unset_keyword?(value) when value in ["unset", "undefined", "none"], do: true
250+
defp unset_keyword?(_), do: false
152251

153252
defp handle_missing_icon(normalized, original) do
154253
maybe_warn(missing_icon_message(normalized, original))

mix.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
defmodule PhoenixIconify.MixProject do
22
use Mix.Project
33

4-
@version "0.2.0"
4+
@version "0.3.0"
55
@source_url "https://github.com/elixir-volt/phoenix_iconify"
66

77
def project do
@@ -42,7 +42,7 @@ defmodule PhoenixIconify.MixProject do
4242
if path = System.get_env("ICONIFY_PATH") do
4343
[path: path]
4444
else
45-
"~> 0.2.0"
45+
"~> 0.3.0"
4646
end
4747
end
4848

test/phoenix_iconify_test.exs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ defmodule PhoenixIconifyTest do
1717
height: 24
1818
})
1919

20+
Manifest.add_icon("lucide:gradient", %Iconify.Icon{
21+
name: "lucide:gradient",
22+
body: ~s|<defs><linearGradient id="a"></linearGradient></defs><path fill="url(#a)"/>|,
23+
width: 32,
24+
height: 16
25+
})
26+
2027
on_exit(fn -> Manifest.clear_cache() end)
2128
end
2229

@@ -34,6 +41,8 @@ defmodule PhoenixIconifyTest do
3441
assert html =~ ~s(data-testid="settings")
3542
assert html =~ ~s(<path d="M10 10"/>)
3643
assert html =~ ~s(aria-hidden="true")
44+
assert html =~ ~s(width="1em")
45+
assert html =~ ~s(height="1em")
3746
end
3847

3948
test "uses accessible label when provided" do
@@ -61,6 +70,34 @@ defmodule PhoenixIconifyTest do
6170
assert html =~ ~s(width="20")
6271
assert html =~ ~s(height="20")
6372
end
73+
74+
test "supports color, inline alignment, aspect-ratio sizing, and id replacement" do
75+
assigns = %{}
76+
77+
html =
78+
rendered_to_string(~H"""
79+
<PhoenixIconify.icon name="lucide:gradient" height="1em" color="red" inline />
80+
""")
81+
82+
assert html =~ ~s(width="2em")
83+
assert html =~ ~s(height="1em")
84+
assert html =~ ~s(style="color:red;vertical-align:-0.125em")
85+
assert html =~ ~s(id="iconify-)
86+
refute html =~ ~s(id="a")
87+
end
88+
89+
test "supports CSS mask render mode" do
90+
assigns = %{}
91+
92+
html =
93+
rendered_to_string(~H"""
94+
<PhoenixIconify.icon name="lucide:settings" mode="mask" class="size-5" />
95+
""")
96+
97+
assert html =~ ~s(<span)
98+
assert html =~ ~s(class="size-5")
99+
assert String.contains?(html, "mask" <> <<58, 118, 97, 114, 40, 45, 45, 115, 118, 103, 41>>)
100+
end
64101
end
65102

66103
describe "normalize_name/1" do

0 commit comments

Comments
 (0)