Repository: woylie/flop_phoenix
Pull request #230 (2023-07-09 04:15:28)

On_paginate


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 97528d6..284a404 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,20 @@
 
 ## Unreleased
 
+### Added
+
+- Added an `on_paginate` attribute to the `pagination` and `cursor_pagination`
+  components. This attribute takes a `Phoenix.LiveView.JS` command as a value.
+  The attribute can be combined with the `path` attribute, in which case the
+  `patch` command to the new URL will be appended to the given JS command.
+
+### Deprecated
+
+- The `show` and `hide` attributes of the `:col` slot of the table component are
+  now deprecated in favor of the `:if` attribute.
+- The `event` attribute of the pagination and cursor pagination components has
+  been deprecated in favor of `on_paginate`.
+
 ## [0.19.1] - 2023-06-30
 
 ### Changed
diff --git a/README.md b/README.md
index 5e60e73..e205647 100644
--- a/README.md
+++ b/README.md
@@ -69,8 +69,8 @@ end
 
 If you prefer the `Flop.Phoenix` components not to reflect pagination, sorting,
 and filtering parameters in the URL, fetch and assign the data in the
-`c:Phoenix.LiveView.handle_event/3` callback. You need to pass the event name as
-an attribute to the components in that case.
+`c:Phoenix.LiveView.handle_event/3` callback. You need to pass a
+`Phoenix.LiveView.JS` command as an attribute to the components in that case.
 
 ### Controller
 
@@ -125,8 +125,9 @@ By using the `for` option in your Flop query, Flop Phoenix can identify which
 table columns are sortable. Additionally, it omits the `order` and `page_size`
 parameters if they align with the default values specified via `Flop.Schema`.
 
-You also have the option to pass an event name instead of a path. For more
-details, please refer to the component documentation.
+You also have the option to pass a `Phoenix.LiveView.JS` command instead of or
+in addition to a path. For more details, please refer to the component
+documentation.
 
 If you wish to implement cursor-based pagination, see
 `Flop.Phoenix.cursor_pagination/1` for setup instructions.
diff --git a/lib/flop_phoenix.ex b/lib/flop_phoenix.ex
index 85d349f..e773c6c 100644
--- a/lib/flop_phoenix.ex
+++ b/lib/flop_phoenix.ex
@@ -79,18 +79,22 @@ defmodule Flop.Phoenix do
   params in the `c:Phoenix.LiveView.handle_params/3` callback of your LiveView
   module.
 
-  ## Event-Based Pagination and Sorting
+  ## Pagination and sorting with JS commands
 
-  To make `Flop.Phoenix` use event based pagination and sorting, you need to
-  assign the `:event` to the pagination and table components. This will
-  generate an `<a>` tag with `phx-click` and `phx-value` attributes set.
+  You can pass a `Phoenix.LiveView.JS` command as the `on_paginate` attribute.
+
+  If used with the `path` attribute, a `patch` command to the new URL will be
+  appended to the given command.
+
+  If used without the `path` attribute, you will need to include a `push`
+  command to trigger an event when a pagination or sort link is clicked.
 
   You can set a different target by assigning a `:target`. The value
   will be used as the `phx-target` attribute.
 
       <Flop.Phoenix.pagination
         meta={@meta}
-        event="paginate-pets"
+        on_paginate={JS.push("paginate-pets")}
         target={@myself}
       />
 
@@ -320,13 +324,39 @@ defmodule Flop.Phoenix do
   attr :path, :any,
     default: nil,
     doc: """
-    Either a URI string (Phoenix verified route), an MFA or FA tuple (Phoenix
-    route helper), or a 1-ary path builder function. See
-    `Flop.Phoenix.build_path/3` for details. If set, links will be
-    rendered with `Phoenix.Components.link/1` with the `patch` attribute. In a
-    LiveView, the parameters will have to be handled in the `handle_params/3`
-    callback of the LiveView module. Alternatively, set `:event`, if you don't
-    want the parameters to appear in the URL.
+    If set, the current view is patched with updated query parameters when a
+    pagination link is clicked. In case the `on_paginate` attribute is set as
+    well, the `patch` command is appended to the `on_paginate` command.
+
+    The value must be either a URI string (Phoenix verified route), an MFA or FA
+    tuple (Phoenix route helper), or a 1-ary path builder function. See
+    `Flop.Phoenix.build_path/3` for details.
+    """
+
+  attr :on_paginate, JS,
+    default: nil,
+    doc: """
+    A `Phoenix.LiveView.JS` command that is triggered when a pagination link is
+    clicked.
+
+    If used without the `path` attribute, you should include a `push` operation
+    to handle the event with the `handle_event` callback.
+
+        <.pagination
+          meta={@meta}
+          on_paginate={
+            JS.dispatch("scroll-to", to: "#pets-table") |> JS.push("paginate")
+          }
+        />
+
+    If used with the `path` attribute, a `patch` command is appended to the
+    given JS command.
+
+        <.pagination
+          meta={@meta}
+          path={~"/pets"}
+          on_paginate={JS.dispatch("scroll-to", to: "#pets-table")}
+        />
     """
 
   attr :event, :string,
@@ -334,6 +364,7 @@ defmodule Flop.Phoenix do
     doc: """
     If set, `Flop.Phoenix` will render links with a `phx-click` attribute.
     Alternatively, set `:path`, if you want the parameters to appear in the URL.
+    Deprecated in favor of `on_paginate`.
     """
 
   attr :target, :string,
@@ -353,18 +384,184 @@ defmodule Flop.Phoenix do
     described in the `Customization` section of the module documentation.
     """
 
-  def pagination(assigns) do
-    assigns = Pagination.init_assigns(assigns)
+  def pagination(%{meta: meta, opts: opts, path: path} = assigns) do
+    Misc.validate_path_or_on_paginate!(
+      assigns,
+      Pagination.path_on_paginate_error_msg()
+    )
+
+    assigns =
+      assigns
+      |> assign(:opts, Pagination.merge_opts(opts))
+      |> assign(
+        :page_link_helper,
+        Pagination.build_page_link_helper(meta, path)
+      )
+      |> assign(:path, nil)
 
     ~H"""
-    <Pagination.render
-      :if={@meta.total_pages > 1}
-      event={@event}
-      meta={@meta}
-      opts={@opts}
-      page_link_helper={Pagination.build_page_link_helper(@meta, @path)}
-      target={@target}
-    />
+    <nav :if={@meta.errors == [] && @meta.total_pages > 1} {@opts[:wrapper_attrs]}>
+      <.pagination_link
+        disabled={!@meta.has_previous_page?}
+        disabled_class={@opts[:disabled_class]}
+        event={@event}
+        target={@target}
+        page={@meta.previous_page}
+        path={@page_link_helper.(@meta.previous_page)}
+        on_paginate={@on_paginate}
+        {@opts[:previous_link_attrs]}
+      >
+        <%= @opts[:previous_link_content] %>
+      </.pagination_link>
+      <.pagination_link
+        disabled={!@meta.has_next_page?}
+        disabled_class={@opts[:disabled_class]}
+        event={@event}
+        target={@target}
+        page={@meta.next_page}
+        path={@page_link_helper.(@meta.next_page)}
+        on_paginate={@on_paginate}
+        {@opts[:next_link_attrs]}
+      >
+        <%= @opts[:next_link_content] %>
+      </.pagination_link>
+      <.page_links
+        event={@event}
+        meta={@meta}
+        on_paginate={@on_paginate}
+        page_link_helper={@page_link_helper}
+        opts={@opts}
+        target={@target}
+      />
+    </nav>
+    """
+  end
+
+  attr :meta, Flop.Meta, required: true
+  attr :on_paginate, JS
+  attr :page_link_helper, :any, required: true
+  attr :event, :string, required: true
+  attr :target, :string, required: true
+  attr :opts, :list, required: true
+
+  def page_links(%{meta: meta} = assigns) do
+    max_pages =
+      Pagination.max_pages(assigns.opts[:page_links], assigns.meta.total_pages)
+
+    range =
+      first..last =
+      Pagination.get_page_link_range(
+        meta.current_page,
+        max_pages,
+        meta.total_pages
+      )
+
+    assigns = assign(assigns, first: first, last: last, range: range)
+
+    ~H"""
+    <ul :if={@opts[:page_links] != :hide} {@opts[:pagination_list_attrs]}>
+      <.pagination_link
+        :if={@first > 1}
+        event={@event}
+        target={@target}
+        page={1}
+        path={@page_link_helper.(1)}
+        on_paginate={@on_paginate}
+        {Pagination.attrs_for_page_link(1, @meta, @opts)}
+      >
+        1
+      </.pagination_link>
+
+      <li :if={@first > 2}>
+        <span {@opts[:ellipsis_attrs]}><%= @opts[:ellipsis_content] %></span>
+      </li>
+
+      <.pagination_link
+        :for={page <- @range}
+        event={@event}
+        target={@target}
+        page={page}
+        path={@page_link_helper.(page)}
+        on_paginate={@on_paginate}
+        {Pagination.attrs_for_page_link(page, @meta, @opts)}
+      >
+        <%= page %>
+      </.pagination_link>
+
+      <li :if={@last < @meta.total_pages - 1}>
+        <span {@opts[:ellipsis_attrs]}><%= @opts[:ellipsis_content] %></span>
+      </li>
+
+      <.pagination_link
+        :if={@last < @meta.total_pages}
+        event={@event}
+        target={@target}
+        page={@meta.total_pages}
+        path={@page_link_helper.(@meta.total_pages)}
+        on_paginate={@on_paginate}
+        {Pagination.attrs_for_page_link(@meta.total_pages, @meta, @opts)}
+      >
+        <%= @meta.total_pages %>
+      </.pagination_link>
+    </ul>
+    """
+  end
+
+  attr :path, :string
+  attr :on_paginate, JS
+  attr :event, :string, required: true
+  attr :target, :string, required: true
+  attr :page, :integer, required: true
+  attr :disabled, :boolean, default: false
+  attr :disabled_class, :string
+  attr :rest, :global
+  slot :inner_block
+
+  defp pagination_link(
+         %{disabled: true, disabled_class: disabled_class} = assigns
+       ) do
+    rest =
+      Map.update(assigns.rest, :class, disabled_class, fn class ->
+        [class, disabled_class]
+      end)
+
+    assigns = assign(assigns, :rest, rest)
+
+    ~H"""
+    <span {@rest} class={@disabled_class}>
+      <%= render_slot(@inner_block) %>
+    </span>
+    """
+  end
+
+  defp pagination_link(%{event: event} = assigns) when is_binary(event) do
+    ~H"""
+    <.link phx-click={@event} phx-target={@target} phx-value-page={@page} {@rest}>
+      <%= render_slot(@inner_block) %>
+    </.link>
+    """
+  end
+
+  defp pagination_link(%{on_paginate: nil, path: path} = assigns)
+       when is_binary(path) do
+    ~H"""
+    <.link patch={@path} {@rest}>
+      <%= render_slot(@inner_block) %>
+    </.link>
+    """
+  end
+
+  defp pagination_link(%{} = assigns) do
+    ~H"""
+    <.link
+      href={@path}
+      phx-click={Misc.click_cmd(@on_paginate, @path)}
+      phx-target={@target}
+      phx-value-page={@page}
+      {@rest}
+    >
+      <%= render_slot(@inner_block) %>
+    </.link>
     """
   end
 
@@ -383,7 +580,7 @@ defmodule Flop.Phoenix do
         path={{Routes, :pet_path, [@socket, :index]}}
       />
 
-  ## Handling parameters and events
+  ## Handling parameters and JS commands
 
   If you set the `path` assign, a link with query parameters is rendered.
   In a LiveView, you need to handle the parameters in the
@@ -394,8 +591,8 @@ defmodule Flop.Phoenix do
         {:noreply, assign(socket, meta: meta, pets: pets)}
       end
 
-  If you use LiveView and set the `event` assign, you need to update the Flop
-  parameters in the `handle_event/3` callback.
+  If you use LiveView and set the `on_paginate` attribute, you need to update
+  the Flop parameters in the `handle_event/3` callback.
 
       def handle_event("paginate-users", %{"to" => to}, socket) do
         flop = Flop.set_cursor(socket.assigns.meta, to)
@@ -449,13 +646,39 @@ defmodule Flop.Phoenix do
   attr :path, :any,
     default: nil,
     doc: """
-    Either a URI string (Phoenix verified route), an MFA or FA tuple (Phoenix
-    route helper), or a 1-ary path builder function. See
-    `Flop.Phoenix.build_path/3` for details. If set, links will be
-    rendered with `Phoenix.Components.link/1` with the `patch` attribute. In a
-    LiveView, the parameters will have to be handled in the `handle_params/3`
-    callback of the LiveView module. Alternatively, set `:event`, if you don't
-    want the parameters to appear in the URL.
+    If set, the current view is patched with updated query parameters when a
+    pagination link is clicked. In case the `on_paginate` attribute is set as
+    well, the `patch` command is appended to the `on_paginate` command.
+
+    The value must be either a URI string (Phoenix verified route), an MFA or FA
+    tuple (Phoenix route helper), or a 1-ary path builder function. See
+    `Flop.Phoenix.build_path/3` for details.
+    """
+
+  attr :on_paginate, JS,
+    default: nil,
+    doc: """
+    A `Phoenix.LiveView.JS` command that is triggered when a pagination link is
+    clicked.
+
+    If used without the `path` attribute, you should include a `push` operation
+    to handle the event with the `handle_event` callback.
+
+        <.cursor_pagination
+          meta={@meta}
+          on_paginate={
+            JS.dispatch("scroll-to", to: "#pets-table") |> JS.push("paginate")
+          }
+        />
+
+    If used with the `path` attribute, a `patch` command is appended to the
+    given JS command.
+
+        <.cursor_pagination
+          meta={@meta}
+          path={~"/pets"}
+          on_paginate={JS.dispatch("scroll-to", to: "#pets-table")}
+        />
     """
 
   attr :event, :string,
@@ -463,6 +686,7 @@ defmodule Flop.Phoenix do
     doc: """
     If set, `Flop.Phoenix` will render links with a `phx-click` attribute.
     Alternatively, set `:path`, if you want the parameters to appear in the URL.
+    Deprecated. Use `on_paginate` instead.
     """
 
   attr :target, :string,
@@ -490,35 +714,111 @@ defmodule Flop.Phoenix do
     documentation.
     """
 
-  def cursor_pagination(assigns) do
-    assigns = CursorPagination.init_assigns(assigns)
+  def cursor_pagination(%{opts: opts} = assigns) do
+    assigns =
+      assigns
+      |> CursorPagination.validate_assigns!()
+      |> assign(:opts, CursorPagination.merge_opts(opts))
 
     ~H"""
     <nav :if={@meta.errors == []} {@opts[:wrapper_attrs]}>
-      <CursorPagination.render_link
-        attrs={@opts[:previous_link_attrs]}
-        content={@opts[:previous_link_content]}
+      <.cursor_pagination_link
         direction={if @reverse, do: :next, else: :previous}
-        event={@event}
         meta={@meta}
         path={@path}
-        opts={@opts}
+        on_paginate={@on_paginate}
+        event={@event}
         target={@target}
-      />
-      <CursorPagination.render_link
-        attrs={@opts[:next_link_attrs]}
-        content={@opts[:next_link_content]}
+        disabled={!CursorPagination.show_link?(@meta, :previous, @reverse)}
+        disabled_class={@opts[:disabled_class]}
+        {@opts[:previous_link_attrs]}
+      >
+        <%= @opts[:previous_link_content] %>
+      </.cursor_pagination_link>
+      <.cursor_pagination_link
         direction={if @reverse, do: :previous, else: :next}
-        event={@event}
         meta={@meta}
         path={@path}
-        opts={@opts}
+        on_paginate={@on_paginate}
+        event={@event}
         target={@target}
-      />
+        disabled={!CursorPagination.show_link?(@meta, :next, @reverse)}
+        disabled_class={@opts[:disabled_class]}
+        {@opts[:next_link_attrs]}
+      >
+        <%= @opts[:next_link_content] %>
+      </.cursor_pagination_link>
     </nav>
     """
   end
 
+  attr :direction, :atom, required: true
+  attr :meta, Flop.Meta, required: true
+  attr :path, :any, required: true
+  attr :on_paginate, JS
+  attr :event, :string, required: true
+  attr :target, :string, required: true
+  attr :disabled, :boolean, default: false
+  attr :disabled_class, :string, required: true
+  attr :rest, :global
+  slot :inner_block
+
+  defp cursor_pagination_link(
+         %{disabled: true, disabled_class: disabled_class} = assigns
+       ) do
+    rest =
+      Map.update(assigns.rest, :class, disabled_class, fn class ->
+        [class, disabled_class]
+      end)
+
+    assigns = assign(assigns, :rest, rest)
+
+    ~H"""
+    <span {@rest} class={@disabled_class}>
+      <%= render_slot(@inner_block) %>
+    </span>
+    """
+  end
+
+  defp cursor_pagination_link(%{event: event} = assigns)
+       when is_binary(event) do
+    ~H"""
+    <.link phx-click={@event} phx-target={@target} phx-value-to={@direction} {@rest}>
+      <%= render_slot(@inner_block) %>
+    </.link>
+    """
+  end
+
+  defp cursor_pagination_link(%{on_paginate: nil} = assigns) do
+    ~H"""
+    <.link
+      patch={CursorPagination.pagination_path(@direction, @path, @meta)}
+      {@rest}
+    >
+      <%= render_slot(@inner_block) %>
+    </.link>
+    """
+  end
+
+  defp cursor_pagination_link(
+         %{direction: direction, path: path, meta: meta} = assigns
+       ) do
+    path = CursorPagination.pagination_path(direction, path, meta)
+    assigns = assign(assigns, :path, path)
+
+    ~H"""
+    <.link
+      href={@path}
+      phx-click={Misc.click_cmd(@on_paginate, @path)}
+      phx-target={@target}
+      phx-value-to={@direction}
+      {@rest}
+    >
+      <%= render_slot(@inner_block) %>
+    </.link>
+    """
+  end
+
   @doc """
   Generates a table with sortable columns.
 
@@ -652,11 +952,16 @@ defmodule Flop.Phoenix do
       """
 
     attr :show, :boolean,
-      doc: "Boolean value to conditionally show the column. Defaults to `true`."
+      doc: """
+      Boolean value to conditionally show the column. Defaults to `true`
+      Deprecated. Use `:if` instead.
+      """
 
     attr :hide, :boolean,
-      doc:
-        "Boolean value to conditionally hide the column. Defaults to `false`."
+      doc: """
+      Boolean value to conditionally hide the column. Defaults to `false`.
+      Deprecated. Use `:if` instead.
+      """
 
     attr :col_style, :string,
       doc: """
diff --git a/lib/flop_phoenix/cursor_pagination.ex b/lib/flop_phoenix/cursor_pagination.ex
index cd5c451..9964843 100644
--- a/lib/flop_phoenix/cursor_pagination.ex
+++ b/lib/flop_phoenix/cursor_pagination.ex
@@ -3,19 +3,22 @@ defmodule Flop.Phoenix.CursorPagination do
 
   use Phoenix.Component
 
+  alias Flop.Meta
   alias Flop.Phoenix.Misc
 
   require Logger
 
-  @path_event_error_msg """
-  the :path or :event option is required when rendering cursor pagination
+  @path_on_paginate_error_msg """
+  path or on_paginate attribute is required
+
+  At least one of the mentioned attributes is required for the cursor
+  pagination component. Combining them will append a JS.patch command to the
+  on_paginate command.
 
   The :path value can be a path as a string, a
-  {module, function_name, args} tuple, a {function, args} tuple, or a 1-ary
+  {module, function_name, args} tuple, a {function, args} tuple, or an 1-ary
   function.
 
-  The :event value needs to be a string.
-
   ## Example
 
       <Flop.Phoenix.cursor_pagination
@@ -48,10 +51,23 @@ defmodule Flop.Phoenix.CursorPagination do
 
       <Flop.Phoenix.cursor_pagination
         meta={@meta}
-        event="paginate"
+        on_paginate={JS.push("paginate")}
+      />
+
+  or
+
+      <Flop.Phoenix.cursor_pagination
+        meta={@meta}
+        path={~"/pets"}
+        on_paginate={JS.dispatch("scroll-to", to: "#my-table")}
       />
   """
 
+  def validate_assigns!(assigns) do
+    Misc.validate_path_or_on_paginate!(assigns, @path_on_paginate_error_msg)
+    assigns
+  end
+
   @spec default_opts() :: [Flop.Phoenix.cursor_pagination_option()]
   def default_opts do
     [
@@ -74,62 +90,22 @@ defmodule Flop.Phoenix.CursorPagination do
     ]
   end
 
-  @spec init_assigns(map) :: map
-  def init_assigns(assigns) do
-    assigns =
-      assigns
-      |> assign(:opts, merge_opts(assigns[:opts] || []))
-      |> assign(:path, assigns[:path])
-
-    Misc.validate_path_or_event!(assigns, @path_event_error_msg)
-    assigns
-  end
-
-  defp merge_opts(opts) do
+  def merge_opts(opts) do
     default_opts()
     |> Misc.deep_merge(Misc.get_global_opts(:cursor_pagination))
     |> Misc.deep_merge(opts)
   end
 
-  attr :meta, Flop.Meta, required: true
-  attr :direction, :atom, required: true
-  attr :attrs, :list, required: true
-  attr :event, :string, required: true
-  attr :target, :string, required: true
-  attr :path, :any, required: true
-  attr :content, :any, required: true
-  attr :opts, :list, required: true
-
-  def render_link(assigns) do
-    ~H"""
-    <%= if show_link?(@meta, @direction) do %>
-      <%= if @event do %>
-        <.link
-          phx-click={@event}
-          phx-target={@target}
-          phx-value-to={@direction}
-          {@attrs}
-        >
-          <%= @content %>
-        </.link>
-      <% else %>
-        <.link patch={pagination_path(@direction, @path, @meta)} {@attrs}>
-          <%= @content %>
-        </.link>
-      <% end %>
-    <% else %>
-      <span {add_disabled_class(@attrs, @opts[:disabled_class])}>
-        <%= @content %>
-      </span>
-    <% end %>
-    """
-  end
+  # meta, direction, reverse
+  def show_link?(%Meta{has_previous_page?: true}, :previous, false), do: true
+  def show_link?(%Meta{has_next_page?: true}, :next, false), do: true
+  def show_link?(%Meta{has_previous_page?: true}, :next, true), do: true
+  def show_link?(%Meta{has_next_page?: true}, :previous, true), do: true
+  def show_link?(%Meta{}, _, _), do: false
 
-  defp show_link?(%Flop.Meta{has_previous_page?: true}, :previous), do: true
-  defp show_link?(%Flop.Meta{has_next_page?: true}, :next), do: true
-  defp show_link?(%Flop.Meta{}, _), do: false
+  def pagination_path(_, nil, _), do: nil
 
-  defp pagination_path(direction, path, %Flop.Meta{} = meta) do
+  def pagination_path(direction, path, %Flop.Meta{} = meta) do
     params =
       meta
       |> Flop.set_cursor(direction)
@@ -137,10 +113,4 @@ defmodule Flop.Phoenix.CursorPagination do
 
     Flop.Phoenix.build_path(path, params)
   end
-
-  defp add_disabled_class(attrs, disabled_class) do
-    Keyword.update(attrs, :class, disabled_class, fn class ->
-      class <> " " <> disabled_class
-    end)
-  end
 end
diff --git a/lib/flop_phoenix/misc.ex b/lib/flop_phoenix/misc.ex
index c5abd25..e346092 100644
--- a/lib/flop_phoenix/misc.ex
+++ b/lib/flop_phoenix/misc.ex
@@ -1,6 +1,8 @@
 defmodule Flop.Phoenix.Misc do
   @moduledoc false
 
+  alias Phoenix.LiveView.JS
+
   require Logger
 
   @doc """
@@ -102,6 +104,9 @@ defmodule Flop.Phoenix.Misc do
     |> Keyword.get(:opts)
   end
 
+  def click_cmd(on_paginate, nil), do: on_paginate
+  def click_cmd(on_paginate, path), do: JS.patch(on_paginate, path)
+
   @doc """
   Validates that either a path helper in the right format or an event are
   assigned, but not both.
@@ -123,4 +128,29 @@ defmodule Flop.Phoenix.Misc do
       do: :ok
 
   def validate_path_or_event!(_, error_msg), do: raise(ArgumentError, error_msg)
+
+  @doc """
+  Validates that either a path attribute or an on_paginate attribute is set.
+  """
+  def validate_path_or_on_paginate!(%{path: {module, function, args}}, _)
+      when is_atom(module) and is_atom(function) and is_list(args),
+      do: :ok
+
+  def validate_path_or_on_paginate!(%{path: {function, args}}, _)
+      when is_function(function) and is_list(args),
+      do: :ok
+
+  def validate_path_or_on_paginate!(%{path: path}, _)
+      when is_binary(path) or is_function(path, 1),
+      do: :ok
+
+  def validate_path_or_on_paginate!(%{on_paginate: %JS{}}, _),
+    do: :ok
+
+  def validate_path_or_on_paginate!(%{event: event}, _)
+      when is_binary(event),
+      do: :ok
+
+  def validate_path_or_on_paginate!(_, error_msg),
+    do: raise(ArgumentError, error_msg)
 end
diff --git a/lib/flop_phoenix/pagination.ex b/lib/flop_phoenix/pagination.ex
index b3e7df5..9d42922 100644
--- a/lib/flop_phoenix/pagination.ex
+++ b/lib/flop_phoenix/pagination.ex
@@ -1,56 +1,66 @@
 defmodule Flop.Phoenix.Pagination do
   @moduledoc false
 
-  use Phoenix.Component
-
   alias Flop.Phoenix.Misc
 
   require Logger
 
-  @path_event_error_msg """
-  the :path or :event option is required when rendering pagination
+  def path_on_paginate_error_msg do
+    """
+    path or on_paginate attribute is required
 
-  The :path value can be a path as a string, a
-  {module, function_name, args} tuple, a {function, args} tuple, or an 1-ary
-  function.
+    At least one of the mentioned attributes is required for the pagination
+    component. Combining them will append a JS.patch command to the on_paginate
+    command.
 
-  The :event value needs to be a string.
+    The :path value can be a path as a string, a
+    {module, function_name, args} tuple, a {function, args} tuple, or an 1-ary
+    function.
 
-  ## Example
+    ## Examples
 
-      <Flop.Phoenix.pagination
-        meta={@meta}
-        path={~p"/pets"}
-      />
+        <Flop.Phoenix.pagination
+          meta={@meta}
+          path={~p"/pets"}
+        />
 
-  or
+    or
 
-      <Flop.Phoenix.pagination
-        meta={@meta}
-        path={{Routes, :pet_path, [@socket, :index]}}
-      />
+        <Flop.Phoenix.pagination
+          meta={@meta}
+          path={{Routes, :pet_path, [@socket, :index]}}
+        />
 
-  or
+    or
 
-      <Flop.Phoenix.pagination
-        meta={@meta}
-        path={{&Routes.pet_path/3, [@socket, :index]}}
-      />
+        <Flop.Phoenix.pagination
+          meta={@meta}
+          path={{&Routes.pet_path/3, [@socket, :index]}}
+        />
 
-  or
+    or
 
-      <Flop.Phoenix.pagination
-        meta={@meta}
-        path={&build_path/1}
-      />
+        <Flop.Phoenix.pagination
+          meta={@meta}
+          path={&build_path/1}
+        />
 
-  or
+    or
 
-      <Flop.Phoenix.pagination
-        meta={@meta}
-        event="paginate"
-      />
-  """
+        <Flop.Phoenix.pagination
+          meta={@meta}
+          on_paginate={JS.push("paginate")}
+        />
+
+    or
+
+        <Flop.Phoenix.pagination
+          meta={@meta}
+          path={&build_path/1}
+          on_paginate={JS.dispatch("scroll-to", to: "#my-table")}
+        />
+    """
+  end
 
   @spec default_opts() :: [Flop.Phoenix.pagination_option()]
   def default_opts do
@@ -84,265 +94,17 @@ defmodule Flop.Phoenix.Pagination do
     ]
   end
 
-  @spec init_assigns(map) :: map
-  def init_assigns(assigns) do
-    assigns =
-      assigns
-      |> assign(:opts, merge_opts(assigns[:opts] || []))
-      |> assign(:path, assigns[:path])
-
-    Misc.validate_path_or_event!(assigns, @path_event_error_msg)
-    assigns
-  end
-
-  defp merge_opts(opts) do
+  def merge_opts(opts) do
     default_opts()
     |> Misc.deep_merge(Misc.get_global_opts(:pagination))
     |> Misc.deep_merge(opts)
   end
 
-  @spec render(map) :: Phoenix.LiveView.Rendered.t()
-
-  attr :meta, Flop.Meta, required: true
-  attr :page_link_helper, :any, required: true
-  attr :event, :string, required: true
-  attr :target, :string, required: true
-  attr :opts, :list, required: true
-
-  def render(assigns) do
-    ~H"""
-    <nav :if={@meta.errors == []} {@opts[:wrapper_attrs]}>
-      <.previous_link
-        attrs={@opts[:previous_link_attrs]}
-        content={@opts[:previous_link_content]}
-        event={@event}
-        meta={@meta}
-        page_link_helper={@page_link_helper}
-        opts={@opts}
-        target={@target}
-      />
-      <.next_link
-        attrs={@opts[:next_link_attrs]}
-        content={@opts[:next_link_content]}
-        event={@event}
-        meta={@meta}
-        page_link_helper={@page_link_helper}
-        opts={@opts}
-        target={@target}
-      />
-      <.page_links
-        event={@event}
-        meta={@meta}
-        page_link_helper={@page_link_helper}
-        opts={@opts}
-        target={@target}
-      />
-    </nav>
-    """
-  end
-
-  attr :meta, Flop.Meta, required: true
-  attr :page_link_helper, :any, required: true
-  attr :event, :string, required: true
-  attr :target, :string, required: true
-  attr :content, :any, required: true
-  attr :attrs, :list, required: true
-  attr :opts, :list, required: true
-
-  defp previous_link(assigns) do
-    ~H"""
-    <%= if @meta.has_previous_page? do %>
-      <%= if @event do %>
-        <.link
-          phx-click={@event}
-          phx-target={@target}
-          phx-value-page={@meta.previous_page}
-          {@attrs}
-        >
-          <%= @content %>
-        </.link>
-      <% else %>
-        <.link patch={@page_link_helper.(@meta.previous_page)} {@attrs}>
-          <%= @content %>
-        </.link>
-      <% end %>
-    <% else %>
-      <span {add_disabled_class(@attrs, @opts[:disabled_class])}>
-        <%= @content %>
-      </span>
-    <% end %>
-    """
-  end
-
-  attr :meta, Flop.Meta, required: true
-  attr :page_link_helper, :any, required: true
-  attr :event, :string, required: true
-  attr :target, :string, required: true
-  attr :content, :any, required: true
-  attr :attrs, :list, required: true
-  attr :opts, :list, required: true
-
-  defp next_link(assigns) do
-    ~H"""
-    <%= if @meta.has_next_page? do %>
-      <%= if @event do %>
-        <.link
-          phx-click={@event}
-          phx-target={@target}
-          phx-value-page={@meta.next_page}
-          {@attrs}
-        >
-          <%= @content %>
-        </.link>
-      <% else %>
-        <.link patch={@page_link_helper.(@meta.next_page)} {@attrs}>
-          <%= @content %>
-        </.link>
-      <% end %>
-    <% else %>
-      <span {add_disabled_class(@attrs, @opts[:disabled_class])}>
-        <%= @content %>
-      </span>
-    <% end %>
-    """
-  end
+  def max_pages(:all, total_pages), do: total_pages
+  def max_pages(:hide, _), do: 0
+  def max_pages({:ellipsis, max_pages}, _), do: max_pages
 
-  attr :meta, Flop.Meta, required: true
-  attr :page_link_helper, :any, required: true
-  attr :event, :string, required: true
-  attr :target, :string, required: true
-  attr :opts, :list, required: true
-
-  defp page_links(assigns) do
-    assigns =
-      assign(
-        assigns,
-        :max_pages,
-        max_pages(assigns.opts[:page_links], assigns.meta.total_pages)
-      )
-
-    ~H"""
-    <.render_page_links
-      :if={@opts[:page_links] != :hide}
-      event={@event}
-      meta={@meta}
-      page_link_helper={@page_link_helper}
-      opts={@opts}
-      range={
-        get_page_link_range(
-          @meta.current_page,
-          @max_pages,
-          @meta.total_pages
-        )
-      }
-      target={@target}
-    />
-    """
-  end
-
-  attr :meta, Flop.Meta, required: true
-  attr :page_link_helper, :any, required: true
-  attr :event, :string, required: true
-  attr :target, :string, required: true
-  attr :opts, :list, required: true
-  attr :range, :any, required: true
-
-  defp render_page_links(%{range: first..last} = assigns) do
-    assigns = assign(assigns, first: first, last: last)
-
-    ~H"""
-    <ul {@opts[:pagination_list_attrs]}>
-      <.page_link_tag
-        :if={@first > 1}
-        event={@event}
-        meta={@meta}
-        opts={@opts}
-        page={1}
-        page_link_helper={@page_link_helper}
-        target={@target}
-      />
-
-      <.pagination_ellipsis
-        :if={@first > 2}
-        attrs={@opts[:ellipsis_attrs]}
-        content={@opts[:ellipsis_content]}
-      />
-
-      <.page_link_tag
-        :for={page <- @range}
-        event={@event}
-        meta={@meta}
-        opts={@opts}
-        page={page}
-        page_link_helper={@page_link_helper}
-        target={@target}
-      />
-
-      <.pagination_ellipsis
-        :if={@last < @meta.total_pages - 1}
-        attrs={@opts[:ellipsis_attrs]}
-        content={@opts[:ellipsis_content]}
-      />
-
-      <.page_link_tag
-        :if={@last < @meta.total_pages}
-        event={@event}
-        meta={@meta}
-        opts={@opts}
-        page={@meta.total_pages}
-        page_link_helper={@page_link_helper}
-        target={@target}
-      />
-    </ul>
-    """
-  end
-
-  attr :meta, Flop.Meta, required: true
-  attr :page_link_helper, :any, required: true
-  attr :event, :string, required: true
-  attr :target, :string, required: true
-  attr :opts, :list, required: true
-  attr :page, :integer, required: true
-
-  defp page_link_tag(%{meta: meta, opts: opts, page: page} = assigns) do
-    assigns = assign(assigns, :attrs, attrs_for_page_link(page, meta, opts))
-
-    ~H"""
-    <%= if @event do %>
-      <li>
-        <.link
-          phx-click={@event}
-          phx-target={@target}
-          phx-value-page={@page}
-          {@attrs}
-        >
-          <%= @page %>
-        </.link>
-      </li>
-    <% else %>
-      <li>
-        <.link patch={@page_link_helper.(@page)} {@attrs}>
-          <%= @page %>
-        </.link>
-      </li>
-    <% end %>
-    """
-  end
-
-  attr :attrs, :list, required: true
-  attr :content, :any, required: true
-
-  defp pagination_ellipsis(assigns) do
-    ~H"""
-    <li><span {@attrs}><%= @content %></span></li>
-    """
-  end
-
-  defp max_pages(:all, total_pages), do: total_pages
-  defp max_pages(:hide, _), do: 0
-  defp max_pages({:ellipsis, max_pages}, _), do: max_pages
-
-  defp get_page_link_range(current_page, max_pages, total_pages) do
+  def get_page_link_range(current_page, max_pages, total_pages) do
     # number of additional pages to show before or after current page
     additional = ceil(max_pages / 2)
 
@@ -360,6 +122,8 @@ defmodule Flop.Phoenix.Pagination do
     end
   end
 
+  def build_page_link_helper(_meta, nil), do: fn _ -> nil end
+
   def build_page_link_helper(meta, path) do
     query_params = build_query_params(meta)
 
@@ -408,19 +172,12 @@ defmodule Flop.Phoenix.Pagination do
   defp maybe_put_page(params, 1), do: Keyword.delete(params, :page)
   defp maybe_put_page(params, page), do: Keyword.put(params, :page, page)
 
-  defp attrs_for_page_link(page, meta, opts) do
-    attrs =
-      if meta.current_page == page,
-        do: opts[:current_link_attrs],
-        else: opts[:pagination_link_attrs]
-
-    add_page_link_aria_label(attrs, page, opts)
+  def attrs_for_page_link(page, %{current_page: page}, opts) do
+    add_page_link_aria_label(opts[:current_link_attrs], page, opts)
   end
 
-  defp add_disabled_class(attrs, disabled_class) do
-    Keyword.update(attrs, :class, disabled_class, fn class ->
-      class <> " " <> disabled_class
-    end)
+  def attrs_for_page_link(page, _meta, opts) do
+    add_page_link_aria_label(opts[:pagination_link_attrs], page, opts)
   end
 
   defp add_page_link_aria_label(attrs, page, opts) do


Test patch:
diff --git a/test/flop_phoenix_test.exs b/test/flop_phoenix_test.exs
index 6507155..afa8f81 100644
--- a/test/flop_phoenix_test.exs
+++ b/test/flop_phoenix_test.exs
@@ -313,6 +313,49 @@ defmodule Flop.PhoenixTest do
       assert Floki.attribute(link, "href") == ["/pets?page_size=10"]
     end
 
+    test "uses phx-click with on_paginate without path" do
+      html =
+        render_pagination(
+          meta: build(:meta_on_second_page),
+          path: nil,
+          on_paginate: JS.push("paginate")
+        )
+
+      link = Floki.find(html, "a:fl-contains('Previous')")
+
+      assert Floki.attribute(link, "class") == ["pagination-previous"]
+      assert Floki.attribute(link, "data-phx-link") == []
+      assert Floki.attribute(link, "data-phx-link-state") == []
+      assert Floki.attribute(link, "href") == ["#"]
+      assert Floki.attribute(link, "phx-value-page") == ["1"]
+      assert [phx_click] = Floki.attribute(link, "phx-click")
+      assert Jason.decode!(phx_click) == [["push", %{"event" => "paginate"}]]
+    end
+
+    test "uses phx-click with on_paginate and path" do
+      html =
+        render_pagination(
+          meta: build(:meta_on_second_page),
+          path: "/pets",
+          on_paginate: JS.push("paginate")
+        )
+
+      link = Floki.find(html, "a:fl-contains('Previous')")
+      expected_path = "/pets?page_size=10"
+
+      assert Floki.attribute(link, "class") == ["pagination-previous"]
+      assert Floki.attribute(link, "data-phx-link") == []
+      assert Floki.attribute(link, "data-phx-link-state") == []
+      assert Floki.attribute(link, "href") == [expected_path]
+      assert Floki.attribute(link, "phx-value-page") == ["1"]
+      assert [phx_click] = Floki.attribute(link, "phx-click")
+
+      assert Jason.decode!(phx_click) == [
+               ["push", %{"event" => "paginate"}],
+               ["patch", %{"href" => "/pets?page_size=10", "replace" => false}]
+             ]
+    end
+
     test "supports a function/args tuple as path" do
       html =
         render_pagination(
@@ -796,9 +839,9 @@ defmodule Flop.PhoenixTest do
       assert Floki.attribute(link, "href") == ["#"]
     end
 
-    test "raises if neither path nor event are passed" do
+    test "raises if neither path nor event nor on_paginate are passed" do
       assert_raise ArgumentError,
-                   ~r/^the :path or :event option is required/,
+                   ~r/^path or on_paginate attribute is required/,
                    fn ->
                      render_component(&pagination/1,
                        __changed__: nil,
@@ -1049,7 +1092,7 @@ defmodule Flop.PhoenixTest do
         )
 
       assert [el, _] = Floki.find(html, "span[class='dotdotdot']")
-      assert Floki.text(el) == "dot dot dot"
+      assert el |> Floki.text() |> String.trim() == "dot dot dot"
     end
 
     test "always uses page/page_size" do
@@ -1124,6 +1167,59 @@ defmodule Flop.PhoenixTest do
       assert Floki.attribute(link, "href") == ["/pets?last=10&before=B"]
     end
 
+    test "uses phx-click with on_paginate without path" do
+      html =
+        render_cursor_pagination(
+          meta: build(:meta_with_cursors),
+          path: nil,
+          on_paginate: JS.push("paginate")
+        )
+
+      link = Floki.find(html, "a:fl-contains('Previous')")
+
+      assert Floki.attribute(link, "class") == ["pagination-previous"]
+      assert Floki.attribute(link, "data-phx-link") == []
+      assert Floki.attribute(link, "data-phx-link-state") == []
+      assert Floki.attribute(link, "href") == ["#"]
+      assert Floki.attribute(link, "phx-value-to") == ["previous"]
+      assert [phx_click] = Floki.attribute(link, "phx-click")
+      assert Jason.decode!(phx_click) == [["push", %{"event" => "paginate"}]]
+    end
+
+    test "uses phx-click with on_paginate and path" do
+      html =
+        render_cursor_pagination(
+          meta: build(:meta_with_cursors),
+          path: "/pets",
+          on_paginate: JS.push("paginate")
+        )
+
+      link = Floki.find(html, "a:fl-contains('Previous')")
+      expected_path = "/pets"
+      expected_query = %{"before" => "B", "last" => "10"}
+
+      assert Floki.attribute(link, "class") == ["pagination-previous"]
+      assert Floki.attribute(link, "data-phx-link") == []
+      assert Floki.attribute(link, "data-phx-link-state") == []
+      assert [href] = Floki.attribute(link, "href")
+
+      uri = URI.parse(href)
+      assert uri.path == expected_path
+      assert URI.decode_query(uri.query) == expected_query
+
+      assert Floki.attribute(link, "phx-value-to") == ["previous"]
+      assert [phx_click] = Floki.attribute(link, "phx-click")
+
+      assert [
+               ["push", %{"event" => "paginate"}],
+               ["patch", %{"href" => href, "replace" => false}]
+             ] = Jason.decode!(phx_click)
+
+      uri = URI.parse(href)
+      assert uri.path == expected_path
+      assert URI.decode_query(uri.query) == expected_query
+    end
+
     test "supports a function/args tuple as path" do
       html =
         render_cursor_pagination(
@@ -1393,7 +1489,7 @@ defmodule Flop.PhoenixTest do
 
     test "raises if neither path nor event are passed" do
       assert_raise ArgumentError,
-                   ~r/^the :path or :event option is required/,
+                   ~r/^path or on_paginate attribute is required/,
                    fn ->
                      render_component(&cursor_pagination/1,
                        __changed__: nil,