Redo Liliform for clarity

This commit is contained in:
Harshad Sharma 2024-05-15 01:28:42 +05:30
parent beea7b0caf
commit 707111fdac
30 changed files with 1348 additions and 1403 deletions

View file

@ -4,6 +4,8 @@ defmodule Freedive.Colorhash do
""" """
alias Freedive.Features alias Freedive.Features
@seed "Free_ive"
@default_hsl {0, 0, 0} @default_hsl {0, 0, 0}
def hsl(input, css: true) do def hsl(input, css: true) do
@ -13,7 +15,7 @@ defmodule Freedive.Colorhash do
def hsl(input) do def hsl(input) do
if Features.enabled?(:colorhash) do if Features.enabled?(:colorhash) do
input = String.downcase(input) input = String.downcase(input) <> @seed
hash = :erlang.phash2(input, 2_147_483_647) hash = :erlang.phash2(input, 2_147_483_647)
hue = calculate_hue(hash) hue = calculate_hue(hash)
@ -35,7 +37,7 @@ defmodule Freedive.Colorhash do
# Ranges to exclude (yellow to light green, cyan) # Ranges to exclude (yellow to light green, cyan)
excluded_ranges = [ excluded_ranges = [
# yellow # yellow
{50, 70}, {40, 80},
# cyan # cyan
{175, 185}, {175, 185},
] ]

View file

@ -53,7 +53,7 @@ defmodule FreediveWeb do
quote do quote do
use Phoenix.LiveView, use Phoenix.LiveView,
layout: {FreediveWeb.Layouts, :app}, layout: {FreediveWeb.Layouts, :app},
global_prefixes: ["is-", "has-", "flex-", "justify-", "align-"] global_prefixes: ["is-", "has-"]
unquote(html_helpers()) unquote(html_helpers())
end end
@ -69,7 +69,7 @@ defmodule FreediveWeb do
def html do def html do
quote do quote do
use Phoenix.Component, global_prefixes: ["is-", "has-", "flex-", "justify-", "align-"] use Phoenix.Component, global_prefixes: ["is-", "has-"]
# Import convenience functions from controllers # Import convenience functions from controllers
import Phoenix.Controller, import Phoenix.Controller,
@ -86,8 +86,9 @@ defmodule FreediveWeb do
import Phoenix.HTML import Phoenix.HTML
# Core UI components and translation # Core UI components and translation
import FreediveWeb.CoreComponents, only: [header: 1] import FreediveWeb.CoreComponents, only: [header: 1]
import Liliform.Components
import FreediveWeb.Gettext import FreediveWeb.Gettext
# Import custom components
use Liliform
# Shortcut for generating JS commands # Shortcut for generating JS commands
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS

View file

@ -12,45 +12,73 @@
</.navbar_brand> </.navbar_brand>
<.navbar_menu id="navbar_top" class="mr-1"> <.navbar_menu id="navbar_top" class="mr-1">
<%!-- <%= if @current_user do %> <%= if @current_user do %>
<.navbar_start> <.navbar_start>
<.navbar_item has-dropdown is-hoverable> <.navbar_item has-dropdown is-hoverable>
<.navbar_link> <.navbar_link>
<.icon for="compute" aria-hidden="true">
Compute Compute
</.icon>
</.navbar_link> </.navbar_link>
<.navbar_dropdown> <.navbar_dropdown>
<.navbar_item> <.link href={~p"/compute/apps"} class="navbar-item">
<.link>Bastille Jails</.link> Apps
</.navbar_item> </.link>
<.link href={~p"/compute/tasks"} class="navbar-item">
Tasks
</.link>
</.navbar_dropdown> </.navbar_dropdown>
</.navbar_item> </.navbar_item>
<.navbar_item has-dropdown is-hoverable> <.navbar_item has-dropdown is-hoverable>
<.navbar_link> <.navbar_link>
<.icon for="storage" aria-hidden="true">
Storage Storage
</.icon>
</.navbar_link> </.navbar_link>
<.navbar_dropdown> <.navbar_dropdown>
<.navbar_item> <.link href={~p"/storage/local"} class="navbar-item">
<.link>Shared Folders</.link> Local
</.navbar_item> </.link>
<.link href={~p"/storage/shared"} class="navbar-item">
Shared
</.link>
<.link href={~p"/storage/remote"} class="navbar-item">
Remote
</.link>
</.navbar_dropdown> </.navbar_dropdown>
</.navbar_item> </.navbar_item>
<.navbar_item has-dropdown is-hoverable> <.navbar_item has-dropdown is-hoverable>
<.navbar_link> <.navbar_link>
<.icon for="network" aria-hidden="true">
Network Network
</.icon>
</.navbar_link> </.navbar_link>
<.navbar_dropdown> <.navbar_dropdown>
<.navbar_item> <.link href={~p"/network/public"} class="navbar-item">
<.link>Private Network</.link> Public
</.navbar_item> </.link>
<.link href={~p"/network/private"} class="navbar-item">
Private
</.link>
<.navbar_divider />
<.link href={~p"/network/endpoints"} class="navbar-item">
Endpoints
</.link>
</.navbar_dropdown> </.navbar_dropdown>
</.navbar_item> </.navbar_item>
</.navbar_start> </.navbar_start>
<% end %> --%> <% end %>
<.navbar_end> <.navbar_end>
<%= if @current_user do %> <%= if @current_user do %>
@ -60,30 +88,20 @@
<.navbar_item has-dropdown is-hoverable> <.navbar_item has-dropdown is-hoverable>
<.navbar_link> <.navbar_link>
<span class="icon mr-2"> <.icon for="system" aria-hidden="true">
<Lucideicons.bot aria-hidden="true" />
</span>
System System
</.icon>
</.navbar_link> </.navbar_link>
<.navbar_dropdown> <.navbar_dropdown>
<.link href={~p"/updates"} class="navbar-item"> <.link href={~p"/updates"} class="navbar-item">
<span class="icon mr-2">
<Lucideicons.hard_drive_download aria-hidden="true" />
</span>
Software updates Software updates
</.link> </.link>
<.navbar_divider /> <.navbar_divider />
<.link href={~p"/packages"} class="navbar-item"> <.link href={~p"/packages"} class="navbar-item">
<span class="icon mr-2">
<Lucideicons.package aria-hidden="true" />
</span>
Packages Packages
</.link> </.link>
<.link href={~p"/services"} class="navbar-item"> <.link href={~p"/services"} class="navbar-item">
<span class="icon mr-2">
<Lucideicons.puzzle aria-hidden="true" />
</span>
Services Services
</.link> </.link>
</.navbar_dropdown> </.navbar_dropdown>
@ -91,29 +109,22 @@
<% end %> <% end %>
<.navbar_item has-dropdown is-hoverable> <.navbar_item has-dropdown is-hoverable>
<.navbar_link> <.navbar_link>
<span class="icon mr-2"> <.icon for="account" aria-hidden="true">
<Lucideicons.circle_user aria-hidden="true" />
</span>
Account Account
</.icon>
</.navbar_link> </.navbar_link>
<.navbar_dropdown> <.navbar_dropdown>
<%= if @current_user do %> <%= if @current_user do %>
<.link href={~p"/users/settings"} class="navbar-item"> <.link href={~p"/users/settings"} class="navbar-item">
<span class="icon mr-2">
<Lucideicons.user_cog aria-hidden="true" />
</span>
Settings Settings
</.link> </.link>
<.link href={~p"/users/log_out"} method="delete" class="navbar-item"> <.link href={~p"/users/log_out"} method="delete" class="navbar-item">
<span class="icon mr-2">
<Lucideicons.log_out aria-hidden="true" />
</span>
Log out Log out
</.link> </.link>
<% else %> <% else %>
<.link href={~p"/users/log_in"} class="navbar-item"> <.link href={~p"/users/log_in"} class="navbar-item">
<Lucideicons.log_in aria-hidden="true" /> Log in Log in
</.link> </.link>
<% end %> <% end %>
</.navbar_dropdown> </.navbar_dropdown>

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
defmodule FreediveWeb.HomeLive do defmodule FreediveWeb.HomeLive do
use FreediveWeb, :live_view use FreediveWeb, :live_view
import Freedive.Colorhash
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -15,19 +14,21 @@ defmodule FreediveWeb.HomeLive do
<a>System</a> <a>System</a>
</.panel_tabs> </.panel_tabs>
<.panel_tabs is-hidden-tablet> <.panel_tabs is-hidden-tablet>
<a class="is-active"> <a class="is-active" title="All">
<Lucideicons.infinity style={"color: #{hsl("all", css: true)}"} aria-hidden="true" /> <.icon for="infinity" color="auto" />
</a> </a>
<a> <a title="Compute">
<Lucideicons.binary style={"color: #{hsl("compute", css: true)}"} aria-hidden="true" /> <.icon for="binary" color="auto" />
</a> </a>
<a> <a title="Storage">
<Lucideicons.hard_drive style={"color: #{hsl("storage", css: true)}"} aria-hidden="true" /> <.icon for="hard-drive" color="auto" />
</a> </a>
<a> <a title="Network">
<Lucideicons.earth style={"color: #{hsl("network", css: true)}"} aria-hidden="true" /> <.icon for="earth" color="auto" />
</a>
<a title="System">
<.icon for="bot" color="auto" />
</a> </a>
<a><Lucideicons.bot style={"color: #{hsl("system", css: true)}"} aria-hidden="true" /></a>
</.panel_tabs> </.panel_tabs>
<.panel_block> <.panel_block>
@ -44,41 +45,23 @@ defmodule FreediveWeb.HomeLive do
</span> </span>
</.control> </.control>
</.panel_block> </.panel_block>
<.link patch={~p"/users/settings"} class="panel-block pt-1">
<span class="panel-icon">
<Lucideicons.user_cog
style={"color: #{hsl("account settings", css: true)}"}
aria-hidden="true"
/>
</span>
<span class="mt-2 ml-2">Account settings</span>
</.link>
<.link patch={~p"/services"} class="panel-block pt-1"> <.link patch={~p"/services"} class="panel-block pt-1">
<span class="panel-icon"> <span class="panel-icon">
<Lucideicons.puzzle <.icon for="puzzle" color="auto" aria-hidden="true" />
style={"color: #{hsl("system services", css: true)}"}
aria-hidden="true"
/>
</span> </span>
<div class="mt-2 ml-2">System services</div> <div class="mt-2 ml-2">Services</div>
</.link> </.link>
<.link patch={~p"/packages"} class="panel-block pt-1"> <.link patch={~p"/packages"} class="panel-block pt-1">
<span class="panel-icon"> <span class="panel-icon">
<Lucideicons.package <.icon for="package" color="auto" aria-hidden="true" />
style={"color: #{hsl("system packages", css: true)}"}
aria-hidden="true"
/>
</span> </span>
<span class="mt-2 ml-2">System packages</span> <span class="mt-2 ml-2">Packages</span>
</.link> </.link>
<.link patch={~p"/updates"} class="panel-block pt-1"> <.link patch={~p"/updates"} class="panel-block pt-1">
<span class="panel-icon"> <span class="panel-icon">
<Lucideicons.hard_drive_download <.icon for="hard-drive-download" color="auto" aria-hidden="true" />
style={"color: #{hsl("system software updates", css: true)}"}
aria-hidden="true"
/>
</span> </span>
<span class="mt-2 ml-2">System software updates</span> <span class="mt-2 ml-2">Software updates</span>
</.link> </.link>
</.panel> </.panel>
</.section> </.section>

View file

@ -69,6 +69,7 @@ defmodule FreediveWeb.Router do
live_session :require_authenticated_user, live_session :require_authenticated_user,
on_mount: [{FreediveWeb.UserAuth, :ensure_authenticated}] do on_mount: [{FreediveWeb.UserAuth, :ensure_authenticated}] do
live "/", HomeLive live "/", HomeLive
live "/liliform", LiliformLive
live "/users/settings", UserSettingsLive, :edit live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end end

24
lib/liliform.ex Normal file
View file

@ -0,0 +1,24 @@
defmodule Liliform do
defmacro __using__(_) do
quote do
import Liliform.Block
import Liliform.Box
import Liliform.Button
import Liliform.Column
import Liliform.Container
import Liliform.Content
import Liliform.Control
import Liliform.Flash
import Liliform.Hero
import Liliform.Icon
import Liliform.Input
import Liliform.Label
import Liliform.Media
import Liliform.Navbar
import Liliform.Panel
import Liliform.Section
import Liliform.SimpleForm
import Liliform.Title
end
end
end

23
lib/liliform/block.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Block do
use Liliform.Component
@doc """
Renders a block.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def block(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["block", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
end

23
lib/liliform/box.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Box do
use Liliform.Component
@doc """
Renders a box.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def box(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["box", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
end

View file

@ -0,0 +1,35 @@
defmodule Liliform.BulmaHelper do
def set_bulma_classes(assigns) do
rest_classes =
assigns.rest
|> Map.keys()
|> Enum.map(&to_string/1)
is_bulma_classes =
rest_classes
|> Enum.filter(&String.starts_with?(&1, "is-"))
has_bulma_classes =
rest_classes
|> Enum.filter(&String.starts_with?(&1, "has-"))
bulma_classes =
is_bulma_classes ++ has_bulma_classes
rest =
assigns.rest
|> Map.reject(fn {k, _} -> String.starts_with?(to_string(k), "is-") end)
|> Map.reject(fn {k, _} -> String.starts_with?(to_string(k), "has-") end)
assigns = Map.put(assigns, :rest, rest)
assigns =
Map.put(
assigns,
:class,
Map.get(assigns, :class, "") <> Enum.join(bulma_classes, " ")
)
assigns
end
end

24
lib/liliform/button.ex Normal file
View file

@ -0,0 +1,24 @@
defmodule Liliform.Button do
use Liliform.Component
@doc """
Renders button.
"""
attr :type, :string, default: "button", doc: "button type"
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def button(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<button class={["button", @class]} type={@type} {@rest}>
<%= render_slot(@inner_block) %>
</button>
"""
end
end

55
lib/liliform/colorhash.ex Normal file
View file

@ -0,0 +1,55 @@
defmodule Liliform.Colorhash do
@moduledoc """
Given a string returns HSL color.
"""
alias Freedive.Features
@seed "zeni"
@default_color {0, 0, 0}
@doc """
Given a string returns HSL color as a CSS string.
"""
def hsl(input) do
{hue, saturation, lightness} = hsl(input, raw: true)
"hsl(#{hue}, #{saturation}%, #{lightness}%)"
end
@doc """
Given a string returns HSL color.
"""
def hsl(input, raw: true) do
if Features.enabled?(:colorhash) do
input = String.downcase(input) <> @seed
hash = :erlang.phash2(input, 2_147_483_647)
hue = calculate_hue(hash)
saturation = 80 + rem(hash, 21)
lightness = 30 + rem(hash, 31)
{hue, saturation, lightness}
else
@default_color
end
end
@doc """
Calculates hue avoiding low contrast colors.
"""
def calculate_hue(hash) do
base_hue = rem(hash, 360)
# Ranges to exclude (yellow to light green, cyan)
excluded_ranges = [
# yellow
{40, 80},
# cyan
{175, 185}
]
# Adjust the hue to skip over excluded ranges
Enum.reduce(excluded_ranges, base_hue, fn {start, stop}, acc_hue ->
if acc_hue >= start and acc_hue <= stop, do: stop + (acc_hue - start + 1), else: acc_hue
end)
end
end

23
lib/liliform/column.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Column do
use Liliform.Component
@doc """
Renders a column.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def column(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["column", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
end

17
lib/liliform/component.ex Normal file
View file

@ -0,0 +1,17 @@
defmodule Liliform.Component do
defmacro __using__(_) do
quote do
# Bulma prefixes
use Phoenix.Component,
global_prefixes: [
"is-",
"has-"
]
alias Phoenix.LiveView.JS
import Liliform.Translation
import Liliform.BulmaHelper
end
end
end

23
lib/liliform/container.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Container do
use Liliform.Component
@doc """
Renders a container.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def container(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["container", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
end

23
lib/liliform/content.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Content do
use Liliform.Component
@doc """
Renders a content.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def content(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["content", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
end

23
lib/liliform/control.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Control do
use Liliform.Component
@doc """
Renders a control.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def control(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["control", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
end

143
lib/liliform/flash.ex Normal file
View file

@ -0,0 +1,143 @@
defmodule Liliform.Flash do
use Liliform.Component
alias Phoenix.LiveView.JS
import FreediveWeb.Gettext
import Liliform.Button
import Liliform.Content
import Liliform.Hero
import Liliform.Media
import Liliform.Title
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :title, :string, default: nil, doc: "optional title for flash message"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
# IO.inspect(assigns.rest)
~H"""
<.hero
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class={
Enum.join(
[
"is-small",
@kind == :info && "is-info",
@kind == :error && "is-warning"
],
" "
)
}
{@rest}
>
<.media>
<.media_left>
<Lucideicons.info :if={@kind == :info} class="icon-small" aria-hidden />
<Lucideicons.triangle_alert :if={@kind == :error} class="icon-small" aria-hidden />
</.media_left>
<.media_content>
<.content>
<.title :if={@title} is-4>
<%= @title %>
</.title>
<.subtitle is-6>
<%= msg %>
</.subtitle>
</.content>
</.media_content>
<.media_right is-hidden-touch>
<.button aria-label={gettext("close")} phx-click={hide("##{@id}")}>
<Lucideicons.circle_x class="h-10 w-10" aria-hidden />
</.button>
</.media_right>
</.media>
</.hero>
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id}>
<.flash kind={:info} title={gettext("Info")} flash={@flash} is-info />
<.flash kind={:error} title={gettext("Error")} flash={@flash} is-danger />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show("#client-error")}
phx-connected={hide("#client-error")}
is-hidden
is-warning
>
<%= gettext("Attempting to reconnect") %>
<Lucideicons.refresh_cw class="h-4 w-4 animate-spin is-inline-block" aria-hidden />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show("#server-error")}
phx-connected={hide("#server-error")}
is-hidden
is-warning
>
<%= gettext("Hang in there while we get back on track") %>
<Lucideicons.refresh_cw class="h-4 w-4 animate-spin is-inline-block" aria-hidden />
</.flash>
</div>
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.remove_class(js, "is-hidden",
to: selector,
time: 100,
transition:
{"transition-all transform ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.add_class(js, "is-hidden",
to: selector,
time: 200,
transition:
{"transition-all transform ease-in duration-200",
"opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
end

23
lib/liliform/hero.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Hero do
use Liliform.Component
@doc """
Renders a hero section.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def hero(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<section class={["hero", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</section>
"""
end
end

119
lib/liliform/icon.ex Normal file
View file

@ -0,0 +1,119 @@
defmodule Liliform.Icon do
@moduledoc """
Bulma icon element.
The icon element is a container for any type of icon font.
Because the icons can take a few seconds to load,
and because you want control over the space
the icons will take, you can use the icon class
as a reliable square container that will prevent
the page to "jump" on page load.
By default, the icon container will take up
exactly 1.5rem x 1.5rem.
The icon itself is sized accordingly to the icon library you're using.
For example, Font Awesome 5 icons will inherit the font size.
"""
use Liliform.Component
alias Liliform.Colorhash
@doc """
Renders an icon.
## For
The icon name is set using the `for` attribute.
## Color
Set color to `auto` to automatically choose/generate color
based on the icon name.
You can also set the color to any valid CSS color value.
## Size
The icon size can be set using the `size` attribute.
By default, the size is set to `1.5rem`.
Example:
<.icon for="home" />
<.icon for="info" color="auto" />
<.icon for="alert" color="#f44" />
<.icon for="settings" color="blue" />
<.icon for="error" color="hsl(0, 100%, 50%)" />
<.icon for="info" size="2rem" />
"""
attr :for, :string, required: true, doc: "icon name"
attr :size, :string, default: nil, doc: "size of icon"
attr :color, :string, default: nil, doc: "color of icon"
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: false
def icon(assigns) do
assigns =
assigns
|> set_bulma_classes()
assigns =
if assigns.size != nil do
Map.put(assigns, :style, "width: #{assigns.size}; height: #{assigns.size};")
else
assigns
end
~H"""
<span class="icon-text">
<span class={["icon", @class]} {@rest}>
<.icon_svg for={@for} height={@size} width={@size} color={icon_color(assigns)} />
</span>
<%= if @inner_block != [] do %>
<span>
<%= render_slot(@inner_block) %>
</span>
<% end %>
</span>
"""
end
defp icon_svg(assigns) do
base_assigns =
assigns
|> Map.reject(fn {k, _} -> k in [:for, :size, :rest] end)
|> Map.reject(fn {k, v} -> k in [:color] and v == "" end)
apply(Lucideicons, icon_name(assigns), [base_assigns])
end
defp icon_color(assigns) do
case assigns.color do
nil -> ""
"auto" -> Colorhash.hsl(assigns.for)
color -> color
end
end
defp icon_name(assigns) do
name =
assigns.for
|> String.trim()
|> String.downcase()
|> String.replace("-", "_")
case name do
"alert" -> :triangle_alert
"compute" -> :binary
"storage" -> :hard_drive
"network" -> :earth
"system" -> :bot
"account" -> :user
lucide_name -> String.to_atom(lucide_name)
end
end
end

157
lib/liliform/input.ex Normal file
View file

@ -0,0 +1,157 @@
defmodule Liliform.Input do
use Liliform.Component
import Liliform.Label
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password
range radio search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
slot :inner_block
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="field" phx-feedback-for={@name}>
<label class="label">
<input type="hidden" name={@name} value="false" />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="checkbox"
{@rest}
/>
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="field" phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<select id={@id} name={@name} class="select" multiple={@multiple} {@rest}>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="field" phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<textarea
id={@id}
name={@name}
class={[
"textarea",
@errors == [] && "",
@errors != [] && "is-danger"
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="field" phx-feedback-for={@name}>
<.label for={@id}><%= @label %></.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"input",
@errors == [] && "",
@errors != [] && "is-danger"
]}
{@rest}
/>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
end
@doc """
Generates a generic error message.
"""
slot :inner_block, required: true
def error(assigns) do
~H"""
<p class="mt-3 phx-no-feedback:hidden">
<Lucideicons.circle_alert aria-hidden />
<%= render_slot(@inner_block) %>
</p>
"""
end
end

19
lib/liliform/label.ex Normal file
View file

@ -0,0 +1,19 @@
defmodule Liliform.Label do
use Liliform.Component
@doc """
Renders a label.
"""
attr :for, :string, default: nil, doc: "the for attribute of the label"
attr :class, :string, default: nil, doc: "the optional class of the label"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the label"
slot :inner_block, required: true, doc: "the inner block that renders the label content"
def label(assigns) do
~H"""
<label for={@for} class={@class} {@rest}>
<%= render_slot(@inner_block) %>
</label>
"""
end
end

80
lib/liliform/media.ex Normal file
View file

@ -0,0 +1,80 @@
defmodule Liliform.Media do
use Liliform.Component
@doc """
Renders a media object.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def media(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<article class={["media", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</article>
"""
end
@doc """
Renders a media-left.
"""
attr :rest, :global
slot :inner_block, required: true
def media_left(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<figure class="media-left" {@rest}>
<%= render_slot(@inner_block) %>
</figure>
"""
end
@doc """
Renders a media-content.
"""
attr :rest, :global
slot :inner_block, required: true
def media_content(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class="media-content" {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a media-right.
"""
attr :rest, :global
slot :inner_block, required: true
def media_right(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class="media-right" {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
end

209
lib/liliform/navbar.ex Normal file
View file

@ -0,0 +1,209 @@
defmodule Liliform.Navbar do
use Liliform.Component
@doc """
Renders a navbar.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<nav class={["navbar", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</nav>
"""
end
@doc """
Renders a navbar-brand.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar_brand(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["navbar-brand", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a navbar-menu.
"""
attr :id, :string, required: true
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar_menu(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div id={@id} class={["navbar-menu", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a navbar-start.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar_start(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["navbar-start", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a navbar-end.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar_end(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["navbar-end", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a navbar-item.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar_item(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["navbar-item", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a navbar-dropdown.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar_dropdown(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["navbar-dropdown", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a navbar-link.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def navbar_link(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<a class={["navbar-link", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</a>
"""
end
@doc """
Renders a navbar-divider.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
def navbar_divider(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<hr class={["navbar-divider", @class]} {@rest} />
"""
end
@doc """
Renders a navbar-burger.
"""
attr :target, :string, required: true
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
def navbar_burger(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<a
role="button"
class={["navbar-burger", @class]}
aria-label="menu"
aria-expanded="false"
data-target={@target}
{@rest}
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
"""
end
end

83
lib/liliform/panel.ex Normal file
View file

@ -0,0 +1,83 @@
defmodule Liliform.Panel do
use Liliform.Component
@doc """
Renders a panel.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def panel(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<nav class={["panel", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</nav>
"""
end
@doc """
Renders a panel-heading.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def panel_heading(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<p class={["panel-heading", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</p>
"""
end
@doc """
Renders a panel-block.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def panel_block(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<div class={["panel-block", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</div>
"""
end
@doc """
Renders a panel-tabs.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def panel_tabs(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<p class={["panel-tabs", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</p>
"""
end
end

23
lib/liliform/section.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Liliform.Section do
use Liliform.Component
@doc """
Renders a section.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def section(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<section class={["section", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</section>
"""
end
end

View file

@ -0,0 +1,39 @@
defmodule Liliform.SimpleForm do
use Liliform.Component
@doc """
Renders a simple form.
## Examples
<.simple_form for={@form} phx-change="validate" phx-submit="save">
<.input field={@form[:email]} label="Email"/>
<.input field={@form[:username]} label="Username" />
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
"""
attr :for, :any, required: true, doc: "the datastructure for the form"
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
attr :rest, :global,
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
doc: "the arbitrary HTML attributes to apply to the form tag"
slot :inner_block, required: true
slot :actions, doc: "the slot for form actions, such as a submit button"
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest}>
<div class="box">
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions} class="mt-4">
<%= render_slot(action, f) %>
</div>
</div>
</.form>
"""
end
end

43
lib/liliform/title.ex Normal file
View file

@ -0,0 +1,43 @@
defmodule Liliform.Title do
use Liliform.Component
@doc """
Renders a title.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def title(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<h1 class={["title", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</h1>
"""
end
@doc """
Renders a subtitle.
"""
attr :class, :string, default: "", doc: "additional classes"
attr :rest, :global
slot :inner_block, required: true
def subtitle(assigns) do
assigns =
assigns
|> set_bulma_classes()
~H"""
<h2 class={["subtitle", @class]} {@rest}>
<%= render_slot(@inner_block) %>
</h2>
"""
end
end

View file

@ -0,0 +1,29 @@
defmodule Liliform.Translation do
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(FreediveWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(FreediveWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

View file

@ -1,23 +1,25 @@
defmodule Freedive.ColorhashTest do defmodule Freedive.ColorhashTest do
use ExUnit.Case use ExUnit.Case
alias Liliform.Colorhash
alias Freedive.Features
test "hsl" do test "hsl" do
assert Freedive.Colorhash.hsl("foo") == {312, 80, 48} assert Colorhash.hsl("foo", raw: true) == {275, 85, 34}
assert Freedive.Colorhash.hsl("bar") == {38, 88, 38} assert Colorhash.hsl("bar", raw: true) == {309, 92, 46}
assert Freedive.Colorhash.hsl("baz") == {288, 95, 42} assert Colorhash.hsl("baz", raw: true) == {307, 87, 37}
end end
test "hsl with css" do test "hsl with css" do
assert Freedive.Colorhash.hsl("foo", css: true) == "hsl(312, 80%, 48%)" assert Colorhash.hsl("foo") == "hsl(275, 85%, 34%)"
assert Freedive.Colorhash.hsl("bar", css: true) == "hsl(38, 88%, 38%)" assert Colorhash.hsl("bar") == "hsl(309, 92%, 46%)"
assert Freedive.Colorhash.hsl("baz", css: true) == "hsl(288, 95%, 42%)" assert Colorhash.hsl("baz") == "hsl(307, 87%, 37%)"
end end
test "hsl with disabled feature" do test "hsl with disabled feature" do
Freedive.Features.disable(:colorhash) Features.disable(:colorhash)
assert Freedive.Colorhash.hsl("foo") == {0, 0, 0} assert Colorhash.hsl("foo", raw: true) == {0, 0, 0}
assert Freedive.Colorhash.hsl("bar") == {0, 0, 0} assert Colorhash.hsl("bar", raw: true) == {0, 0, 0}
assert Freedive.Colorhash.hsl("baz") == {0, 0, 0} assert Colorhash.hsl("baz", raw: true) == {0, 0, 0}
Freedive.Features.enable(:colorhash) Features.enable(:colorhash)
end end
end end