defmodule PhoenixActiveLink do @moduledoc """ PhoenixActiveLink provides helpers to add active links in views. ## Configuration Default options can be customized in the configuration: ```elixir use Mix.Config config :phoenix_active_link, :defaults, wrap_tag: :li, class_active: "enabled", class_inactive: "disabled" ``` ## Integrate in Phoenix The simplest way to add the helpers to Phoenix is to `import PhoenixActiveLink` either in your `web.ex` under views to have it available under every views, or under for example `App.LayoutView` to have it available in your layout. """ use Phoenix.HTML import Plug.Conn alias Plug.Conn.Query @opts ~w(active wrap_tag class_active class_inactive active_disable wrap_tag_opts)a @doc """ `active_link/3` is a wrapper around `Phoenix.HTML.Link.link/2`. It generates a link and adds an `active` class depending on the desired state. It can be customized using the following options. ## Options * `:active` - See `active_path?/2` documentation for more information * `:wrap_tag` - Wraps the link in another tag which will also have the same active class. This options is useful for usage with `li` in bootstrap for example. * `:class_active` - The class to add when the link is active. Defaults to `"active"` * `:class_inactive` - The class to add when the link is not active. Empty by default. * `:active_disable` - Uses a `span` element instead of an anchor when not active. ## Examples ```elixir <%= active_link(@conn, "Link text", to: "/my/path") %> <%= active_link(@conn, "Link text", to: "/my/path", wrap_tag: :li) %> <%= active_link(@conn, "Link text", to: "/my/path", active: :exact) %> ``` """ def active_link(conn, opts, do: contents) when is_list(opts) do active_link(conn, contents, opts) end def active_link(conn, text, opts) do opts = Keyword.merge(default_opts(), opts) active? = active_path?(conn, opts) extra_class = extra_class(active?, opts) opts = append_class(opts, extra_class) link = make_link(active?, text, opts) cond do tag = opts[:wrap_tag] -> content_tag(tag, link, wrap_tag_opts(extra_class, opts)) true -> link end end @doc """ `active_path?/2` is a helper to determine if the element should be in active state or not. The `:opts` should contain the `:to` option and the active detection can be customized using by passing `:active` one of the following values. * `true` - Will always return `true` * `false` - Will always return `false` * `:inclusive` - Will return `true` if the current path starts with the link path. For example, `active_path?(conn, to: "/foo")` will return `true` if the path is `"/foo"` or `"/foobar"`. * `:exclusive` - Will return `true` if the current path and the link path are the same, but will ignore the trailing slashes For example, `active_path?(conn, "/foo")` will return `true` when the path is `"/foo/"` * `:exact` - Will return `true` if the current path and the link path are exactly the same, including trailing slashes. * a `%Regex{}` - Will return `true` if the current path matches the regex. Beware that `active?(conn, active: ~r/foo/)` will return `true` if the path is `"/bar/foo"`, so you must use `active?(conn, active: ~r/^foo/)` if you want to match the beginning of the path. * a `{controller, action}` list - A list of tuples with a controller module and an action symbol. Both can be the `:any` symbol to match any controller or action. * a `{live_view, action}` list - A list of tuples with a live view module and an action symbol. Both can be the `:any` symbol to match any live view module or action. * `:exact_with_params` - Will return `true` if the current path and the link path are exactly the same, including trailing slashes and query string as is. * `:inclusive_with_params` - Will return `true` if the current path is equal to the link path and the query params of the current path are included to the link path. For example, `active_path?(conn, to: "/foo?bar=2")` will return `true` if the path is `"/foo?bar=2"` or `"/foo?baz=2&bar=2"`. For example, `active_path?(conn, to: "/foo?bar=2")` will return `false` if the path is `"/foobaz?bar=2"`. ## Examples ```elixir active_path?(conn, to: "/foo") active_path?(conn, to: "/foo", active: false) active_path?(conn, to: "/foo", active: :exclusive) active_path?(conn, to: "/foo", active: ~r(^/foo/[0-9]+)) active_path?(conn, to: "/foo", active: [{MyController, :index}, {OtherController, :any}]) active_path?(conn, to: "/foo", active: [{MyLive, :index}, {OtherLive, :any}]) active_path?(conn, to: "/foo?baz=2", active: :inclusive_with_params) ``` """ def active_path?(conn, opts) do to = Keyword.get(opts, :to, "") case Keyword.get(opts, :active, :inclusive) do true -> true false -> false :inclusive -> starts_with_path?(conn.request_path, to) :exclusive -> String.trim_trailing(conn.request_path, "/") == String.trim_trailing(to, "/") :exact -> conn.request_path == to :exact_with_params -> request_path_with_params(conn) == to :inclusive_with_params -> compare_path_and_params(conn, to) %Regex{} = regex -> Regex.match?(regex, conn.request_path) module_actions when is_list(module_actions) -> module_actions_active?(conn, module_actions) _ -> false end end # NOTE: root path is an exception, otherwise it would be active all the time defp starts_with_path?(request_path, "/") when request_path != "/", do: false defp starts_with_path?(request_path, to) do # Parse both paths to strip any query parameters %{path: request_path} = URI.parse(request_path) %{path: to_path} = URI.parse(to) String.starts_with?(request_path, String.trim_trailing(to_path, "/")) end defp module_actions_active?(conn, module_actions) do {current_module, current_action} = case conn.private do %{phoenix_controller: module, phoenix_action: action} -> {module, action} %{phoenix_live_view: {module, opts}} -> {module, Keyword.get(opts, :action)} %{} -> {nil, nil} end Enum.any? module_actions, fn {module, action} -> (module == :any or module == current_module) and (action == :any or action == current_action) end end defp request_path_with_params(conn) do case conn.query_string do "" -> conn.request_path query_string -> conn.request_path <> "?" <> query_string end end defp compare_path_and_params(conn, to) do %{query_params: request_params} = fetch_query_params(conn) with [path, query_params] <- String.split(to, "?"), true <- starts_with_path?(conn.request_path, path) do decoded_params = query_params |> Query.decode() map_include?(request_params, decoded_params) else [path] -> conn.request_path == path false -> false end end defp map_include?(map, {key, %{} = value}), do: map_include?(map[key], value) defp map_include?(map, {key, value}), do: map[key] == value defp map_include?(in_map, %{} = map), do: Enum.all?(map, &map_include?(in_map, &1)) defp wrap_tag_opts(extra_class, opts) do Keyword.get(opts, :wrap_tag_opts, []) |> append_class(extra_class) end defp make_link(active?, text, opts) do if active? and opts[:active_disable] do content_tag(:span, text, span_opts(opts)) else link(text, link_opts(opts)) end end defp extra_class(active?, opts) do if active? do opts[:class_active] || "active" else opts[:class_inactive] || "" end end defp append_class(opts, class) do class = opts |> Keyword.get(:class, "") |> String.split(" ") |> List.insert_at(0, class) |> Enum.reject(&(&1 == "")) |> Enum.join(" ") Keyword.put(opts, :class, class) end defp link_opts(opts) do Enum.reject(opts, &(elem(&1, 0) in @opts)) end defp span_opts(opts) do opts |> link_opts() |> Enum.reject(&(elem(&1, 0) in ~w(to form method)a)) end defp default_opts do Application.get_env(:phoenix_active_link, :defaults, []) end end