render NitroKit::Container.new(size::sm, id:"gallery-table-constrained-container") do
render NitroKit::Table.new(id:"gallery-table-constrained") do |table|
table.caption("Invoice history in a constrained container")
table.thead do
table.tr do
table.th("Invoice")
table.th("Issued")
table.th("Status")
table.th("Amount", align::right)
table.th("Action", align::right)
endend
table.tbody doGallery::Data.invoices.each do |invoice|
table.tr do
table.th(invoice.number, scope::row)
table.td(invoice.issued_on.iso8601)
table.td(invoice.status.to_s.humanize)
table.td(format_amount(invoice), align::right)
table.td("View", align::right)
endendendendend
Empty state
An empty collection remains a valid table with a caption, headers, and one spanning message.
render NitroKit::Table.new(
id:"gallery-table-empty",
table_html: { id:"gallery-table-empty-element" }
) do |table|
table.caption("API credentials")
table.thead do
table.tr do
table.th("Name")
table.th("Access")
table.th("Last used", align::right)
endend
table.tbody do
table.tr do
table.td(
"No API credentials have been created.",
html: { colspan:3 }
)
endendend
Sort state
Only the active column owns native aria-sort and a direction indicator. Sortable but unsorted columns stay neutral with aria-sort="none".
sample("Ascending", slug:"table-sort-ascending") do
render_sort_state_table(sort::name, direction::asc, id:"gallery-table-sort-ascending")
end
sample("Descending", slug:"table-sort-descending") do
render_sort_state_table(sort::seats, direction::desc, id:"gallery-table-sort-descending")
end
sample("Unsorted", slug:"table-sort-unsorted") do
render_sort_state_table(sort:nil, direction:nil, id:"gallery-table-sort-unsorted")
end
Forty Gallery::Hostile members with 130-character emails, unbroken and mixed-script names, webhook URLs as link text, huge and negative amounts, and per-row action groups under sortable headers; then a hostile roster in a small container.
Hostile roster
Regional data residency, retention, and lawful-basis preferences for every workspace
render NitroKit::Container.new(size::sm, id:"gallery-table-hostile-narrow-container") do
render NitroKit::Table.new(id:"gallery-table-hostile-narrow") do |table|
table.caption("Hostile roster in a small container")
table.thead do
table.tr do
table.th("Member")
table.th("Email")
table.th("Balance", align::right)
endend
table.tbody doGallery::Hostile.members(9).each_with_index do |member, index|
table.tr do
table.th(member.name, scope::row)
table.td(member.email)
table.td(index.even? ? Gallery::Hostile::HUGE_MONEY : Gallery::Hostile::NEGATIVE_MONEY, align::right)
endendendendend
Component contract
Constructor options, rendered root, closed vocabularies, and compound boundary for this component, exactly as shipped.
div[data-nk=table] wrapping a native table; cell alignments left center right; header scopes col row; directions asc desc or nil
Compound contract
Every table uses its horizontal scroll wrapper and non-wrapping cells at all viewport widths; columns and controls retain their natural size. Two phases: caption, thead, and tbody collect declarations so the component owns caption → head → body order, while tr, th, and td render immediately inside a collected block and validate before emitting their own markup. Rows must be declared inside a head/body and cells inside a row; text and block content are mutually exclusive. align: :left is the default and emits no attribute. Sorting lives on Table itself; there is no separate SortableTable. th(text = nil, align: :left, scope: :col, sort: nil, href: nil, sort_data: {}) renders a caller-owned sort link with a unique key, falling back to the humanized sort key when no header text is given; href: without sort: raises and sort: requires a non-blank String href:. The active header owns aria-sort="ascending"/"descending" and a direction Icon, other sortable headers own aria-sort="none" and a neutral Icon. sort: must be declared inside thead. sort: and direction: are both set or both nil and mirror onto the root as data-sort and data-direction; direction: must be a Symbol or String. A declared caption receives a deterministic id (<root id>-caption, generated when the table has no id) and turns the scroll wrapper into a focusable role="region" labelled by it; without a caption the wrapper stays a plain div. table_html:, table_aria:, and table_data: address the inner table element.
Relevant patterns
Application conventions this component belongs to. Each summary is the leading section of its pattern document.
docs/patterns/queryable_collection.md
Queryable collection
Default to ordinary GET forms and links with Turbo Drive for a full-page collection. URL parameters are the state; keep Turbo's default caching.
An application query object owns allowlists, defaults, tenant scope, and page bounds; NitroKit::Table owns no query policy.
Pagination advances browser history; filters, reset, and sorting replace the current history entry.
Add a Turbo Frame only when the collection is an independently navigable region of a larger page, not merely to make pagination feel faster.
System rules for coding agents
Instructions for coding agents, repeated on every component page so one fetched page has enough context. Humans can usually skip this section.
test/dummy/app/components/gallery/agent_rules.rb
View agent instructions
Nitro Kit 2.0 is a gem-owned, Phlex-only UI system for Rails. Render components directly: render NitroKit::Button.new("Save", variant: :primary).
Compose Nitro components inside application-owned Phlex classes. Public initializers and declared slots are the component API; private methods are not.
Public options are explicit keywords, and enumerated options are closed vocabularies. Invalid names or values raise ArgumentError; this component's accepted options are in the contract above.
Pass native attributes through their typed boundary: html: for HTML, aria: for ARIA, and data: for application data. class and style are forbidden, including inside html:.
Nitro owns NitroKit::Component::RESERVED_DATA_ATTRIBUTES: data-nk, data-slot, data-variant, data-size, data-nk-escape, data-enhanced, data-state, data-disabled, data-required, data-orientation, data-presentation, data-placement, data-layout, data-side, data-field-type, data-dir, data-gap, data-align, data-justify, data-wrap, data-cols, data-mode, data-key. Do not pass them through data:. data-action, data-controller are additive and compose with Nitro behavior.
If an integration truly requires a class, use desperately_need_a_class:. It requires a non-blank String and marks the exception with data-nk-escape="class".
Every root emits data-nk; owned parts emit component-qualified data-slot values such as field-control or card-title. Select on those attributes, never on classes.
Customize components with documented --nk-* custom properties in an application stylesheet. Variables beginning with --_nk-* are private component mechanics.
Use NitroKit::Flex and NitroKit::Grid for layout. Parents own external placement and available width; components own their intrinsic geometry.
Preserve native elements and accessibility semantics. State is exposed through native semantics and ARIA first, and through data-state when styling or behavior also needs it.