Dependency-free sortable headers with caller-owned URLs and an optional Ransack recipe.
app/components/nitro_kit/sortable_table.rbNitroKit::SortableTable.new(current:, direction:) { |table| table.sortable_th(key, label, href:, align:) }Only the active column owns native aria-sort and a visible direction indicator. Unsorted tables stay neutral.
test/dummy/app/components/gallery/components/sortable_table_page.rbsample("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")
endThe application supplies every URL and may use ordinary params, Ransack, or another query object without changing the component.
test/dummy/app/components/gallery/components/sortable_table_page.rbrender 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
endtest/dummy/app/components/gallery/components/sortable_table_page.rbrender 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
endThis 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.
| Select | Workspace | Owner | Status | Seats | Updated |
|---|---|---|---|---|---|
| Atlas Workspace NK-1001 | Ada Lovelace | Active | 42 | 2026-07-17 | |
| Northstar Energy NK-1014 | Gladys West | Active | 49 | 2026-07-17 | |
| Harbor Health NK-1008 | Mary Jackson | Active | 51 | 2026-07-17 | |
| Cedar Research NK-1003 | Katherine Johnson | Active | 64 | 2026-07-17 | |
| Lumen Education NK-1012 | Karen Spärck Jones | Active | 73 | 2026-07-17 |
Showing 11–15 of 15 workspaces
test/dummy/app/components/gallery/components/sortable_table_page.rbsearch = 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