Skip to content

Instantly share code, notes, and snippets.

@julianrubisch
Last active February 12, 2026 15:32
Show Gist options
  • Select an option

  • Save julianrubisch/d8ce2874a90c42d0cc21361d8716f9a7 to your computer and use it in GitHub Desktop.

Select an option

Save julianrubisch/d8ce2874a90c42d0cc21361d8716f9a7 to your computer and use it in GitHub Desktop.
WaCombobox server-side pagination & filtering for Rails + Hotwire + Web Awesome

WaCombobox Server-Side Pagination & Filtering

Lazy-loaded, paginated, server-side filtered <wa-combobox> options for Rails 8 + Hotwire + Web Awesome — no extra gems required.

How it works

  1. On connect, the Stimulus controller fetches page 1 via Turbo Stream → turbo_stream.update replaces the combobox with the first N options plus a <turbo-frame loading="lazy"> sentinel at the bottom.
  2. When the user scrolls to the sentinel, Turbo auto-loads the next page → remove(old sentinel) + append(new options + new sentinel).
  3. When the user types, keyup->combobox#search fires (debounced), re-fetches with q= param, resetting to page 1.
  4. Filter param changes re-fetch page 1, replacing all content including stale sentinels.

Note: keyup is used instead of input because wa-combobox handles keyboard input inside its shadow DOM — the native input event does not bubble out to the light DOM, but keyup does.

Files

File Location
combobox_page.rb app/models/combobox_page.rb
combobox_pagination.rb app/controllers/concerns/combobox_pagination.rb
_combobox_page.turbo_stream.erb app/views/shared/_combobox_page.turbo_stream.erb
combobox_controller.js app/javascript/controllers/combobox_controller.js

Example usage

Controller

class TagsController < ApplicationController
  include ComboboxPagination

  def index
    collection = Label.all_top_tags  # returns { "ambient" => 12, "jazz" => 8, ... }
    collection = filter_combobox_collection(collection, params[:q])

    @combobox_page = paginate_for_combobox(
      collection.sort_by { |_k, v| -v },
      target: params[:target] || "tags",
      selected: Array.wrap(params[:selected]).compact_blank.map(&:to_s)
    )
    @combobox_page.next_page_path = tags_path(combobox_next_page_params(@combobox_page))

    respond_to do |format|
      format.turbo_stream { render layout: false }
    end
  end
end

Turbo Stream view (app/views/tags/index.turbo_stream.erb)

<%= render @combobox_page %>

ERB view using the combobox

<wa-combobox
  name="tags[]"
  id="tags"
  multiple
  placeholder="Choose tags"
  data-controller="combobox"
  data-action="change->faceted-search#perform:prevent keyup->combobox#search"
  data-combobox-async-src-value="<%= tags_path %>">
</wa-combobox>

Routes

resources :tags, only: :index

Collection shape

The concern expects a collection of [key, count] pairs — i.e. a Hash or array of 2-element arrays. The filter_combobox_collection helper does a case-insensitive substring match on the key.

If your collection has a different shape, filter and sort it before passing to paginate_for_combobox, and update _combobox_page.turbo_stream.erb to render your option labels accordingly.

<% if combobox_page.first_page? %>
<%= turbo_stream.update(combobox_page.target) do %>
<% combobox_page.items.each do |value, count| %>
<wa-option value="<%= value %>"<%= combobox_page.selected.include?(value) ? " selected" : "" %>>
<%= value %> (<%= count %>)
</wa-option>
<% end %>
<% if combobox_page.next_page %>
<turbo-frame id="<%= "#{combobox_page.target}-page-#{combobox_page.page}-sentinel" %>"
loading="lazy"
src="<%= combobox_page.next_page_path %>">
</turbo-frame>
<% end %>
<% end %>
<% else %>
<%= turbo_stream.remove "#{combobox_page.target}-page-#{combobox_page.page - 1}-sentinel" %>
<%= turbo_stream.append(combobox_page.target) do %>
<% combobox_page.items.each do |value, count| %>
<wa-option value="<%= value %>"<%= combobox_page.selected.include?(value) ? " selected" : "" %>>
<%= value %> (<%= count %>)
</wa-option>
<% end %>
<% if combobox_page.next_page %>
<turbo-frame id="<%= "#{combobox_page.target}-page-#{combobox_page.page}-sentinel" %>"
loading="lazy"
src="<%= combobox_page.next_page_path %>">
</turbo-frame>
<% end %>
<% end %>
<% end %>
import { Controller } from "@hotwired/stimulus";
import { get } from "@rails/request.js";
import { debounce } from "../helpers/timing_helpers";
export default class extends Controller {
static values = {
asyncSrc: String,
};
async connect() {
this.search = debounce(this.search.bind(this), 300);
this.element.disabled = true;
await this._fetch();
this.element.disabled = false;
}
// Wire up via: data-action="keyup->combobox#search"
// (keyup bubbles from wa-combobox shadow DOM; input does not)
async search() {
await this._fetch(this.element.inputValue);
}
async _fetch(q = "") {
const url = new URL(this.asyncSrcValue, window.location.origin);
if (q) url.searchParams.set("q", q);
await get(url.toString(), { responseKind: "turbo-stream" });
}
}
class ComboboxPage
include ActiveModel::API
PER_PAGE = 25
attr_accessor :page, :items, :next_page, :target, :selected, :next_page_path
def self.for(collection, page:, target:, selected:, per_page: PER_PAGE)
page = (page || 1).to_i
offset = (page - 1) * per_page
items = collection[offset, per_page] || []
next_page = collection.size > offset + per_page ? page + 1 : nil
new(page: page, items: items, next_page: next_page,
target: target, selected: selected)
end
def first_page? = page == 1
def to_partial_path
"shared/combobox_page"
end
end
module ComboboxPagination
extend ActiveSupport::Concern
private
def paginate_for_combobox(collection, target:, selected:, per_page: ComboboxPage::PER_PAGE)
ComboboxPage.for(
collection,
page: params[:page],
target: target,
selected: selected,
per_page: per_page
)
end
# Returns query params for the next page URL, preserving all current filters.
# Includes format: :turbo_stream so the sentinel <turbo-frame> request hits
# the correct respond_to branch (Turbo Frames send text/html by default).
# Call after paginate_for_combobox so combobox_page.next_page is available.
def combobox_next_page_params(combobox_page)
request.query_parameters.merge(page: combobox_page.next_page, format: :turbo_stream)
end
# Filters a { key => count } hash by a query string (case-insensitive substring match).
# Returns the collection unchanged if q is blank.
def filter_combobox_collection(collection, q)
return collection if q.blank?
collection.select { |key, _| key.to_s.downcase.include?(q.downcase) }
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment