Packages
noora
0.83.0
0.86.0
0.85.0
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
lib/noora/icon.ex
defmodule Noora.Icon do
@moduledoc """
Icons in use across the Noora design system.
## Usage
The module exposes all icons in two ways:
1. As a function of the shape `<.{icon_name} />`
2. As an `icon` component that takes a `name` attribute.
```elixir
<.mail />
<.icon name="mail" />
```
## Animated transitions
`<.icon>` can animate between two icons when the `data-state` of an ancestor
changes (e.g. a dropdown trigger opening). Pass `active_name` and a
`transition` strategy:
```elixir
<.icon id="switcher" name="selector" active_name="selector_2" transition="morph"
watch="[data-part='trigger']" active_state="open" />
```
Strategies:
- `"morph"` — tween one filled path into the other. Raises at render time if
either icon is not a filled path (see `morphable?/1`).
- `"crossfade_rotate"` — crossfade and rotate the two icons. Works for any pair.
- `"auto"` (default) — morph when both icons are compatible, otherwise crossfade.
## Adding an icon
Icons are automatically read and generated from the `icons` folder. To add an icon, simply add a SVG file to the folder. File names are
required to be alphanumeric. Hyphens in the file name are automatically substituted to underscores.
"""
use Phoenix.Component
@icons_path Path.join(File.cwd!(), "lib/noora/icons")
@external_resource @icons_path
# Noora icons must be filled paths (`fill="currentColor"`) so they render and
# animate (morph) correctly. Stroked icons would render as solid blobs, so we
# fail the build if any are added.
@stroked_icons for file <- File.ls!(@icons_path),
Path.extname(file) == ".svg",
content = File.read!(Path.join(@icons_path, file)),
String.contains?(content, ~s(stroke="currentColor")),
do: Path.basename(file, ".svg")
if @stroked_icons != [] do
raise ~s|Noora icons must be filled paths (fill="currentColor"), but these are stroked: | <>
Enum.join(@stroked_icons, ", ") <>
". Convert them to filled paths so they render and animate correctly."
end
# Icons whose path is filled with `currentColor` — the set that is safe to
# morph. Everything else falls back to a crossfade transition.
@morphable_icons (for file <- File.ls!(@icons_path),
Path.extname(file) == ".svg",
content = File.read!(Path.join(@icons_path, file)),
String.contains?(content, ~s(fill="currentColor")),
into: MapSet.new() do
file |> Path.basename(".svg") |> String.replace("-", "_")
end)
def icon(%{active_name: active_name} = assigns) when is_binary(active_name) do
assigns =
assigns
|> assign_new(:id, fn -> nil end)
|> assign_new(:transition, fn -> "auto" end)
|> assign_new(:watch, fn -> nil end)
|> assign_new(:active_state, fn -> "open" end)
case resolve_transition(assigns.transition, assigns.name, active_name) do
:morph -> render_morph_icon(assigns)
:crossfade_rotate -> render_crossfade_icon(assigns)
end
end
for file <- File.ls!(@icons_path),
Path.extname(file) == ".svg" do
icon_name =
file
|> Path.basename(".svg")
|> String.replace("-", "_")
file_path = Path.join(@icons_path, file)
@external_resource file_path
content = File.read!(file_path)
def unquote(String.to_atom(icon_name))(assigns) do
assigns = assign(assigns, :content, unquote(content))
~H"""
{Phoenix.HTML.raw(@content)}
"""
end
def icon(%{name: unquote(icon_name)} = assigns) do
assigns = assign(assigns, :content, unquote(content))
~H"""
{Phoenix.HTML.raw(@content)}
"""
end
end
@icon_paths (for file <- File.ls!(@icons_path),
Path.extname(file) == ".svg",
into: %{} do
name = file |> Path.basename(".svg") |> String.replace("-", "_")
d =
@icons_path
|> Path.join(file)
|> File.read!()
|> then(&Regex.scan(~r/\bd="([^"]*)"/, &1))
|> Enum.map_join(" ", fn [_, path] -> path end)
{name, d}
end)
@doc """
Returns the concatenated SVG path data (the `d` attribute) for an icon name.
"""
def icon_path(name) when is_binary(name), do: Map.fetch!(@icon_paths, name)
def icon_path(name) when is_atom(name), do: icon_path(Atom.to_string(name))
@doc """
Whether an icon can be morphed (it is a filled-path icon). Stroked or
non-`currentColor` icons fall back to a crossfade transition.
"""
def morphable?(name) when is_binary(name), do: MapSet.member?(@morphable_icons, name)
def morphable?(name) when is_atom(name), do: morphable?(Atom.to_string(name))
# Resolves the requested transition strategy. `"morph"` requires compatible
# (filled-path) icons and raises otherwise so misuse fails loudly rather than
# rendering an incorrect shape. `"auto"` morphs when possible and crossfades
# otherwise.
defp resolve_transition("crossfade_rotate", _from, _to), do: :crossfade_rotate
defp resolve_transition("auto", from, to) do
if morphable?(from) and morphable?(to), do: :morph, else: :crossfade_rotate
end
defp resolve_transition("morph", from, to) do
incompatible = Enum.reject([from, to], &morphable?/1)
if incompatible == [] do
:morph
else
raise ArgumentError,
~s(Noora.Icon: transition="morph" only supports filled-path icons, but ) <>
Enum.map_join(incompatible, ", ", &inspect/1) <>
~s( cannot be morphed. Use transition="crossfade_rotate" or transition="auto".)
end
end
defp icon_class(name), do: "icon icon-tabler icons-tabler-outline icon-tabler-#{String.replace(name, "_", "-")}"
defp render_morph_icon(assigns) do
assigns =
assigns
|> assign(:from_path, icon_path(assigns.name))
|> assign(:to_path, icon_path(assigns.active_name))
~H"""
<svg
id={@id}
class={icon_class(@name)}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke-linejoin="round"
stroke-linecap="round"
xmlns="http://www.w3.org/2000/svg"
phx-hook="NooraIconTransition"
phx-update="ignore"
data-transition="morph"
data-watch={@watch}
data-active-state={@active_state}
data-morph-from={@from_path}
data-morph-to={@to_path}
>
<path d={@from_path} fill="currentColor" />
</svg>
"""
end
defp render_crossfade_icon(assigns) do
~H"""
<span
id={@id}
class="noora-icon-transition"
phx-hook="NooraIconTransition"
phx-update="ignore"
data-transition="crossfade_rotate"
data-watch={@watch}
data-active-state={@active_state}
>
<span data-part="icon-from"><.icon name={@name} /></span>
<span data-part="icon-to"><.icon name={@active_name} /></span>
</span>
"""
end
end