Sortable table

Dependency-free sortable headers with caller-owned URLs and an optional Ransack recipe.

Source
app/components/nitro_kit/sortable_table.rb
API
NitroKit::SortableTable.new(current:, direction:) { |table| table.sortable_th(key, label, href:, align:) }

Sort state

Only the active column owns native aria-sort and a visible direction indicator. Unsorted tables stay neutral.

Ascending, descending, and unsorted

Ascending
Descending
Unsorted
ViewportFull width
Rubytest/dummy/app/components/gallery/components/sortable_table_page.rb
sample("Ascending", slug: "sortable-table-ascending") do
  render_state_table(current: :name, direction: :asc, id: "gallery-sortable-table-ascending")
end
sample("Descending", slug: "sortable-table-descending") do
  render_state_table(current: :seats, direction: :desc, id: "gallery-sortable-table-descending")
end
sample("Unsorted", slug: "sortable-table-unsorted") do
  render_state_table(current: nil, direction: nil, id: "gallery-sortable-table-unsorted")
end

Application-owned policy

The application supplies every URL and may use ordinary params, Ransack, or another query object without changing the component.

Inventory

ViewportFull width
Rubytest/dummy/app/components/gallery/components/sortable_table_page.rb
render NitroKit::SortableTable.new(
  current: :owner,
  direction: :asc,
  id: "gallery-sortable-table-inventory"
) do |table|
  table.caption("Workspace inventory ordered by owner")
  table.thead do
    table.tr do
      table.sortable_th(:name, "Workspace", href: "?sort=name-asc")
      table.sortable_th(:owner, href: "?sort=owner-desc")
      table.sortable_th(:status, href: "?sort=status-asc")
      table.sortable_th(:seats, href: "?sort=seats-asc", align: :right)
    end
  end
  table.tbody do
    Gallery::CatalogItem::EXAMPLES.first(4).each do |item|
      table.tr do
        table.th(item.fetch(:name), scope: :row)
        table.td(item.fetch(:owner))
        table.td { render_status(item.fetch(:status)) }
        table.td(item.fetch(:seats).to_s, align: :right)
      end
    end
  end
end

No matching rows

ViewportFull width
Rubytest/dummy/app/components/gallery/components/sortable_table_page.rb
render NitroKit::SortableTable.new(id: "gallery-sortable-table-empty") do |table|
  table.caption("No workspaces match the current filters")
  table.thead do
    table.tr do
      table.sortable_th(:name, "Workspace", href: "?sort=name-asc")
      table.sortable_th(:owner, href: "?sort=owner-asc")
      table.sortable_th(:seats, href: "?sort=seats-asc", align: :right)
    end
  end
  table.tbody do
    table.tr do
      table.td("Try a different name, owner, or status.", html: { colspan: 3 })
    end
  end
end

Optional Ransack recipe

This dummy application adapts a real Ransack::Search to Nitro links. Filtering, allowlists, and pagination remain application code. The native bulk controls intentionally do not submit; applications wire authorization and mutations themselves.

Workspace catalog

ViewportFull width
Rubytest/dummy/app/components/gallery/components/sortable_table_page.rb
search = Gallery::CatalogItem.ransack(ransack_parameters)
search.sorts = "name asc" if search.sorts.empty?
sort = Gallery::RansackSort.new(search, path: entry_path(entry), filters: ransack_parameters.except("s"))
relation = search.result(distinct: true)
total_count = relation.count
page = current_page(total_count)
records = relation.offset((page - 1) * PER_PAGE).limit(PER_PAGE)

turbo_frame_tag(FRAME_ID) do
  render NitroKit::Flex.new(dir: :col, gap: 6, align: :stretch, id: "gallery-sortable-table-recipe") do
    render_filter_form(sort)
    render_bulk_toolbar(records)
    render_results_table(records, sort)
    render_results_pagination(page:, total_count:, sort:)
  end
end