lib/bylaw/credo/check/heex/prefer_native_interactive_element.ex

defmodule Bylaw.Credo.Check.HEEx.PreferNativeInteractiveElement do
  @moduledoc """
  Prefers native interactive elements over clickable static HEEx/HTML tags.

  ## Examples

  Embedded `~H` templates are checked during normal Credo runs over Elixir
  files. Standalone `.html.heex` templates require enabling
  `Bylaw.Credo.Plugin.HEExSources` in Credo's `plugins` configuration.
  Avoid:

        ~H\"\"\"
        <div phx-click="save">Save</div>
        <span phx-click="open">Open</span>
        \"\"\"
  Prefer:

        ~H\"\"\"
        <button type="button" phx-click="save">Save</button>
        <a href={~p"/settings"}>Settings</a>
        \"\"\"

  ## Notes

  Embedded `~H` templates in `.ex` and `.exs` files are checked by Credo's normal source traversal. Standalone `.html.heex` templates are checked when `Bylaw.Credo.Plugin.HEExSources` is enabled in `.credo.exs`.

  This check uses static HEEx token analysis, so it reports only patterns visible in the template source.

  ## Options

  This check has no check-specific options. Configure it with an empty option list.

  ## Usage

  Add this check to Credo's `checks:` list in `.credo.exs`:

  ```elixir
  %{
    configs: [
      %{
        name: "default",
        checks: [
          {Bylaw.Credo.Check.HEEx.PreferNativeInteractiveElement, []}
        ]
      }
    ]
  }
  ```
  """

  use Credo.Check,
    base_priority: :high,
    category: :warning,
    explanations: [check: @moduledoc]

  alias Bylaw.Credo.Heex

  @message "Prefer a native interactive element, such as button or a, over a clickable div or span."
  @static_non_interactive_tags ["div", "span"]
  @keyboard_attrs [
    "phx-keydown",
    "phx-keyup",
    "phx-window-keydown",
    "phx-window-keyup",
    "onkeydown",
    "onkeyup"
  ]
  @doc false
  @impl Credo.Check
  def run(%Credo.SourceFile{} = source_file, params \\ []) do
    issue_meta = IssueMeta.for(source_file, params)

    source_file
    |> Heex.templates()
    |> Enum.flat_map(&Heex.tags/1)
    |> Enum.filter(&clickable_static_non_interactive?/1)
    |> Enum.map(&issue_for(issue_meta, &1))
  end

  defp clickable_static_non_interactive?(%Heex.Tag{type: :tag, name: name} = tag)
       when name in @static_non_interactive_tags do
    Heex.has_attr?(tag, "phx-click") and not Heex.has_attr?(tag, :root) and
      not accessible_widget_pattern?(tag)
  end

  defp clickable_static_non_interactive?(_tag), do: false

  defp accessible_widget_pattern?(%Heex.Tag{} = tag) do
    Heex.has_attr?(tag, "role") and Heex.has_attr?(tag, "tabindex") and keyboard_handler?(tag)
  end

  defp keyboard_handler?(%Heex.Tag{} = tag) do
    Enum.any?(@keyboard_attrs, &Heex.has_attr?(tag, &1))
  end

  defp issue_for(issue_meta, %Heex.Tag{} = tag) do
    format_issue(
      issue_meta,
      message: @message,
      trigger: "<#{tag.name}",
      line_no: tag.line,
      column: tag.column
    )
  end
end