Current section
Files
Jump to
Current section
Files
lib/nex/helpers.ex
defmodule Nex.Helpers do
@moduledoc """
Common formatting helpers automatically imported into all Nex page and component modules.
These functions are available in any module that uses `use Nex` — no explicit import needed.
## Available Helpers
- `format_number/1` — Format integers with k/M suffix (e.g. `12345` → `"12.3k"`)
- `format_date/1` — Format dates/datetimes to human-readable string
- `time_ago/1` — Relative time string (e.g. `"3 days ago"`)
- `truncate/3` — Truncate strings with ellipsis
- `pluralize/3` — Singular/plural based on count
- `clsx/1` — Build CSS classes from lists
- `class/2` — Build CSS classes with template support
- `attrs/1` — Build HTML attributes conditionally
"""
@type date_input :: Date.t() | NaiveDateTime.t() | DateTime.t() | String.t() | nil
@doc """
Formats a number with k/M suffix for compact display.
## Examples
format_number(500) # => "500"
format_number(1_200) # => "1.2k"
format_number(45_000) # => "45.0k"
format_number(1_500_000) # => "1.5M"
"""
@spec format_number(integer() | float() | nil) :: String.t()
def format_number(nil), do: "0"
def format_number(n) when is_integer(n) and n >= 1_000_000_000 do
"#{format_float(n / 1_000_000_000)}B"
end
def format_number(n) when is_integer(n) and n >= 1_000_000 do
"#{format_float(n / 1_000_000)}M"
end
def format_number(n) when is_integer(n) and n >= 1_000 do
"#{format_float(n / 1_000)}k"
end
def format_number(n) when is_integer(n), do: "#{n}"
def format_number(n) when is_float(n), do: format_number(round(n))
def format_number(n), do: "#{n}"
# Avoid scientific notation for large floats.
defp format_float(n) do
rounded = Float.round(n, 1)
:io_lib.format("~.1f", [rounded]) |> to_string()
end
@doc """
Formats a date or datetime to a human-readable string.
Accepts `%Date{}`, `%NaiveDateTime{}`, `%DateTime{}`, or an ISO 8601 string.
## Examples
format_date(~D[2026-02-19]) # => "Feb 19, 2026"
format_date(~N[2026-02-19 10:30:00]) # => "Feb 19, 2026"
format_date("2026-02-19T10:30:00Z") # => "Feb 19, 2026"
format_date(nil) # => ""
"""
@spec format_date(date_input()) :: String.t()
def format_date(nil), do: ""
def format_date(%Date{} = d), do: Calendar.strftime(d, "%b %d, %Y")
def format_date(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%b %d, %Y")
def format_date(%DateTime{} = dt), do: Calendar.strftime(dt, "%b %d, %Y")
def format_date(s) when is_binary(s) do
cond do
String.length(s) >= 10 ->
case Date.from_iso8601(String.slice(s, 0, 10)) do
{:ok, d} -> format_date(d)
_ -> s
end
true ->
s
end
end
def format_date(_), do: ""
@doc """
Truncates a string to the given length, appending an ellipsis if truncated.
## Options
* `:omission` - String appended when truncated (default: `"..."`)
## Examples
truncate("Hello, world!", 8) # => "Hello..."
truncate("Hello", 10) # => "Hello"
truncate("Hello, world!", 8, omission: "…") # => "Hello, …"
truncate(nil, 10) # => ""
"""
@spec truncate(String.t() | nil, pos_integer(), keyword()) :: String.t()
def truncate(str, length, opts \\ [])
def truncate(nil, _length, _opts), do: ""
def truncate(str, length, opts) when is_binary(str) and is_integer(length) do
omission = Keyword.get(opts, :omission, "...")
if String.length(str) <= length do
str
else
truncated_length = max(0, length - String.length(omission))
String.slice(str, 0, truncated_length) <> omission
end
end
@doc """
Returns the singular or plural form of a word based on count.
## Examples
pluralize(1, "item", "items") # => "1 item"
pluralize(5, "item", "items") # => "5 items"
pluralize(0, "item", "items") # => "0 items"
"""
@spec pluralize(integer(), String.t(), String.t()) :: String.t()
def pluralize(count, singular, plural) when is_integer(count) do
word = if count == 1, do: singular, else: plural
"#{count} #{word}"
end
@doc """
Builds a CSS class string from a list of values, filtering out falsy entries.
Accepts strings, `{class, condition}` tuples, and ignores `nil`/`false`.
## Examples
clsx(["btn", "btn-primary"]) # => "btn btn-primary"
clsx(["btn", nil, false, "active"]) # => "btn active"
clsx(["btn", {"btn-active", true}, {"hidden", false}]) # => "btn btn-active"
clsx([]) # => ""
"""
@spec clsx([String.t() | {String.t(), boolean()} | nil | false]) :: String.t()
def clsx(list) when is_list(list) do
list
|> Enum.flat_map(fn
{class, true} when is_binary(class) -> [class]
{_class, _} -> []
class when is_binary(class) and class != "" -> [class]
_ -> []
end)
|> Enum.join(" ")
end
@doc """
Returns a relative time string from a past datetime.
## Examples
time_ago(DateTime.add(DateTime.utc_now(), -30, :second)) # => "just now"
time_ago(DateTime.add(DateTime.utc_now(), -90, :second)) # => "2 minutes ago"
time_ago(DateTime.add(DateTime.utc_now(), -3600 * 5, :second)) # => "5 hours ago"
time_ago(DateTime.add(DateTime.utc_now(), -86400 * 3, :second)) # => "3 days ago"
"""
@spec time_ago(date_input()) :: String.t()
def time_ago(nil), do: ""
def time_ago(%Date{} = d) do
d
|> NaiveDateTime.new!(~T[00:00:00])
|> DateTime.from_naive!("Etc/UTC")
|> time_ago()
end
def time_ago(%NaiveDateTime{} = dt) do
dt |> DateTime.from_naive!("Etc/UTC") |> time_ago()
end
def time_ago(%DateTime{} = dt) do
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
cond do
diff < 60 -> "just now"
diff < 3_600 ->
minutes = div(diff, 60)
"#{minutes} #{pluralize_unit(minutes, "minute", "minutes")} ago"
diff < 86_400 ->
hours = div(diff, 3_600)
"#{hours} #{pluralize_unit(hours, "hour", "hours")} ago"
diff < 604_800 ->
days = div(diff, 86_400)
"#{days} #{pluralize_unit(days, "day", "days")} ago"
diff < 2_592_000 ->
weeks = div(diff, 604_800)
"#{weeks} #{pluralize_unit(weeks, "week", "weeks")} ago"
true ->
months = div(diff, 2_592_000)
"#{months} #{pluralize_unit(months, "month", "months")} ago"
end
end
def time_ago(s) when is_binary(s) do
case DateTime.from_iso8601(s) do
{:ok, dt, _} -> time_ago(dt)
_ -> ""
end
end
def time_ago(_), do: ""
defp pluralize_unit(1, singular, _plural), do: singular
defp pluralize_unit(_count, _singular, plural), do: plural
@doc """
Builds CSS classes with template support.
Similar to `clsx/1` but supports nested templates with the `&` placeholder.
## Examples
class("btn &", ["btn-primary", "btn-large"])
# => "btn-primary btn-large"
class("item active-&1", ["active", "disabled"])
# => "item active-active"
class("btn &-state", ["success", "large"])
# => "btn success-state btn large-state"
"""
@spec class(String.t(), [String.t() | nil | false]) :: String.t()
def class(template, values) when is_binary(template) and is_list(values) do
values
|> Enum.filter(& &1)
|> Enum.map(fn val -> String.replace(template, "&", to_string(val)) end)
|> Enum.join(" ")
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
@doc """
Builds HTML attributes from a keyword list, filtering out nil/false values.
## Examples
attrs([class: "btn", disabled: false, data_action: "submit"])
# => "class=\"btn\" data-action=\"submit\""
attrs([id: "my-form", required: true])
# => "id=\"my-form\" required"
"""
@spec attrs([{atom() | String.t(), term()}]) :: String.t()
def attrs(attrs) when is_list(attrs) do
attrs
|> Enum.filter(fn
{_key, nil} -> false
{_key, false} -> false
{_key, _value} -> true
end)
|> Enum.map(fn
{key, true} ->
"#{attr_key(key)}"
{key, value} when is_binary(value) or is_number(value) ->
escaped = value |> to_string() |> html_escape_attr()
"#{attr_key(key)}=\"#{escaped}\""
{key, value} ->
escaped = value |> inspect() |> html_escape_attr()
"#{attr_key(key)}=\"#{escaped}\""
end)
|> Enum.join(" ")
end
defp html_escape_attr(text) when is_binary(text) do
text
|> String.replace("&", "&")
|> String.replace("\"", """)
|> String.replace("<", "<")
|> String.replace(">", ">")
end
defp attr_key(key) when is_atom(key) do
key
|> Atom.to_string()
|> String.replace("_", "-")
end
defp attr_key(key) when is_binary(key), do: key
end