ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Data Table

Server-driven data table — sortable column headers + row selection. Composes Table + Pagination.

Installation

bin/rails g wabi:add data_table

Example

Click a column header to sort (server-driven via Turbo); use the checkboxes to select rows.

CustomerStatusAmount
Acme Co.Paid$250.00
GlobexPending$150.00
InitechPaid$350.00
UmbrellaOverdue$90.00

Source

app/components/ui/data_table.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    # Scope wrapper that hosts the wabi--data-table selection controller.
    # Compose: DataTable { Table { … DataTableColumnHeader / DataTableCheckbox … } + Pagination }.
    class DataTable < Wabi::Base
      def initialize(**attrs) = @attrs = attrs

      def view_template
        user_class = @attrs.delete(:class)
        div(data: { controller: "wabi--data-table" }, **@attrs, class: user_class) do
          yield if block_given?
          # Live region announces row-selection changes to screen reader users (WCAG 4.1.3).
          span(
            role: "status",
            class: "sr-only",
            data: { "wabi--data-table-target": "statusAnnouncer" }
          )
        end
      end
    end
  end
end

app/components/ui/data_table_column_header.rb

# frozen_string_literal: true

module Components
  module UI
    # Sortable column header — a plain link the server acts on. Place inside a
    # TableHead. `sorted:` is this column's current state (nil / :asc / :desc);
    # `href:` is the app-computed toggle target. No JS.
    class DataTableColumnHeader < Wabi::Base
      variants do
        base "inline-flex items-center gap-1 transition-colors motion-reduce:transition-none hover:text-foreground"
      end

      ICONS = {
        asc:  '<svg aria-hidden="true" focusable="false" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg>',
        desc: '<svg aria-hidden="true" focusable="false" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
        none: '<svg aria-hidden="true" focusable="false" class="h-3.5 w-3.5 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 9 12 5 16 9"/><polyline points="16 15 12 19 8 15"/></svg>',
      }.freeze

      # Sort direction announced to assistive tech (the icon is decorative). For the
      # full ARIA pattern also set aria-sort ("ascending"/"descending") on the
      # wrapping <th> (TableHead) — pass it from the same `sorted` state.
      SR_LABEL = { asc: ", sorted ascending", desc: ", sorted descending" }.freeze

      def initialize(href:, sorted: nil, **attrs)
        @href   = href
        @sorted = sorted
        @attrs  = attrs
      end

      def view_template(&)
        user_class = @attrs.delete(:class)
        a(href: @href, **@attrs, class: merge_class(tokens, user_class)) do
          yield
          raw(safe(ICONS[@sorted || :none]))
          span(class: "sr-only") { SR_LABEL[@sorted] } if SR_LABEL.key?(@sorted)
        end
      end
    end
  end
end

app/components/ui/data_table_checkbox.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    # Native, theme-styled checkbox for DataTable selection. NOT the Zag-backed
    # Checkbox — a plain <input> so a page of N rows doesn't spawn N machines and
    # the wabi--data-table controller can read/toggle them directly. `accent-primary`
    # themes the native check.
    class DataTableCheckbox < Wabi::Base
      variants do
        base "h-4 w-4 shrink-0 rounded-sm border border-primary accent-primary cursor-pointer " \
             "ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " \
             "disabled:cursor-not-allowed disabled:opacity-50"
      end

      def initialize(select_all: false, value: nil, checked: false, row_label: nil, **attrs)
        @select_all = select_all
        @value      = value
        @checked    = checked
        @row_label  = row_label
        @attrs      = attrs
      end

      def view_template
        user_class = @attrs.delete(:class)
        target = @select_all ? "selectAll" : "rowCheckbox"
        action = @select_all ? "change->wabi--data-table#toggleAll" : "change->wabi--data-table#toggleRow"
        # Per-row label uses row_label (display name) or falls back to value so
        # every checkbox has a unique accessible name — callers pass row_label: "Jane Doe"
        # or the value itself provides uniqueness when it is a human-readable ID.
        input(
          type: "checkbox",
          value: @value,
          checked: @checked,
          "aria-label": (@select_all ? "Select all rows" : "Select row #{@row_label || @value}"),
          data: { "wabi--data-table-target": target, action: action },
          **@attrs,
          class: merge_class(tokens, user_class)
        )
      end
    end
  end
end

app/javascript/controllers/wabi/data_table_controller.js

import { Controller } from "@hotwired/stimulus"

// Row-selection coordinator for a DataTable. Native checkboxes (no per-row Zag
// machine): a header "select all" + one per row. Toggling updates each row's
// data-state="selected" (TableRow styles data-[state=selected]:bg-muted), keeps
// the select-all checked/indeterminate in sync, and dispatches
// `wabi--data-table:change` with the selected row values for app-side bulk actions.
export default class extends Controller {
  static targets = ["selectAll", "rowCheckbox", "statusAnnouncer"]

  connect() {
    this.rowCheckboxTargets.forEach((cb) => this.syncRow(cb))
    this.syncSelectAll()
  }

  toggleAll() {
    const checked = this.hasSelectAllTarget ? this.selectAllTarget.checked : false
    this.rowCheckboxTargets.forEach((cb) => {
      cb.checked = checked
      this.syncRow(cb)
    })
    this.syncSelectAll()
    this.emitChange()
  }

  toggleRow() {
    this.rowCheckboxTargets.forEach((cb) => this.syncRow(cb))
    this.syncSelectAll()
    this.emitChange()
  }

  syncRow(cb) {
    const row = cb.closest("tr")
    if (!row) return
    if (cb.checked) row.dataset.state = "selected"
    else delete row.dataset.state
  }

  syncSelectAll() {
    if (!this.hasSelectAllTarget) return
    const total = this.rowCheckboxTargets.length
    const checked = this.rowCheckboxTargets.filter((cb) => cb.checked).length
    this.selectAllTarget.checked = total > 0 && checked === total
    this.selectAllTarget.indeterminate = checked > 0 && checked < total
  }

  emitChange() {
    const values = this.rowCheckboxTargets.filter((cb) => cb.checked).map((cb) => cb.value)
    const count = values.length
    this.dispatch("change", { detail: { values, count } })
    // Update the live region so screen readers announce selection changes (WCAG 4.1.3).
    if (this.hasStatusAnnouncerTarget) {
      this.statusAnnouncerTarget.textContent =
        count === 0 ? "No rows selected" : `${count} row${count === 1 ? "" : "s"} selected`
    }
  }
}

Accessibility

  • Sortable headers are real links — keyboard-focusable and announced as links; set aria-sort on the surrounding TableHead in your app if you want the sort state announced.
  • Selection checkboxes are native inputs with aria-label; the select-all reflects an indeterminate state when only some rows are checked.
  • Selected rows get data-state="selected" (TableRow styles it); the wabi--data-table:change event carries the selected values for bulk actions.