From 3681497eada2a1eb634d45850d7fa2fd59eb4ec0 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Mon, 23 Mar 2026 17:14:44 -0300 Subject: [PATCH 01/52] feat: add DataTable docs page with interactive demo - Add DataTable component files and Stimulus controller - Add docs page with live search, sort, and pagination demo - Add demo controller with fake employee data - Add route, controller action, and menu entry --- .../ruby_ui/data_table/data_table.rb | 26 +++ .../ruby_ui/data_table/data_table_content.rb | 22 +++ .../data_table/data_table_pagination.rb | 60 ++++++ .../ruby_ui/data_table/data_table_per_page.rb | 37 ++++ .../ruby_ui/data_table/data_table_search.rb | 32 ++++ .../data_table/data_table_sortable_header.rb | 69 +++++++ .../ruby_ui/data_table/data_table_toolbar.rb | 17 ++ app/components/shared/menu.rb | 1 + .../docs/data_table_demo_controller.rb | 79 ++++++++ app/controllers/docs_controller.rb | 4 + app/javascript/controllers/index.js | 3 + .../ruby_ui/data_table_controller.js | 77 ++++++++ app/views/docs/data_table.rb | 172 ++++++++++++++++++ app/views/docs/data_table_demo/index.rb | 81 +++++++++ config/routes.rb | 2 + 15 files changed, 682 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table.rb create mode 100644 app/components/ruby_ui/data_table/data_table_content.rb create mode 100644 app/components/ruby_ui/data_table/data_table_pagination.rb create mode 100644 app/components/ruby_ui/data_table/data_table_per_page.rb create mode 100644 app/components/ruby_ui/data_table/data_table_search.rb create mode 100644 app/components/ruby_ui/data_table/data_table_sortable_header.rb create mode 100644 app/components/ruby_ui/data_table/data_table_toolbar.rb create mode 100644 app/controllers/docs/data_table_demo_controller.rb create mode 100644 app/javascript/controllers/ruby_ui/data_table_controller.js create mode 100644 app/views/docs/data_table.rb create mode 100644 app/views/docs/data_table_demo/index.rb diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb new file mode 100644 index 000000000..97245a0ee --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RubyUI + class DataTable < Base + def initialize(src: nil, **attrs) + @src = src + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "w-full space-y-4", + data: { + controller: "ruby-ui--data-table", + ruby_ui__data_table_src_value: @src + } + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_content.rb b/app/components/ruby_ui/data_table/data_table_content.rb new file mode 100644 index 000000000..ec752a391 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_content.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableContent < Base + def initialize(frame_id: "data_table_content", **attrs) + @frame_id = frame_id + super(**attrs) + end + + def view_template(&) + div(id: @frame_id, **attrs, &) + end + + private + + def default_attrs + { + class: "rounded-md border" + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_pagination.rb b/app/components/ruby_ui/data_table/data_table_pagination.rb new file mode 100644 index 000000000..66179e79f --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePagination < Base + def initialize(current_page:, total_pages:, **attrs) + @current_page = current_page + @total_pages = total_pages + super(**attrs) + end + + def view_template + div(**attrs) do + div(class: "flex items-center justify-between px-2") do + div(class: "flex-1 text-sm text-muted-foreground") do + plain "Page #{@current_page} of #{@total_pages}" + end + div(class: "flex items-center space-x-2") do + nav_button( + direction: "previous", + disabled: @current_page <= 1, + action: "click->ruby-ui--data-table#previousPage", + icon_path: "m15 18-6-6 6-6" + ) + nav_button( + direction: "next", + disabled: @current_page >= @total_pages, + action: "click->ruby-ui--data-table#nextPage", + icon_path: "m9 18 6-6-6-6" + ) + end + end + end + end + + private + + def nav_button(direction:, disabled:, action:, icon_path:) + button( + type: "button", + class: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground h-8 w-8 p-0 #{disabled ? "opacity-50 pointer-events-none" : ""}", + disabled: disabled, + aria_label: direction, + data: {action: action} + ) do + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "16", height: "16", viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", + stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round" + ) { |s| s.path(d: icon_path) } + end + end + + def default_attrs + { + class: "flex items-center justify-end py-4" + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_per_page.rb b/app/components/ruby_ui/data_table/data_table_per_page.rb new file mode 100644 index 000000000..1e045c55d --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_per_page.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePerPage < Base + def initialize(options: [10, 20, 50, 100], current: 10, **attrs) + @options = options + @current = current + super(**attrs) + end + + def view_template + div(**attrs) do + span(class: "text-sm text-muted-foreground") { "Rows per page" } + render RubyUI::NativeSelect.new( + size: :sm, + class: "w-16", + data: { + action: "change->ruby-ui--data-table#changePerPage", + ruby_ui__data_table_target: "perPage" + } + ) do + @options.each do |opt| + render RubyUI::NativeSelectOption.new(value: opt, selected: opt == @current) { opt.to_s } + end + end + end + end + + private + + def default_attrs + { + class: "flex items-center gap-2" + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_search.rb b/app/components/ruby_ui/data_table/data_table_search.rb new file mode 100644 index 000000000..1f433f248 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_search.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSearch < Base + def initialize(placeholder: "Search...", name: "search", **attrs) + @placeholder = placeholder + @name = name + super(**attrs) + end + + def view_template + render RubyUI::Input.new( + type: :search, + name: @name, + placeholder: @placeholder, + **attrs + ) + end + + private + + def default_attrs + { + class: "max-w-sm", + data: { + ruby_ui__data_table_target: "search", + action: "input->ruby-ui--data-table#search" + } + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_sortable_header.rb b/app/components/ruby_ui/data_table/data_table_sortable_header.rb new file mode 100644 index 000000000..e6823c7da --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_sortable_header.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSortableHeader < Base + def initialize(column:, label: nil, direction: nil, **attrs) + @column = column + @label = label || column.to_s.tr("_", " ").capitalize + @direction = direction # nil, "asc", or "desc" + super(**attrs) + end + + def view_template(&block) + th(**attrs) do + button( + type: "button", + class: "inline-flex items-center gap-1 hover:text-foreground", + data: { + action: "click->ruby-ui--data-table#sort", + ruby_ui__data_table_column_param: @column, + ruby_ui__data_table_direction_param: next_direction + } + ) do + if block + yield + else + plain @label + end + render_sort_icon + end + end + end + + private + + def next_direction + case @direction + when "asc" then "desc" + when "desc" then "" + else "asc" + end + end + + def render_sort_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "14", height: "14", + viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", + stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", + class: "ml-1 #{@direction ? "" : "text-muted-foreground"}" + ) do |s| + if @direction == "asc" + s.path(d: "m18 15-6-6-6 6") + elsif @direction == "desc" + s.path(d: "m6 9 6 6 6-6") + else + s.path(d: "m7 15 5 5 5-5") + s.path(d: "m7 9 5-5 5 5") + end + end + end + + def default_attrs + { + class: "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0" + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_toolbar.rb b/app/components/ruby_ui/data_table/data_table_toolbar.rb new file mode 100644 index 000000000..6c3c02dff --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_toolbar.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableToolbar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex items-center justify-between gap-2" + } + end + end +end diff --git a/app/components/shared/menu.rb b/app/components/shared/menu.rb index 3cb1150b1..9f77bfb8b 100644 --- a/app/components/shared/menu.rb +++ b/app/components/shared/menu.rb @@ -83,6 +83,7 @@ def components {name: "Combobox", path: docs_combobox_path, badge: "Updated"}, {name: "Command", path: docs_command_path}, {name: "Context Menu", path: docs_context_menu_path}, + {name: "Data Table", path: docs_data_table_path, badge: "New"}, {name: "Date Picker", path: docs_date_picker_path}, {name: "Dialog / Modal", path: docs_dialog_path}, {name: "Dropdown Menu", path: docs_dropdown_menu_path}, diff --git a/app/controllers/docs/data_table_demo_controller.rb b/app/controllers/docs/data_table_demo_controller.rb new file mode 100644 index 000000000..2ba854f19 --- /dev/null +++ b/app/controllers/docs/data_table_demo_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Docs + class DataTableDemoController < ApplicationController + EMPLOYEES = [ + {id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000}, + {id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000}, + {id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000}, + {id: 6, name: "Frank Miller", email: "frank@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 7, name: "Grace Lee", email: "grace@example.com", department: "HR", status: "Active", salary: 71_000}, + {id: 8, name: "Henry Wilson", email: "henry@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 9, name: "Iris Martinez", email: "iris@example.com", department: "Design", status: "Inactive", salary: 79_000}, + {id: 10, name: "Jack Taylor", email: "jack@example.com", department: "Engineering", status: "Active", salary: 110_000}, + {id: 11, name: "Karen Anderson", email: "karen@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 12, name: "Liam Thomas", email: "liam@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 13, name: "Mia Jackson", email: "mia@example.com", department: "Engineering", status: "On Leave", salary: 96_000}, + {id: 14, name: "Noah Harris", email: "noah@example.com", department: "Finance", status: "Active", salary: 89_000}, + {id: 15, name: "Olivia Clark", email: "olivia@example.com", department: "HR", status: "Active", salary: 68_000}, + {id: 16, name: "Paul Lewis", email: "paul@example.com", department: "Design", status: "Active", salary: 84_000}, + {id: 17, name: "Quinn Robinson", email: "quinn@example.com", department: "Engineering", status: "Active", salary: 105_000}, + {id: 18, name: "Rachel Walker", email: "rachel@example.com", department: "Product", status: "Inactive", salary: 87_000}, + {id: 19, name: "Sam Young", email: "sam@example.com", department: "Marketing", status: "Active", salary: 72_000}, + {id: 20, name: "Tina Hall", email: "tina@example.com", department: "Finance", status: "Active", salary: 91_000}, + {id: 21, name: "Uma Allen", email: "uma@example.com", department: "Engineering", status: "Active", salary: 99_000}, + {id: 22, name: "Victor Scott", email: "victor@example.com", department: "Design", status: "On Leave", salary: 81_000}, + {id: 23, name: "Wendy Green", email: "wendy@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 24, name: "Xander Baker", email: "xander@example.com", department: "Engineering", status: "Active", salary: 108_000}, + {id: 25, name: "Yara Adams", email: "yara@example.com", department: "Product", status: "Active", salary: 93_000}, + {id: 26, name: "Zoe Nelson", email: "zoe@example.com", department: "Marketing", status: "Inactive", salary: 73_000}, + {id: 27, name: "Aaron Carter", email: "aaron@example.com", department: "Finance", status: "Active", salary: 86_000}, + {id: 28, name: "Bella Mitchell", email: "bella@example.com", department: "Engineering", status: "Active", salary: 101_000}, + {id: 29, name: "Carlos Perez", email: "carlos@example.com", department: "Design", status: "Active", salary: 83_000}, + {id: 30, name: "Diana Roberts", email: "diana@example.com", department: "Product", status: "Active", salary: 90_000} + ].map { |e| Data.define(*e.keys).new(**e) }.freeze + + def index + employees = EMPLOYEES.dup + + # Search + if params[:search].present? + query = params[:search].downcase + employees = employees.select do |e| + e.name.downcase.include?(query) || e.email.downcase.include?(query) + end + end + + # Sort + if params[:sort].present? + col = params[:sort].to_sym + employees = employees.sort_by { |e| e.send(col).to_s.downcase } rescue employees + employees = employees.reverse if params[:direction] == "desc" + end + + @total_count = employees.size + @per_page = (params[:per_page] || 10).to_i.clamp(1, 100) + @current_page = (params[:page] || 1).to_i.clamp(1, Float::INFINITY) + @total_pages = [(@total_count.to_f / @per_page).ceil, 1].max + @current_page = [@current_page, @total_pages].min + + offset = (@current_page - 1) * @per_page + @employees = employees.slice(offset, @per_page) || [] + + @sort = params[:sort] + @direction = params[:direction] + + render Views::Docs::DataTableDemo::Index.new( + employees: @employees, + current_page: @current_page, + total_pages: @total_pages, + total_count: @total_count, + per_page: @per_page, + sort: @sort, + direction: @direction + ) + end + end +end diff --git a/app/controllers/docs_controller.rb b/app/controllers/docs_controller.rb index 92382c692..bafa2bad7 100644 --- a/app/controllers/docs_controller.rb +++ b/app/controllers/docs_controller.rb @@ -190,6 +190,10 @@ def switch render Views::Docs::Switch.new end + def data_table + render Views::Docs::DataTable.new + end + def table render Views::Docs::Table.new end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index e92a12d63..01d4fedd9 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -43,6 +43,9 @@ application.register("ruby-ui--command", RubyUi__CommandController) import RubyUi__ContextMenuController from "./ruby_ui/context_menu_controller" application.register("ruby-ui--context-menu", RubyUi__ContextMenuController) +import RubyUi__DataTableController from "./ruby_ui/data_table_controller" +application.register("ruby-ui--data-table", RubyUi__DataTableController) + import RubyUi__DialogController from "./ruby_ui/dialog_controller" application.register("ruby-ui--dialog", RubyUi__DialogController) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js new file mode 100644 index 000000000..0017fc9a9 --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -0,0 +1,77 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["search", "perPage"] + static values = { + src: String, + sortColumn: String, + sortDirection: String, + page: { type: Number, default: 1 }, + perPage: { type: Number, default: 10 }, + searchQuery: String, + debounceMs: { type: Number, default: 300 } + } + + connect() { + this.searchTimeout = null + } + + disconnect() { + if (this.searchTimeout) clearTimeout(this.searchTimeout) + } + + sort(event) { + const { column, direction } = event.params + this.sortColumnValue = column + this.sortDirectionValue = direction || "" + this.pageValue = 1 + this._reload() + } + + search() { + if (this.searchTimeout) clearTimeout(this.searchTimeout) + this.searchTimeout = setTimeout(() => { + this.searchQueryValue = this.searchTarget.value + this.pageValue = 1 + this._reload() + }, this.debounceMsValue) + } + + nextPage() { + this.pageValue += 1 + this._reload() + } + + previousPage() { + if (this.pageValue > 1) { + this.pageValue -= 1 + this._reload() + } + } + + changePerPage() { + this.perPageValue = parseInt(this.perPageTarget.value) + this.pageValue = 1 + this._reload() + } + + _reload() { + if (!this.hasSrcValue || !this.srcValue) return + + const url = new URL(this.srcValue, window.location.origin) + if (this.sortColumnValue) url.searchParams.set("sort", this.sortColumnValue) + if (this.sortDirectionValue) url.searchParams.set("direction", this.sortDirectionValue) + if (this.searchQueryValue) url.searchParams.set("search", this.searchQueryValue) + url.searchParams.set("page", this.pageValue) + url.searchParams.set("per_page", this.perPageValue) + + // Use Turbo to fetch and replace the content frame + const frame = this.element.querySelector("turbo-frame") + if (frame) { + frame.src = url.toString() + } else { + // Fallback: dispatch custom event for consumer to handle + this.dispatch("navigate", { detail: { url: url.toString() } }) + } + } +} diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb new file mode 100644 index 000000000..c547a77dd --- /dev/null +++ b/app/views/docs/data_table.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +class Views::Docs::DataTable < Views::Base + include Phlex::Rails::Helpers::TurboFrameTag + + Employee = Struct.new(:id, :name, :email, :department, :status, :salary, keyword_init: true) + + def view_template + component = "DataTable" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: component, + description: "A powerful data table component with sorting, searching, and pagination support.") + + Heading(level: 2) { "Interactive Demo" } + + p(class: "text-sm text-muted-foreground -mt-6") { + "Live example with search, sort, and pagination using fake employee data." + } + + div(class: "rounded-lg border p-6 space-y-4") do + DataTable(src: "/docs/data_table/demo") do + DataTableToolbar do + DataTableSearch(placeholder: "Search by name or email...") + DataTablePerPage(options: [5, 10, 20], current: 10) + end + turbo_frame_tag "data_table_content" do + div(class: "rounded-md border") do + Table do + TableHeader do + TableRow do + DataTableSortableHeader(column: "name", label: "Name") + DataTableSortableHeader(column: "email", label: "Email") + DataTableSortableHeader(column: "department", label: "Department") + TableHead { "Status" } + DataTableSortableHeader(column: "salary", label: "Salary") + end + end + TableBody do + initial_employees.each do |employee| + TableRow do + TableCell(class: "font-medium") { employee.name } + TableCell(class: "text-muted-foreground") { employee.email } + TableCell { employee.department } + TableCell { status_badge(employee.status) } + TableCell { format_salary(employee.salary) } + end + end + end + end + end + div(class: "flex items-center justify-between px-2 py-4") do + div(class: "text-sm text-muted-foreground") { "Showing 10 of 30 results" } + DataTablePagination(current_page: 1, total_pages: 3) + end + end + end + end + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Basic Example", context: self) do + <<~RUBY + DataTable do + DataTableToolbar do + DataTableSearch(placeholder: "Search employees...") + end + DataTableContent do + Table do + TableHeader do + TableRow do + DataTableSortableHeader(column: "name", label: "Name") + DataTableSortableHeader(column: "department", label: "Department") + TableHead { "Status" } + DataTableSortableHeader(column: "salary", label: "Salary") + end + end + TableBody do + employees.each do |employee| + TableRow do + TableCell(class: "font-medium") { employee.name } + TableCell { employee.department } + TableCell { employee.status } + TableCell { format_salary(employee.salary) } + end + end + end + end + end + DataTablePagination(current_page: 1, total_pages: 3) + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With Per Page Selector", context: self) do + <<~RUBY + DataTable do + DataTableToolbar do + DataTableSearch(placeholder: "Search...") + DataTablePerPage(options: [10, 20, 50], current: 10) + end + DataTableContent do + Table do + TableHeader do + TableRow do + DataTableSortableHeader(column: "name", label: "Name") + DataTableSortableHeader(column: "department", label: "Department") + TableHead { "Status" } + end + end + TableBody do + employees.each do |employee| + TableRow do + TableCell(class: "font-medium") { employee.name } + TableCell { employee.department } + TableCell { employee.status } + end + end + end + end + end + DataTablePagination(current_page: 1, total_pages: 5) + end + RUBY + end + + render Components::ComponentSetup::Tabs.new(component_name: component) + + render Docs::ComponentsTable.new(component_files(component)) + end + end + + private + + def initial_employees + [ + Employee.new(id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000), + Employee.new(id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000), + Employee.new(id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000), + Employee.new(id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000), + Employee.new(id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000), + Employee.new(id: 6, name: "Frank Miller", email: "frank@example.com", department: "Engineering", status: "Active", salary: 98_000), + Employee.new(id: 7, name: "Grace Lee", email: "grace@example.com", department: "HR", status: "Active", salary: 71_000), + Employee.new(id: 8, name: "Henry Wilson", email: "henry@example.com", department: "Finance", status: "Active", salary: 85_000), + Employee.new(id: 9, name: "Iris Martinez", email: "iris@example.com", department: "Design", status: "Inactive", salary: 79_000), + Employee.new(id: 10, name: "Jack Taylor", email: "jack@example.com", department: "Engineering", status: "Active", salary: 110_000) + ] + end + + def employees + [ + Employee.new(id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000), + Employee.new(id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000), + Employee.new(id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000), + Employee.new(id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000), + Employee.new(id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000) + ] + end + + def format_salary(amount) + "$#{amount.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse}" + end + + def status_badge(status) + color = case status + when "Active" then "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" + when "Inactive" then "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" + else "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + end + span(class: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{color}") { status } + end +end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb new file mode 100644 index 000000000..ff6483f23 --- /dev/null +++ b/app/views/docs/data_table_demo/index.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Views + module Docs + module DataTableDemo + class Index < Views::Base + include Phlex::Rails::Helpers::TurboFrameTag + + def initialize(employees:, current_page:, total_pages:, total_count:, per_page:, sort:, direction:) + @employees = employees + @current_page = current_page + @total_pages = total_pages + @total_count = total_count + @per_page = per_page + @sort = sort + @direction = direction + end + + def view_template + turbo_frame_tag "data_table_content" do + div(class: "rounded-md border") do + Table do + TableHeader do + TableRow do + DataTableSortableHeader(column: "name", label: "Name", direction: col_direction("name")) + DataTableSortableHeader(column: "email", label: "Email", direction: col_direction("email")) + DataTableSortableHeader(column: "department", label: "Department", direction: col_direction("department")) + TableHead { "Status" } + DataTableSortableHeader(column: "salary", label: "Salary", direction: col_direction("salary")) + end + end + TableBody do + if @employees.empty? + TableRow do + TableCell(colspan: 5, class: "text-center text-muted-foreground py-8") { "No results found." } + end + else + @employees.each do |employee| + TableRow do + TableCell(class: "font-medium") { employee.name } + TableCell(class: "text-muted-foreground") { employee.email } + TableCell { employee.department } + TableCell { status_badge(employee.status) } + TableCell { format_salary(employee.salary) } + end + end + end + end + end + end + div(class: "flex items-center justify-between px-2 py-4") do + div(class: "text-sm text-muted-foreground") do + plain "Showing #{@employees.size} of #{@total_count} results" + end + DataTablePagination(current_page: @current_page, total_pages: @total_pages) + end + end + end + + private + + def col_direction(col) + @sort == col ? @direction : nil + end + + def format_salary(amount) + "$#{amount.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse}" + end + + def status_badge(status) + color = case status + when "Active" then "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" + when "Inactive" then "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" + else "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + end + span(class: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{color}") { status } + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index a6da0ff23..94c5557bf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,8 @@ get "sidebar/inset", to: "docs/sidebar#inset_example", as: :docs_sidebar_inset get "skeleton", to: "docs#skeleton", as: :docs_skeleton get "switch", to: "docs#switch", as: :docs_switch + get "data_table", to: "docs#data_table", as: :docs_data_table + get "data_table/demo", to: "docs/data_table_demo#index", as: :docs_data_table_demo get "table", to: "docs#table", as: :docs_table get "tabs", to: "docs#tabs", as: :docs_tabs get "textarea", to: "docs#textarea", as: :docs_textarea From 041e3d1d91354c0b8299664176af339460f51a09 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:43:04 -0300 Subject: [PATCH 02/52] chore: add @tanstack/table-core dependency --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index f3f293b63..1d78e98d1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "@hotwired/turbo-rails": "8.0.23", "@tailwindcss/forms": "0.5.11", "@tailwindcss/typography": "0.5.19", + "@tanstack/table-core": "^8.21.3", "autoprefixer": "10.4.27", "chart.js": "4.5.1", "class-variance-authority": "0.7.1", From dccb5e2d509fbafabbd20c97d44cea302c8888f4 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:44:52 -0300 Subject: [PATCH 03/52] chore: gitignore local superpowers plans folder --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 011574f2a..20a6471cf 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ yarn-error.log # Pnpm .pnpm-store +docs/superpowers/ From 460528d44f409b5e6658eaf4631070f48c6311ce Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:47:26 -0300 Subject: [PATCH 04/52] refactor(data_table): remove DataTableSortableHeader (moves to JS) --- .../data_table/data_table_sortable_header.rb | 69 ------------------- app/views/docs/data_table.rb | 18 ++--- app/views/docs/data_table_demo/index.rb | 8 +-- 3 files changed, 13 insertions(+), 82 deletions(-) delete mode 100644 app/components/ruby_ui/data_table/data_table_sortable_header.rb diff --git a/app/components/ruby_ui/data_table/data_table_sortable_header.rb b/app/components/ruby_ui/data_table/data_table_sortable_header.rb deleted file mode 100644 index e6823c7da..000000000 --- a/app/components/ruby_ui/data_table/data_table_sortable_header.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class DataTableSortableHeader < Base - def initialize(column:, label: nil, direction: nil, **attrs) - @column = column - @label = label || column.to_s.tr("_", " ").capitalize - @direction = direction # nil, "asc", or "desc" - super(**attrs) - end - - def view_template(&block) - th(**attrs) do - button( - type: "button", - class: "inline-flex items-center gap-1 hover:text-foreground", - data: { - action: "click->ruby-ui--data-table#sort", - ruby_ui__data_table_column_param: @column, - ruby_ui__data_table_direction_param: next_direction - } - ) do - if block - yield - else - plain @label - end - render_sort_icon - end - end - end - - private - - def next_direction - case @direction - when "asc" then "desc" - when "desc" then "" - else "asc" - end - end - - def render_sort_icon - svg( - xmlns: "http://www.w3.org/2000/svg", - width: "14", height: "14", - viewBox: "0 0 24 24", - fill: "none", stroke: "currentColor", - stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", - class: "ml-1 #{@direction ? "" : "text-muted-foreground"}" - ) do |s| - if @direction == "asc" - s.path(d: "m18 15-6-6-6 6") - elsif @direction == "desc" - s.path(d: "m6 9 6 6 6-6") - else - s.path(d: "m7 15 5 5 5-5") - s.path(d: "m7 9 5-5 5 5") - end - end - end - - def default_attrs - { - class: "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0" - } - end - end -end diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index c547a77dd..6a014c897 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -29,11 +29,11 @@ def view_template Table do TableHeader do TableRow do - DataTableSortableHeader(column: "name", label: "Name") - DataTableSortableHeader(column: "email", label: "Email") - DataTableSortableHeader(column: "department", label: "Department") + TableHead { "Name" } + TableHead { "Email" } + TableHead { "Department" } TableHead { "Status" } - DataTableSortableHeader(column: "salary", label: "Salary") + TableHead { "Salary" } end end TableBody do @@ -69,10 +69,10 @@ def view_template Table do TableHeader do TableRow do - DataTableSortableHeader(column: "name", label: "Name") - DataTableSortableHeader(column: "department", label: "Department") + TableHead { "Name" } + TableHead { "Department" } TableHead { "Status" } - DataTableSortableHeader(column: "salary", label: "Salary") + TableHead { "Salary" } end end TableBody do @@ -103,8 +103,8 @@ def view_template Table do TableHeader do TableRow do - DataTableSortableHeader(column: "name", label: "Name") - DataTableSortableHeader(column: "department", label: "Department") + TableHead { "Name" } + TableHead { "Department" } TableHead { "Status" } end end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index ff6483f23..d850aa062 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -22,11 +22,11 @@ def view_template Table do TableHeader do TableRow do - DataTableSortableHeader(column: "name", label: "Name", direction: col_direction("name")) - DataTableSortableHeader(column: "email", label: "Email", direction: col_direction("email")) - DataTableSortableHeader(column: "department", label: "Department", direction: col_direction("department")) + TableHead { "Name" } + TableHead { "Email" } + TableHead { "Department" } TableHead { "Status" } - DataTableSortableHeader(column: "salary", label: "Salary", direction: col_direction("salary")) + TableHead { "Salary" } end end TableBody do From da21dd03a35b9c400e1d31d66d2bfa7134ca74f2 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:50:09 -0300 Subject: [PATCH 05/52] feat(data_table): serialize data/columns to Stimulus values --- app/components/ruby_ui/data_table/data_table.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index 97245a0ee..5d1e9d79d 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -2,8 +2,15 @@ module RubyUI class DataTable < Base - def initialize(src: nil, **attrs) + # @param data [Array] current page rows (each row is a hash keyed by column key) + # @param columns [Array] column definitions: [{ key: "name", header: "Name" }, ...] + # @param src [String, nil] URL for JSON fetches on state change (unused in skeleton; reserved for Plan 02) + # @param row_count [Integer] total rows across all pages (unused in skeleton; reserved for Plan 02) + def initialize(data: [], columns: [], src: nil, row_count: 0, **attrs) + @data = data + @columns = columns @src = src + @row_count = row_count super(**attrs) end @@ -18,7 +25,10 @@ def default_attrs class: "w-full space-y-4", data: { controller: "ruby-ui--data-table", - ruby_ui__data_table_src_value: @src + ruby_ui__data_table_src_value: @src.to_s, + ruby_ui__data_table_data_value: @data.to_json, + ruby_ui__data_table_columns_value: @columns.to_json, + ruby_ui__data_table_row_count_value: @row_count } } end From a99fbe4357e6a04e9027dc17a609910c66ddcc0a Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:50:29 -0300 Subject: [PATCH 06/52] feat(data_table): DataTableContent renders empty table shell with Stimulus targets --- .../ruby_ui/data_table/data_table_content.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_content.rb b/app/components/ruby_ui/data_table/data_table_content.rb index ec752a391..796f83c77 100644 --- a/app/components/ruby_ui/data_table/data_table_content.rb +++ b/app/components/ruby_ui/data_table/data_table_content.rb @@ -2,13 +2,13 @@ module RubyUI class DataTableContent < Base - def initialize(frame_id: "data_table_content", **attrs) - @frame_id = frame_id - super(**attrs) - end - - def view_template(&) - div(id: @frame_id, **attrs, &) + def view_template + div(**attrs) do + table(class: "w-full caption-bottom text-sm") do + thead(class: "[&_tr]:border-b", data: {ruby_ui__data_table_target: "thead"}) + tbody(class: "[&_tr:last-child]:border-0", data: {ruby_ui__data_table_target: "tbody"}) + end + end end private From 32264f2900240a75a8cc83416bcbd4d10b5aba41 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:50:50 -0300 Subject: [PATCH 07/52] feat(data_table): Stimulus controller renders via TanStack Table Core --- .../ruby_ui/data_table_controller.js | 111 +++++++++--------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 0017fc9a9..41139ace3 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -1,77 +1,78 @@ import { Controller } from "@hotwired/stimulus" +import { createTable, getCoreRowModel } from "@tanstack/table-core" export default class extends Controller { - static targets = ["search", "perPage"] + static targets = ["thead", "tbody"] static values = { src: String, - sortColumn: String, - sortDirection: String, - page: { type: Number, default: 1 }, - perPage: { type: Number, default: 10 }, - searchQuery: String, - debounceMs: { type: Number, default: 300 } + data: { type: Array, default: [] }, + columns: { type: Array, default: [] }, + rowCount: { type: Number, default: 0 } } connect() { - this.searchTimeout = null - } + this.table = createTable({ + data: this.dataValue, + columns: this.columnsValue.map((c) => ({ + id: c.key, + accessorKey: c.key, + header: c.header + })), + getCoreRowModel: getCoreRowModel(), + state: {}, + onStateChange: () => {}, + renderFallbackValue: null + }) - disconnect() { - if (this.searchTimeout) clearTimeout(this.searchTimeout) + this.render() } - sort(event) { - const { column, direction } = event.params - this.sortColumnValue = column - this.sortDirectionValue = direction || "" - this.pageValue = 1 - this._reload() + render() { + this.renderHeaders() + this.renderRows() } - search() { - if (this.searchTimeout) clearTimeout(this.searchTimeout) - this.searchTimeout = setTimeout(() => { - this.searchQueryValue = this.searchTarget.value - this.pageValue = 1 - this._reload() - }, this.debounceMsValue) - } + renderHeaders() { + if (!this.hasTheadTarget) return - nextPage() { - this.pageValue += 1 - this._reload() - } + const html = this.table.getHeaderGroups().map((group) => { + const cells = group.headers.map((header) => { + const def = header.column.columnDef.header + const label = typeof def === "function" ? def(header.getContext()) : def + return `${escapeHtml(label ?? "")}` + }).join("") + return `${cells}` + }).join("") - previousPage() { - if (this.pageValue > 1) { - this.pageValue -= 1 - this._reload() - } + this.theadTarget.innerHTML = html } - changePerPage() { - this.perPageValue = parseInt(this.perPageTarget.value) - this.pageValue = 1 - this._reload() - } + renderRows() { + if (!this.hasTbodyTarget) return - _reload() { - if (!this.hasSrcValue || !this.srcValue) return + const rows = this.table.getRowModel().rows + if (rows.length === 0) { + this.tbodyTarget.innerHTML = `No results.` + return + } - const url = new URL(this.srcValue, window.location.origin) - if (this.sortColumnValue) url.searchParams.set("sort", this.sortColumnValue) - if (this.sortDirectionValue) url.searchParams.set("direction", this.sortDirectionValue) - if (this.searchQueryValue) url.searchParams.set("search", this.searchQueryValue) - url.searchParams.set("page", this.pageValue) - url.searchParams.set("per_page", this.perPageValue) + const html = rows.map((row) => { + const cells = row.getVisibleCells().map((cell) => { + const value = cell.getValue() + return `${escapeHtml(value ?? "")}` + }).join("") + return `${cells}` + }).join("") - // Use Turbo to fetch and replace the content frame - const frame = this.element.querySelector("turbo-frame") - if (frame) { - frame.src = url.toString() - } else { - // Fallback: dispatch custom event for consumer to handle - this.dispatch("navigate", { detail: { url: url.toString() } }) - } + this.tbodyTarget.innerHTML = html } } + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} From de6d27541ebbf673b9e497232da4cafc0398a878 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:51:44 -0300 Subject: [PATCH 08/52] feat(docs): rebuild DataTable demo on skeleton API --- app/views/docs/data_table.rb | 169 +++++------------------------------ 1 file changed, 24 insertions(+), 145 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 6a014c897..7a46f84bf 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -1,172 +1,51 @@ # frozen_string_literal: true class Views::Docs::DataTable < Views::Base - include Phlex::Rails::Helpers::TurboFrameTag - - Employee = Struct.new(:id, :name, :email, :department, :status, :salary, keyword_init: true) + Employee = Struct.new(:id, :name, :email, :department, keyword_init: true) def view_template component = "DataTable" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do - render Docs::Header.new(title: component, - description: "A powerful data table component with sorting, searching, and pagination support.") - - Heading(level: 2) { "Interactive Demo" } + render Docs::Header.new( + title: component, + description: "A headless data table powered by TanStack Table Core and Hotwire." + ) + Heading(level: 2) { "Skeleton Demo" } p(class: "text-sm text-muted-foreground -mt-6") { - "Live example with search, sort, and pagination using fake employee data." + "Client-side render driven by Stimulus + TanStack Table. " \ + "Pagination, sorting, and search arrive in subsequent iterations." } - div(class: "rounded-lg border p-6 space-y-4") do - DataTable(src: "/docs/data_table/demo") do - DataTableToolbar do - DataTableSearch(placeholder: "Search by name or email...") - DataTablePerPage(options: [5, 10, 20], current: 10) - end - turbo_frame_tag "data_table_content" do - div(class: "rounded-md border") do - Table do - TableHeader do - TableRow do - TableHead { "Name" } - TableHead { "Email" } - TableHead { "Department" } - TableHead { "Status" } - TableHead { "Salary" } - end - end - TableBody do - initial_employees.each do |employee| - TableRow do - TableCell(class: "font-medium") { employee.name } - TableCell(class: "text-muted-foreground") { employee.email } - TableCell { employee.department } - TableCell { status_badge(employee.status) } - TableCell { format_salary(employee.salary) } - end - end - end - end - end - div(class: "flex items-center justify-between px-2 py-4") do - div(class: "text-sm text-muted-foreground") { "Showing 10 of 30 results" } - DataTablePagination(current_page: 1, total_pages: 3) - end - end + div(class: "rounded-lg border p-6") do + DataTable( + data: employees.map(&:to_h), + columns: [ + {key: "name", header: "Name"}, + {key: "email", header: "Email"}, + {key: "department", header: "Department"} + ], + row_count: employees.size + ) do + DataTableContent() end end - Heading(level: 2) { "Usage" } - - render Docs::VisualCodeExample.new(title: "Basic Example", context: self) do - <<~RUBY - DataTable do - DataTableToolbar do - DataTableSearch(placeholder: "Search employees...") - end - DataTableContent do - Table do - TableHeader do - TableRow do - TableHead { "Name" } - TableHead { "Department" } - TableHead { "Status" } - TableHead { "Salary" } - end - end - TableBody do - employees.each do |employee| - TableRow do - TableCell(class: "font-medium") { employee.name } - TableCell { employee.department } - TableCell { employee.status } - TableCell { format_salary(employee.salary) } - end - end - end - end - end - DataTablePagination(current_page: 1, total_pages: 3) - end - RUBY - end - - render Docs::VisualCodeExample.new(title: "With Per Page Selector", context: self) do - <<~RUBY - DataTable do - DataTableToolbar do - DataTableSearch(placeholder: "Search...") - DataTablePerPage(options: [10, 20, 50], current: 10) - end - DataTableContent do - Table do - TableHeader do - TableRow do - TableHead { "Name" } - TableHead { "Department" } - TableHead { "Status" } - end - end - TableBody do - employees.each do |employee| - TableRow do - TableCell(class: "font-medium") { employee.name } - TableCell { employee.department } - TableCell { employee.status } - end - end - end - end - end - DataTablePagination(current_page: 1, total_pages: 5) - end - RUBY - end - render Components::ComponentSetup::Tabs.new(component_name: component) - render Docs::ComponentsTable.new(component_files(component)) end end private - def initial_employees - [ - Employee.new(id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000), - Employee.new(id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000), - Employee.new(id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000), - Employee.new(id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000), - Employee.new(id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000), - Employee.new(id: 6, name: "Frank Miller", email: "frank@example.com", department: "Engineering", status: "Active", salary: 98_000), - Employee.new(id: 7, name: "Grace Lee", email: "grace@example.com", department: "HR", status: "Active", salary: 71_000), - Employee.new(id: 8, name: "Henry Wilson", email: "henry@example.com", department: "Finance", status: "Active", salary: 85_000), - Employee.new(id: 9, name: "Iris Martinez", email: "iris@example.com", department: "Design", status: "Inactive", salary: 79_000), - Employee.new(id: 10, name: "Jack Taylor", email: "jack@example.com", department: "Engineering", status: "Active", salary: 110_000) - ] - end - def employees [ - Employee.new(id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000), - Employee.new(id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000), - Employee.new(id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000), - Employee.new(id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000), - Employee.new(id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000) + Employee.new(id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering"), + Employee.new(id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design"), + Employee.new(id: 3, name: "Carol White", email: "carol@example.com", department: "Product"), + Employee.new(id: 4, name: "David Brown", email: "david@example.com", department: "Engineering"), + Employee.new(id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing") ] end - - def format_salary(amount) - "$#{amount.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse}" - end - - def status_badge(status) - color = case status - when "Active" then "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" - when "Inactive" then "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" - else "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" - end - span(class: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{color}") { status } - end end From 337f31602054e34d9b07e0d1a7c93f85e43034b1 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 15:53:00 -0300 Subject: [PATCH 09/52] test(data_table): system test covers skeleton render --- test/system/docs/data_table_skeleton_test.rb | 22 ++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 test/system/docs/data_table_skeleton_test.rb diff --git a/test/system/docs/data_table_skeleton_test.rb b/test/system/docs/data_table_skeleton_test.rb new file mode 100644 index 000000000..3463c81d5 --- /dev/null +++ b/test/system/docs/data_table_skeleton_test.rb @@ -0,0 +1,22 @@ +require "application_system_test_case" + +class Docs::DataTableSkeletonTest < ApplicationSystemTestCase + test "renders rows from data attribute via TanStack" do + visit "/docs/data_table" + + # Wait for Stimulus to connect and TanStack to render rows + assert_selector "[data-controller='ruby-ui--data-table'] tbody tr", minimum: 3 + + # First row should contain the seed data + within("[data-controller='ruby-ui--data-table'] tbody tr:first-child") do + assert_text "Alice Johnson" + assert_text "alice@example.com" + end + + # Headers should come from columns config + within("[data-controller='ruby-ui--data-table'] thead") do + assert_text "Name" + assert_text "Email" + end + end +end From 5e0404b379d91db6c8d353fdbc9d64d743f46e71 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 16:39:38 -0300 Subject: [PATCH 10/52] fix(data_table): remove empty controlled state that broke TanStack defaults --- app/javascript/controllers/ruby_ui/data_table_controller.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 41139ace3..04c59137e 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -19,8 +19,6 @@ export default class extends Controller { header: c.header })), getCoreRowModel: getCoreRowModel(), - state: {}, - onStateChange: () => {}, renderFallbackValue: null }) From d63d4c7b83d0fcfece07c4a490275f512765b6d9 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 16:48:17 -0300 Subject: [PATCH 11/52] fix(data_table): wire TanStack initialState via setOptions (vanilla pattern) --- .../ruby_ui/data_table_controller.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 04c59137e..d36c7e612 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -11,6 +11,7 @@ export default class extends Controller { } connect() { + // Step 1: create with required stubs this.table = createTable({ data: this.dataValue, columns: this.columnsValue.map((c) => ({ @@ -19,9 +20,25 @@ export default class extends Controller { header: c.header })), getCoreRowModel: getCoreRowModel(), - renderFallbackValue: null + renderFallbackValue: null, + state: {}, + onStateChange: () => {} }) + // Step 2: seed local state from TanStack's fully-initialized initialState + this.tableState = this.table.initialState + + // Step 3: wire proper state management + this.table.setOptions((prev) => ({ + ...prev, + state: this.tableState, + onStateChange: (updater) => { + this.tableState = typeof updater === "function" ? updater(this.tableState) : updater + this.table.setOptions((p) => ({ ...p, state: this.tableState })) + this.render() + } + })) + this.render() } From 4cce400a38010631983e8f9c887f1b1685768183 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:03:18 -0300 Subject: [PATCH 12/52] feat(data_table): demo controller responds to JSON for TanStack fetches --- .../docs/data_table_demo_controller.rb | 36 +++++++++++------- .../docs/data_table_demo_controller_test.rb | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 test/controllers/docs/data_table_demo_controller_test.rb diff --git a/app/controllers/docs/data_table_demo_controller.rb b/app/controllers/docs/data_table_demo_controller.rb index 2ba854f19..8429e5059 100644 --- a/app/controllers/docs/data_table_demo_controller.rb +++ b/app/controllers/docs/data_table_demo_controller.rb @@ -38,7 +38,6 @@ class DataTableDemoController < ApplicationController def index employees = EMPLOYEES.dup - # Search if params[:search].present? query = params[:search].downcase employees = employees.select do |e| @@ -46,7 +45,6 @@ def index end end - # Sort if params[:sort].present? col = params[:sort].to_sym employees = employees.sort_by { |e| e.send(col).to_s.downcase } rescue employees @@ -62,18 +60,28 @@ def index offset = (@current_page - 1) * @per_page @employees = employees.slice(offset, @per_page) || [] - @sort = params[:sort] - @direction = params[:direction] - - render Views::Docs::DataTableDemo::Index.new( - employees: @employees, - current_page: @current_page, - total_pages: @total_pages, - total_count: @total_count, - per_page: @per_page, - sort: @sort, - direction: @direction - ) + respond_to do |format| + format.html do + render Views::Docs::DataTableDemo::Index.new( + employees: @employees, + current_page: @current_page, + total_pages: @total_pages, + total_count: @total_count, + per_page: @per_page, + sort: params[:sort], + direction: params[:direction] + ) + end + format.json do + render json: { + data: @employees.map { |e| + {id: e.id, name: e.name, email: e.email, department: e.department, + status: e.status, salary: e.salary} + }, + row_count: @total_count + } + end + end end end end diff --git a/test/controllers/docs/data_table_demo_controller_test.rb b/test/controllers/docs/data_table_demo_controller_test.rb new file mode 100644 index 000000000..13978cd94 --- /dev/null +++ b/test/controllers/docs/data_table_demo_controller_test.rb @@ -0,0 +1,37 @@ +require "test_helper" + +class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest + test "GET /docs/data_table/demo returns HTML by default" do + get docs_data_table_demo_path + assert_response :success + assert_match "text/html", response.content_type + end + + test "GET /docs/data_table/demo returns JSON when requested" do + get docs_data_table_demo_path, headers: { "Accept" => "application/json" } + assert_response :success + assert_match "application/json", response.content_type + json = JSON.parse(response.body) + assert json.key?("data") + assert json.key?("row_count") + assert_kind_of Array, json["data"] + assert_kind_of Integer, json["row_count"] + end + + test "JSON response respects page param" do + get docs_data_table_demo_path, params: { page: 2, per_page: 5 }, + headers: { "Accept" => "application/json" } + assert_response :success + json = JSON.parse(response.body) + assert_equal 5, json["data"].length + assert_equal 30, json["row_count"] + end + + test "JSON response respects search param" do + get docs_data_table_demo_path, params: { search: "alice" }, + headers: { "Accept" => "application/json" } + json = JSON.parse(response.body) + assert json["data"].all? { |r| r["name"].downcase.include?("alice") || r["email"].downcase.include?("alice") } + assert json["row_count"] < 30 + end +end From a7d7d0b4c2102497064db8d967e8b94d34c5f44a Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:05:46 -0300 Subject: [PATCH 13/52] feat(data_table): add page/per_page props, serialize pagination value --- app/components/ruby_ui/data_table/data_table.rb | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index 5d1e9d79d..e18d8f4a3 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -2,15 +2,19 @@ module RubyUI class DataTable < Base - # @param data [Array] current page rows (each row is a hash keyed by column key) - # @param columns [Array] column definitions: [{ key: "name", header: "Name" }, ...] - # @param src [String, nil] URL for JSON fetches on state change (unused in skeleton; reserved for Plan 02) - # @param row_count [Integer] total rows across all pages (unused in skeleton; reserved for Plan 02) - def initialize(data: [], columns: [], src: nil, row_count: 0, **attrs) + # @param data [Array] current page rows + # @param columns [Array] [{ key:, header:, type: (optional) }] + # @param src [String, nil] URL for JSON fetches on state change + # @param row_count [Integer] total rows across all pages + # @param page [Integer] current page, 1-based (converted to 0-based pageIndex for TanStack) + # @param per_page [Integer] rows per page + def initialize(data: [], columns: [], src: nil, row_count: 0, page: 1, per_page: 10, **attrs) @data = data @columns = columns @src = src @row_count = row_count + @page = page + @per_page = per_page super(**attrs) end @@ -28,7 +32,8 @@ def default_attrs ruby_ui__data_table_src_value: @src.to_s, ruby_ui__data_table_data_value: @data.to_json, ruby_ui__data_table_columns_value: @columns.to_json, - ruby_ui__data_table_row_count_value: @row_count + ruby_ui__data_table_row_count_value: @row_count, + ruby_ui__data_table_pagination_value: {pageIndex: @page - 1, pageSize: @per_page}.to_json } } end From 88529865225b5d8793556dd4204ae56fe8476f4e Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:06:22 -0300 Subject: [PATCH 14/52] feat(data_table): add Stimulus targets to DataTablePagination --- .../data_table/data_table_pagination.rb | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_pagination.rb b/app/components/ruby_ui/data_table/data_table_pagination.rb index 66179e79f..9862ff847 100644 --- a/app/components/ruby_ui/data_table/data_table_pagination.rb +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -11,7 +11,10 @@ def initialize(current_page:, total_pages:, **attrs) def view_template div(**attrs) do div(class: "flex items-center justify-between px-2") do - div(class: "flex-1 text-sm text-muted-foreground") do + div( + class: "flex-1 text-sm text-muted-foreground", + data: {ruby_ui__data_table_target: "pageIndicator"} + ) do plain "Page #{@current_page} of #{@total_pages}" end div(class: "flex items-center space-x-2") do @@ -19,12 +22,14 @@ def view_template direction: "previous", disabled: @current_page <= 1, action: "click->ruby-ui--data-table#previousPage", + target: "prevButton", icon_path: "m15 18-6-6 6-6" ) nav_button( direction: "next", disabled: @current_page >= @total_pages, action: "click->ruby-ui--data-table#nextPage", + target: "nextButton", icon_path: "m9 18 6-6-6-6" ) end @@ -34,13 +39,16 @@ def view_template private - def nav_button(direction:, disabled:, action:, icon_path:) + def nav_button(direction:, disabled:, action:, target:, icon_path:) button( type: "button", class: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground h-8 w-8 p-0 #{disabled ? "opacity-50 pointer-events-none" : ""}", disabled: disabled, aria_label: direction, - data: {action: action} + data: { + action: action, + ruby_ui__data_table_target: target + } ) do svg( xmlns: "http://www.w3.org/2000/svg", @@ -52,9 +60,7 @@ def nav_button(direction:, disabled:, action:, icon_path:) end def default_attrs - { - class: "flex items-center justify-end py-4" - } + {class: "flex items-center justify-end py-4"} end end end From e2716a901a4333252b4a693275fdb8171f126893 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:07:00 -0300 Subject: [PATCH 15/52] feat(data_table): wire manual pagination with TanStack onPaginationChange --- .../ruby_ui/data_table_controller.js | 91 +++++++++++++++---- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index d36c7e612..77485e187 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -2,38 +2,46 @@ import { Controller } from "@hotwired/stimulus" import { createTable, getCoreRowModel } from "@tanstack/table-core" export default class extends Controller { - static targets = ["thead", "tbody"] + static targets = ["thead", "tbody", "prevButton", "nextButton", "pageIndicator"] static values = { src: String, data: { type: Array, default: [] }, columns: { type: Array, default: [] }, - rowCount: { type: Number, default: 0 } + rowCount: { type: Number, default: 0 }, + pagination: { type: Object, default: { pageIndex: 0, pageSize: 10 } } } connect() { - // Step 1: create with required stubs this.table = createTable({ data: this.dataValue, - columns: this.columnsValue.map((c) => ({ - id: c.key, - accessorKey: c.key, - header: c.header - })), + columns: this.dataValue.length > 0 + ? this.columnsValue.map((c) => ({ id: c.key, accessorKey: c.key, header: c.header })) + : [], getCoreRowModel: getCoreRowModel(), renderFallbackValue: null, + manualPagination: true, + rowCount: this.rowCountValue, state: {}, onStateChange: () => {} }) - // Step 2: seed local state from TanStack's fully-initialized initialState - this.tableState = this.table.initialState + this.tableState = { + ...this.table.initialState, + pagination: this.paginationValue + } - // Step 3: wire proper state management this.table.setOptions((prev) => ({ ...prev, state: this.tableState, + onPaginationChange: (updater) => { + const next = typeof updater === "function" ? updater(this.tableState.pagination) : updater + this.tableState = { ...this.tableState, pagination: next } + this.table.setOptions((p) => ({ ...p, state: this.tableState })) + this.#fetchAndRender() + }, onStateChange: (updater) => { - this.tableState = typeof updater === "function" ? updater(this.tableState) : updater + const next = typeof updater === "function" ? updater(this.tableState) : updater + this.tableState = next this.table.setOptions((p) => ({ ...p, state: this.tableState })) this.render() } @@ -42,12 +50,63 @@ export default class extends Controller { this.render() } + previousPage() { + this.table.previousPage() + } + + nextPage() { + this.table.nextPage() + } + render() { - this.renderHeaders() - this.renderRows() + this.#renderHeaders() + this.#renderRows() + this.#syncPaginationUI() + } + + #fetchAndRender() { + if (!this.hasSrcValue || !this.srcValue) return + + fetch(this.#buildURL(), { headers: { Accept: "application/json" } }) + .then((r) => r.json()) + .then(({ data, row_count }) => { + this.table.setOptions((prev) => ({ + ...prev, + data, + rowCount: row_count, + state: this.tableState + })) + this.render() + }) + } + + #buildURL() { + const url = new URL(this.srcValue, window.location.origin) + const { pageIndex, pageSize } = this.tableState.pagination + url.searchParams.set("page", pageIndex + 1) + url.searchParams.set("per_page", pageSize) + return url.toString() + } + + #syncPaginationUI() { + if (this.hasPageIndicatorTarget) { + const { pageIndex } = this.tableState.pagination + const pageCount = this.table.getPageCount() + this.pageIndicatorTarget.textContent = `Page ${pageIndex + 1} of ${pageCount}` + } + if (this.hasPrevButtonTarget) { + this.prevButtonTarget.disabled = !this.table.getCanPreviousPage() + this.prevButtonTarget.classList.toggle("opacity-50", !this.table.getCanPreviousPage()) + this.prevButtonTarget.classList.toggle("pointer-events-none", !this.table.getCanPreviousPage()) + } + if (this.hasNextButtonTarget) { + this.nextButtonTarget.disabled = !this.table.getCanNextPage() + this.nextButtonTarget.classList.toggle("opacity-50", !this.table.getCanNextPage()) + this.nextButtonTarget.classList.toggle("pointer-events-none", !this.table.getCanNextPage()) + } } - renderHeaders() { + #renderHeaders() { if (!this.hasTheadTarget) return const html = this.table.getHeaderGroups().map((group) => { @@ -62,7 +121,7 @@ export default class extends Controller { this.theadTarget.innerHTML = html } - renderRows() { + #renderRows() { if (!this.hasTbodyTarget) return const rows = this.table.getRowModel().rows From bf19c7ac61186d759ea4762287619da5a9969db9 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:10:10 -0300 Subject: [PATCH 16/52] feat(docs): wire DataTable demo with src + DataTablePagination --- app/views/docs/data_table.rb | 46 +++++++++++-------- .../docs/data_table_demo_controller_test.rb | 9 ++++ 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 7a46f84bf..7a19209c2 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -1,7 +1,20 @@ # frozen_string_literal: true class Views::Docs::DataTable < Views::Base - Employee = Struct.new(:id, :name, :email, :department, keyword_init: true) + Employee = Struct.new(:id, :name, :email, :department, :status, :salary, keyword_init: true) + + INITIAL_EMPLOYEES = [ + {id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000}, + {id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000}, + {id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000}, + {id: 6, name: "Frank Miller", email: "frank@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 7, name: "Grace Lee", email: "grace@example.com", department: "HR", status: "Active", salary: 71_000}, + {id: 8, name: "Henry Wilson", email: "henry@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 9, name: "Iris Martinez", email: "iris@example.com", department: "Design", status: "Inactive", salary: 79_000}, + {id: 10, name: "Jack Taylor", email: "jack@example.com", department: "Engineering", status: "Active", salary: 110_000} + ].freeze def view_template component = "DataTable" @@ -9,26 +22,31 @@ def view_template div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new( title: component, - description: "A headless data table powered by TanStack Table Core and Hotwire." + description: "A headless data table powered by TanStack Table Core and Hotwire. Server-side pagination, sorting, and search — no client-side dataset." ) - Heading(level: 2) { "Skeleton Demo" } + Heading(level: 2) { "Demo" } p(class: "text-sm text-muted-foreground -mt-6") { - "Client-side render driven by Stimulus + TanStack Table. " \ - "Pagination, sorting, and search arrive in subsequent iterations." + "Live demo with server-side pagination. Sorting and search coming in the next iteration." } div(class: "rounded-lg border p-6") do DataTable( - data: employees.map(&:to_h), + src: docs_data_table_demo_path, + data: INITIAL_EMPLOYEES, columns: [ {key: "name", header: "Name"}, {key: "email", header: "Email"}, - {key: "department", header: "Department"} + {key: "department", header: "Department"}, + {key: "status", header: "Status"}, + {key: "salary", header: "Salary"} ], - row_count: employees.size + row_count: 30, + page: 1, + per_page: 10 ) do DataTableContent() + DataTablePagination(current_page: 1, total_pages: 3) end end @@ -36,16 +54,4 @@ def view_template render Docs::ComponentsTable.new(component_files(component)) end end - - private - - def employees - [ - Employee.new(id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering"), - Employee.new(id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design"), - Employee.new(id: 3, name: "Carol White", email: "carol@example.com", department: "Product"), - Employee.new(id: 4, name: "David Brown", email: "david@example.com", department: "Engineering"), - Employee.new(id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing") - ] - end end diff --git a/test/controllers/docs/data_table_demo_controller_test.rb b/test/controllers/docs/data_table_demo_controller_test.rb index 13978cd94..8bbd2bed2 100644 --- a/test/controllers/docs/data_table_demo_controller_test.rb +++ b/test/controllers/docs/data_table_demo_controller_test.rb @@ -34,4 +34,13 @@ class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest assert json["data"].all? { |r| r["name"].downcase.include?("alice") || r["email"].downcase.include?("alice") } assert json["row_count"] < 30 end + + test "docs data_table page includes pagination data-value" do + get docs_data_table_path + assert_response :success + assert_match "data-ruby-ui--data-table-pagination-value", response.body + decoded = CGI.unescapeHTML(response.body) + assert_match '"pageIndex":0', decoded + assert_match '"pageSize":10', decoded + end end From d05e81ae77a7e0f12dd9bccb187fc59a2fa9312b Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:21:37 -0300 Subject: [PATCH 17/52] test(data_table): add sort param tests to demo controller --- .../docs/data_table_demo_controller_test.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/controllers/docs/data_table_demo_controller_test.rb b/test/controllers/docs/data_table_demo_controller_test.rb index 8bbd2bed2..95f05bbca 100644 --- a/test/controllers/docs/data_table_demo_controller_test.rb +++ b/test/controllers/docs/data_table_demo_controller_test.rb @@ -43,4 +43,20 @@ class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest assert_match '"pageIndex":0', decoded assert_match '"pageSize":10', decoded end + + test "JSON response respects sort param ascending" do + get docs_data_table_demo_path, params: {sort: "name", direction: "asc"}, + headers: {"Accept" => "application/json"} + json = JSON.parse(response.body) + names = json["data"].map { |r| r["name"] } + assert_equal names.sort, names + end + + test "JSON response respects sort param descending" do + get docs_data_table_demo_path, params: {sort: "name", direction: "desc"}, + headers: {"Accept" => "application/json"} + json = JSON.parse(response.body) + names = json["data"].map { |r| r["name"] } + assert_equal names.sort.reverse, names + end end From bfde429165dffa81115f493aabf83ff1145495a6 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:21:58 -0300 Subject: [PATCH 18/52] feat(data_table): add sort/direction props, serialize sorting value --- app/components/ruby_ui/data_table/data_table.rb | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index e18d8f4a3..af246c1bf 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -4,17 +4,22 @@ module RubyUI class DataTable < Base # @param data [Array] current page rows # @param columns [Array] [{ key:, header:, type: (optional) }] - # @param src [String, nil] URL for JSON fetches on state change + # @param src [String, nil] URL for JSON fetches # @param row_count [Integer] total rows across all pages - # @param page [Integer] current page, 1-based (converted to 0-based pageIndex for TanStack) + # @param page [Integer] current page, 1-based # @param per_page [Integer] rows per page - def initialize(data: [], columns: [], src: nil, row_count: 0, page: 1, per_page: 10, **attrs) + # @param sort [String, nil] sorted column key + # @param direction [String, nil] "asc" or "desc" + def initialize(data: [], columns: [], src: nil, row_count: 0, + page: 1, per_page: 10, sort: nil, direction: nil, **attrs) @data = data @columns = columns @src = src @row_count = row_count @page = page @per_page = per_page + @sort = sort + @direction = direction super(**attrs) end @@ -25,6 +30,7 @@ def view_template(&) private def default_attrs + sorting = @sort.present? ? [{id: @sort, desc: @direction == "desc"}] : [] { class: "w-full space-y-4", data: { @@ -33,7 +39,8 @@ def default_attrs ruby_ui__data_table_data_value: @data.to_json, ruby_ui__data_table_columns_value: @columns.to_json, ruby_ui__data_table_row_count_value: @row_count, - ruby_ui__data_table_pagination_value: {pageIndex: @page - 1, pageSize: @per_page}.to_json + ruby_ui__data_table_pagination_value: {pageIndex: @page - 1, pageSize: @per_page}.to_json, + ruby_ui__data_table_sorting_value: sorting.to_json } } end From a552515b99abad7f1811fbacce9ccd057d445da7 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:22:38 -0300 Subject: [PATCH 19/52] feat(data_table): wire manual sorting with clickable headers and sort icons --- .../ruby_ui/data_table_controller.js | 86 ++++++++++++++----- 1 file changed, 66 insertions(+), 20 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 77485e187..a5f62f928 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -8,18 +8,22 @@ export default class extends Controller { data: { type: Array, default: [] }, columns: { type: Array, default: [] }, rowCount: { type: Number, default: 0 }, - pagination: { type: Object, default: { pageIndex: 0, pageSize: 10 } } + pagination: { type: Object, default: { pageIndex: 0, pageSize: 10 } }, + sorting: { type: Array, default: [] } } connect() { this.table = createTable({ data: this.dataValue, - columns: this.dataValue.length > 0 - ? this.columnsValue.map((c) => ({ id: c.key, accessorKey: c.key, header: c.header })) - : [], + columns: this.columnsValue.map((c) => ({ + id: c.key, + accessorKey: c.key, + header: c.header + })), getCoreRowModel: getCoreRowModel(), renderFallbackValue: null, manualPagination: true, + manualSorting: true, rowCount: this.rowCountValue, state: {}, onStateChange: () => {} @@ -27,7 +31,8 @@ export default class extends Controller { this.tableState = { ...this.table.initialState, - pagination: this.paginationValue + pagination: this.paginationValue, + sorting: this.sortingValue } this.table.setOptions((prev) => ({ @@ -39,6 +44,16 @@ export default class extends Controller { this.table.setOptions((p) => ({ ...p, state: this.tableState })) this.#fetchAndRender() }, + onSortingChange: (updater) => { + const next = typeof updater === "function" ? updater(this.tableState.sorting) : updater + this.tableState = { + ...this.tableState, + sorting: next, + pagination: { ...this.tableState.pagination, pageIndex: 0 } + } + this.table.setOptions((p) => ({ ...p, state: this.tableState })) + this.#fetchAndRender() + }, onStateChange: (updater) => { const next = typeof updater === "function" ? updater(this.tableState) : updater this.tableState = next @@ -50,13 +65,8 @@ export default class extends Controller { this.render() } - previousPage() { - this.table.previousPage() - } - - nextPage() { - this.table.nextPage() - } + previousPage() { this.table.previousPage() } + nextPage() { this.table.nextPage() } render() { this.#renderHeaders() @@ -85,6 +95,13 @@ export default class extends Controller { const { pageIndex, pageSize } = this.tableState.pagination url.searchParams.set("page", pageIndex + 1) url.searchParams.set("per_page", pageSize) + + if (this.tableState.sorting.length > 0) { + const { id, desc } = this.tableState.sorting[0] + url.searchParams.set("sort", id) + url.searchParams.set("direction", desc ? "desc" : "asc") + } + return url.toString() } @@ -95,14 +112,16 @@ export default class extends Controller { this.pageIndicatorTarget.textContent = `Page ${pageIndex + 1} of ${pageCount}` } if (this.hasPrevButtonTarget) { - this.prevButtonTarget.disabled = !this.table.getCanPreviousPage() - this.prevButtonTarget.classList.toggle("opacity-50", !this.table.getCanPreviousPage()) - this.prevButtonTarget.classList.toggle("pointer-events-none", !this.table.getCanPreviousPage()) + const can = this.table.getCanPreviousPage() + this.prevButtonTarget.disabled = !can + this.prevButtonTarget.classList.toggle("opacity-50", !can) + this.prevButtonTarget.classList.toggle("pointer-events-none", !can) } if (this.hasNextButtonTarget) { - this.nextButtonTarget.disabled = !this.table.getCanNextPage() - this.nextButtonTarget.classList.toggle("opacity-50", !this.table.getCanNextPage()) - this.nextButtonTarget.classList.toggle("pointer-events-none", !this.table.getCanNextPage()) + const can = this.table.getCanNextPage() + this.nextButtonTarget.disabled = !can + this.nextButtonTarget.classList.toggle("opacity-50", !can) + this.nextButtonTarget.classList.toggle("pointer-events-none", !can) } } @@ -112,13 +131,40 @@ export default class extends Controller { const html = this.table.getHeaderGroups().map((group) => { const cells = group.headers.map((header) => { const def = header.column.columnDef.header - const label = typeof def === "function" ? def(header.getContext()) : def - return `${escapeHtml(label ?? "")}` + const label = typeof def === "function" ? def(header.getContext()) : (def ?? "") + const sorted = header.column.getIsSorted() // false | "asc" | "desc" + const canSort = header.column.getCanSort() + + if (!canSort) { + return `${escapeHtml(label)}` + } + + const icon = sorted === "asc" + ? `` + : sorted === "desc" + ? `` + : `` + + return ` + + ` }).join("") return `${cells}` }).join("") this.theadTarget.innerHTML = html + + // Attach sort handlers after render (avoid inline onclick with private methods) + this.theadTarget.querySelectorAll("[data-sort-col]").forEach((btn) => { + btn.addEventListener("click", () => this.#sortColumn(btn.dataset.sortCol)) + }) + } + + #sortColumn(colId) { + const col = this.table.getColumn(colId) + if (col) col.toggleSorting() } #renderRows() { From 25eea2104aa1784d8f775cbfefe98c34fb3a17e0 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:22:54 -0300 Subject: [PATCH 20/52] feat(docs): pass sort/direction props to DataTable demo --- app/views/docs/data_table.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 7a19209c2..11c89b6eb 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -43,7 +43,9 @@ def view_template ], row_count: 30, page: 1, - per_page: 10 + per_page: 10, + sort: nil, + direction: nil ) do DataTableContent() DataTablePagination(current_page: 1, total_pages: 3) From 878d64896fdc2054847bd39497d572799d177c3c Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:23:21 -0300 Subject: [PATCH 21/52] feat(data_table): add search prop, serialize search value --- app/components/ruby_ui/data_table/data_table.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index af246c1bf..10203e4c8 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -10,8 +10,9 @@ class DataTable < Base # @param per_page [Integer] rows per page # @param sort [String, nil] sorted column key # @param direction [String, nil] "asc" or "desc" + # @param search [String, nil] initial search query def initialize(data: [], columns: [], src: nil, row_count: 0, - page: 1, per_page: 10, sort: nil, direction: nil, **attrs) + page: 1, per_page: 10, sort: nil, direction: nil, search: nil, **attrs) @data = data @columns = columns @src = src @@ -20,6 +21,7 @@ def initialize(data: [], columns: [], src: nil, row_count: 0, @per_page = per_page @sort = sort @direction = direction + @search = search super(**attrs) end @@ -40,7 +42,8 @@ def default_attrs ruby_ui__data_table_columns_value: @columns.to_json, ruby_ui__data_table_row_count_value: @row_count, ruby_ui__data_table_pagination_value: {pageIndex: @page - 1, pageSize: @per_page}.to_json, - ruby_ui__data_table_sorting_value: sorting.to_json + ruby_ui__data_table_sorting_value: sorting.to_json, + ruby_ui__data_table_search_value: @search.to_s } } end From 714765696b24e83c4a8ee06108e97f357e206ac2 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:23:58 -0300 Subject: [PATCH 22/52] feat(data_table): wire search debounce and per-page change --- .../ruby_ui/data_table_controller.js | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index a5f62f928..22182196d 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -2,17 +2,20 @@ import { Controller } from "@hotwired/stimulus" import { createTable, getCoreRowModel } from "@tanstack/table-core" export default class extends Controller { - static targets = ["thead", "tbody", "prevButton", "nextButton", "pageIndicator"] + static targets = ["thead", "tbody", "prevButton", "nextButton", "pageIndicator", "search", "perPage"] static values = { src: String, data: { type: Array, default: [] }, columns: { type: Array, default: [] }, rowCount: { type: Number, default: 0 }, pagination: { type: Object, default: { pageIndex: 0, pageSize: 10 } }, - sorting: { type: Array, default: [] } + sorting: { type: Array, default: [] }, + search: { type: String, default: "" } } connect() { + this.searchTimeout = null + this.table = createTable({ data: this.dataValue, columns: this.columnsValue.map((c) => ({ @@ -24,6 +27,7 @@ export default class extends Controller { renderFallbackValue: null, manualPagination: true, manualSorting: true, + manualFiltering: true, rowCount: this.rowCountValue, state: {}, onStateChange: () => {} @@ -32,7 +36,8 @@ export default class extends Controller { this.tableState = { ...this.table.initialState, pagination: this.paginationValue, - sorting: this.sortingValue + sorting: this.sortingValue, + globalFilter: this.searchValue } this.table.setOptions((prev) => ({ @@ -62,12 +67,45 @@ export default class extends Controller { } })) + // Pre-fill search input if hydrating from initial state + if (this.hasSearchTarget && this.searchValue) { + this.searchTarget.value = this.searchValue + } + this.render() } + disconnect() { + if (this.searchTimeout) clearTimeout(this.searchTimeout) + } + previousPage() { this.table.previousPage() } nextPage() { this.table.nextPage() } + search() { + if (this.searchTimeout) clearTimeout(this.searchTimeout) + this.searchTimeout = setTimeout(() => { + const query = this.searchTarget.value + this.tableState = { + ...this.tableState, + globalFilter: query, + pagination: { ...this.tableState.pagination, pageIndex: 0 } + } + this.table.setOptions((p) => ({ ...p, state: this.tableState })) + this.#fetchAndRender() + }, 300) + } + + changePerPage() { + const pageSize = parseInt(this.perPageTarget.value) + this.tableState = { + ...this.tableState, + pagination: { pageIndex: 0, pageSize } + } + this.table.setOptions((p) => ({ ...p, state: this.tableState })) + this.#fetchAndRender() + } + render() { this.#renderHeaders() this.#renderRows() @@ -102,6 +140,10 @@ export default class extends Controller { url.searchParams.set("direction", desc ? "desc" : "asc") } + if (this.tableState.globalFilter) { + url.searchParams.set("search", this.tableState.globalFilter) + } + return url.toString() } @@ -132,7 +174,7 @@ export default class extends Controller { const cells = group.headers.map((header) => { const def = header.column.columnDef.header const label = typeof def === "function" ? def(header.getContext()) : (def ?? "") - const sorted = header.column.getIsSorted() // false | "asc" | "desc" + const sorted = header.column.getIsSorted() const canSort = header.column.getCanSort() if (!canSort) { @@ -156,17 +198,14 @@ export default class extends Controller { this.theadTarget.innerHTML = html - // Attach sort handlers after render (avoid inline onclick with private methods) this.theadTarget.querySelectorAll("[data-sort-col]").forEach((btn) => { - btn.addEventListener("click", () => this.#sortColumn(btn.dataset.sortCol)) + btn.addEventListener("click", () => { + const col = this.table.getColumn(btn.dataset.sortCol) + if (col) col.toggleSorting() + }) }) } - #sortColumn(colId) { - const col = this.table.getColumn(colId) - if (col) col.toggleSorting() - } - #renderRows() { if (!this.hasTbodyTarget) return From 9a50c60be73f447f0426652dbe08039736e39bdd Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:24:31 -0300 Subject: [PATCH 23/52] feat(docs): add DataTableToolbar with search and per-page to demo --- app/views/docs/data_table.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 11c89b6eb..ce9aa44bd 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -43,10 +43,12 @@ def view_template ], row_count: 30, page: 1, - per_page: 10, - sort: nil, - direction: nil + per_page: 10 ) do + DataTableToolbar do + DataTableSearch(placeholder: "Search by name or email...") + DataTablePerPage(options: [5, 10, 20], current: 10) + end DataTableContent() DataTablePagination(current_page: 1, total_pages: 3) end From bb00f6835947ceba52921b736cec5f393f3dcddc Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:24:47 -0300 Subject: [PATCH 24/52] feat(data_table): sync URL via history.replaceState on every state change --- app/javascript/controllers/ruby_ui/data_table_controller.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 22182196d..abd76f23f 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -110,6 +110,7 @@ export default class extends Controller { this.#renderHeaders() this.#renderRows() this.#syncPaginationUI() + this.#syncURL() } #fetchAndRender() { @@ -167,6 +168,11 @@ export default class extends Controller { } } + #syncURL() { + if (!this.hasSrcValue || !this.srcValue) return + history.replaceState(null, "", this.#buildURL()) + } + #renderHeaders() { if (!this.hasTheadTarget) return From e966cae79cf583d75aa8d3d5f267dcf0ff1cd5d4 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:26:36 -0300 Subject: [PATCH 25/52] feat(data_table): hydrate initial state from URL params via DocsController --- app/controllers/docs_controller.rb | 34 ++++++++++++- app/views/docs/data_table.rb | 62 +++++++++++++----------- test/controllers/docs_controller_test.rb | 25 ++++++++++ 3 files changed, 93 insertions(+), 28 deletions(-) diff --git a/app/controllers/docs_controller.rb b/app/controllers/docs_controller.rb index bafa2bad7..3d10407ff 100644 --- a/app/controllers/docs_controller.rb +++ b/app/controllers/docs_controller.rb @@ -191,7 +191,39 @@ def switch end def data_table - render Views::Docs::DataTable.new + # Delegate filtering/sorting/paging to the same demo logic + employees = Docs::DataTableDemoController::EMPLOYEES.dup + + if params[:search].present? + query = params[:search].downcase + employees = employees.select { |e| e.name.downcase.include?(query) || e.email.downcase.include?(query) } + end + + if params[:sort].present? + col = params[:sort].to_sym + employees = employees.sort_by { |e| e.send(col).to_s.downcase } rescue employees + employees = employees.reverse if params[:direction] == "desc" + end + + total_count = employees.size + per_page = (params[:per_page] || 10).to_i.clamp(1, 100) + page = (params[:page] || 1).to_i.clamp(1, Float::INFINITY) + total_pages = [(total_count.to_f / per_page).ceil, 1].max + page = [page, total_pages].min + offset = (page - 1) * per_page + initial_data = (employees.slice(offset, per_page) || []).map do |e| + {id: e.id, name: e.name, email: e.email, department: e.department, status: e.status, salary: e.salary} + end + + render Views::Docs::DataTable.new( + initial_data: initial_data, + total_count: total_count, + page: page, + per_page: per_page, + sort: params[:sort], + direction: params[:direction], + search: params[:search] + ) end def table diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index ce9aa44bd..779e027d5 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -1,21 +1,29 @@ # frozen_string_literal: true class Views::Docs::DataTable < Views::Base - Employee = Struct.new(:id, :name, :email, :department, :status, :salary, keyword_init: true) - - INITIAL_EMPLOYEES = [ - {id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000}, - {id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000}, - {id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000}, - {id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000}, - {id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000}, - {id: 6, name: "Frank Miller", email: "frank@example.com", department: "Engineering", status: "Active", salary: 98_000}, - {id: 7, name: "Grace Lee", email: "grace@example.com", department: "HR", status: "Active", salary: 71_000}, - {id: 8, name: "Henry Wilson", email: "henry@example.com", department: "Finance", status: "Active", salary: 85_000}, - {id: 9, name: "Iris Martinez", email: "iris@example.com", department: "Design", status: "Inactive", salary: 79_000}, - {id: 10, name: "Jack Taylor", email: "jack@example.com", department: "Engineering", status: "Active", salary: 110_000} + COLUMNS = [ + {key: "name", header: "Name"}, + {key: "email", header: "Email"}, + {key: "department", header: "Department"}, + {key: "status", header: "Status"}, + {key: "salary", header: "Salary"} ].freeze + DEMO_EMPLOYEES = ::Docs::DataTableDemoController::EMPLOYEES.first(10).map do |e| + {id: e.id, name: e.name, email: e.email, department: e.department, status: e.status, salary: e.salary} + end.freeze + + def initialize(initial_data: DEMO_EMPLOYEES, total_count: 30, + page: 1, per_page: 10, sort: nil, direction: nil, search: nil) + @initial_data = initial_data + @total_count = total_count + @page = page + @per_page = per_page + @sort = sort + @direction = direction + @search = search + end + def view_template component = "DataTable" @@ -27,30 +35,30 @@ def view_template Heading(level: 2) { "Demo" } p(class: "text-sm text-muted-foreground -mt-6") { - "Live demo with server-side pagination. Sorting and search coming in the next iteration." + "Live demo — pagination, sorting, and search. URL reflects current state; paste it to share or reload to restore." } div(class: "rounded-lg border p-6") do DataTable( src: docs_data_table_demo_path, - data: INITIAL_EMPLOYEES, - columns: [ - {key: "name", header: "Name"}, - {key: "email", header: "Email"}, - {key: "department", header: "Department"}, - {key: "status", header: "Status"}, - {key: "salary", header: "Salary"} - ], - row_count: 30, - page: 1, - per_page: 10 + data: @initial_data, + columns: COLUMNS, + row_count: @total_count, + page: @page, + per_page: @per_page, + sort: @sort, + direction: @direction, + search: @search ) do DataTableToolbar do DataTableSearch(placeholder: "Search by name or email...") - DataTablePerPage(options: [5, 10, 20], current: 10) + DataTablePerPage(options: [5, 10, 20], current: @per_page) end DataTableContent() - DataTablePagination(current_page: 1, total_pages: 3) + DataTablePagination( + current_page: @page, + total_pages: [(@total_count.to_f / @per_page).ceil, 1].max + ) end end diff --git a/test/controllers/docs_controller_test.rb b/test/controllers/docs_controller_test.rb index da92f1498..62bb4c8f0 100644 --- a/test/controllers/docs_controller_test.rb +++ b/test/controllers/docs_controller_test.rb @@ -5,4 +5,29 @@ class DocsControllerTest < ActionDispatch::IntegrationTest get docs_typography_url assert_response :success end + + test "data_table with page param passes correct initial data" do + get docs_data_table_path, params: {page: 2, per_page: 5} + assert_response :success + # Page 2, per_page 5 means rows 6-10 are shown initially + # The data-value should reflect page 2 state + assert_match "data-ruby-ui--data-table-pagination-value", response.body + decoded = CGI.unescapeHTML(response.body) + assert_match '"pageIndex":1', decoded + assert_match '"pageSize":5', decoded + end + + test "data_table with search param pre-fills search value" do + get docs_data_table_path, params: {search: "alice"} + assert_response :success + assert_match "data-ruby-ui--data-table-search-value=\"alice\"", response.body + end + + test "data_table with sort param pre-fills sorting value" do + get docs_data_table_path, params: {sort: "name", direction: "desc"} + assert_response :success + decoded = CGI.unescapeHTML(response.body) + assert_match '"id":"name"', decoded + assert_match '"desc":true', decoded + end end From 8542ef8366d5a990c49de6fa963ced2d8f157116 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:27:10 -0300 Subject: [PATCH 26/52] feat(data_table): add cell renderer registry (text, badge, date, currency) --- .../ruby_ui/data_table_controller.js | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index abd76f23f..d6f2f0bf2 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -13,6 +13,27 @@ export default class extends Controller { search: { type: String, default: "" } } + static CELL_RENDERERS = { + text: (value) => escapeHtml(value ?? ""), + + badge: (value, meta) => { + const colors = meta?.colors ?? {} + const colorClass = colors[value] ?? "bg-secondary text-secondary-foreground" + return `${escapeHtml(value ?? "")}` + }, + + date: (value) => { + if (!value) return "" + const d = new Date(value) + return isNaN(d.getTime()) ? escapeHtml(value) : d.toLocaleDateString() + }, + + currency: (value) => { + if (value == null || value === "") return "" + return new Intl.NumberFormat("en-US", {style: "currency", currency: "USD", maximumFractionDigits: 0}).format(Number(value)) + } + } + connect() { this.searchTimeout = null @@ -21,7 +42,8 @@ export default class extends Controller { columns: this.columnsValue.map((c) => ({ id: c.key, accessorKey: c.key, - header: c.header + header: c.header, + meta: { type: c.type ?? "text", colors: c.colors ?? null } })), getCoreRowModel: getCoreRowModel(), renderFallbackValue: null, @@ -224,7 +246,10 @@ export default class extends Controller { const html = rows.map((row) => { const cells = row.getVisibleCells().map((cell) => { const value = cell.getValue() - return `${escapeHtml(value ?? "")}` + const meta = cell.column.columnDef.meta ?? {} + const type = meta.type ?? "text" + const renderer = this.constructor.CELL_RENDERERS[type] ?? this.constructor.CELL_RENDERERS.text + return `${renderer(value, meta)}` }).join("") return `${cells}` }).join("") From e4d323043e219c3e21cefe05238e029062469371 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:27:23 -0300 Subject: [PATCH 27/52] feat(docs): use badge/currency cell types in DataTable demo --- app/views/docs/data_table.rb | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 779e027d5..844a85786 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -2,11 +2,15 @@ class Views::Docs::DataTable < Views::Base COLUMNS = [ - {key: "name", header: "Name"}, - {key: "email", header: "Email"}, - {key: "department", header: "Department"}, - {key: "status", header: "Status"}, - {key: "salary", header: "Salary"} + {key: "name", header: "Name", type: "text"}, + {key: "email", header: "Email", type: "text"}, + {key: "department", header: "Department", type: "text"}, + {key: "status", header: "Status", type: "badge", colors: { + "Active" => "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + "Inactive" => "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + "On Leave" => "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + }}, + {key: "salary", header: "Salary", type: "currency"} ].freeze DEMO_EMPLOYEES = ::Docs::DataTableDemoController::EMPLOYEES.first(10).map do |e| From 6e025f9dd046967271c321020239a5d14db337ee Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:29:09 -0300 Subject: [PATCH 28/52] feat(data_table): expand demo dataset to 100 employees --- .../docs/data_table_demo_controller.rb | 130 ++++++++++++++---- app/views/docs/data_table.rb | 2 +- .../docs/data_table_demo_controller_test.rb | 10 +- 3 files changed, 109 insertions(+), 33 deletions(-) diff --git a/app/controllers/docs/data_table_demo_controller.rb b/app/controllers/docs/data_table_demo_controller.rb index 8429e5059..c69ada062 100644 --- a/app/controllers/docs/data_table_demo_controller.rb +++ b/app/controllers/docs/data_table_demo_controller.rb @@ -3,36 +3,106 @@ module Docs class DataTableDemoController < ApplicationController EMPLOYEES = [ - {id: 1, name: "Alice Johnson", email: "alice@example.com", department: "Engineering", status: "Active", salary: 95_000}, - {id: 2, name: "Bob Smith", email: "bob@example.com", department: "Design", status: "Active", salary: 82_000}, - {id: 3, name: "Carol White", email: "carol@example.com", department: "Product", status: "On Leave", salary: 88_000}, - {id: 4, name: "David Brown", email: "david@example.com", department: "Engineering", status: "Active", salary: 102_000}, - {id: 5, name: "Eve Davis", email: "eve@example.com", department: "Marketing", status: "Inactive", salary: 74_000}, - {id: 6, name: "Frank Miller", email: "frank@example.com", department: "Engineering", status: "Active", salary: 98_000}, - {id: 7, name: "Grace Lee", email: "grace@example.com", department: "HR", status: "Active", salary: 71_000}, - {id: 8, name: "Henry Wilson", email: "henry@example.com", department: "Finance", status: "Active", salary: 85_000}, - {id: 9, name: "Iris Martinez", email: "iris@example.com", department: "Design", status: "Inactive", salary: 79_000}, - {id: 10, name: "Jack Taylor", email: "jack@example.com", department: "Engineering", status: "Active", salary: 110_000}, - {id: 11, name: "Karen Anderson", email: "karen@example.com", department: "Marketing", status: "Active", salary: 76_000}, - {id: 12, name: "Liam Thomas", email: "liam@example.com", department: "Product", status: "Active", salary: 92_000}, - {id: 13, name: "Mia Jackson", email: "mia@example.com", department: "Engineering", status: "On Leave", salary: 96_000}, - {id: 14, name: "Noah Harris", email: "noah@example.com", department: "Finance", status: "Active", salary: 89_000}, - {id: 15, name: "Olivia Clark", email: "olivia@example.com", department: "HR", status: "Active", salary: 68_000}, - {id: 16, name: "Paul Lewis", email: "paul@example.com", department: "Design", status: "Active", salary: 84_000}, - {id: 17, name: "Quinn Robinson", email: "quinn@example.com", department: "Engineering", status: "Active", salary: 105_000}, - {id: 18, name: "Rachel Walker", email: "rachel@example.com", department: "Product", status: "Inactive", salary: 87_000}, - {id: 19, name: "Sam Young", email: "sam@example.com", department: "Marketing", status: "Active", salary: 72_000}, - {id: 20, name: "Tina Hall", email: "tina@example.com", department: "Finance", status: "Active", salary: 91_000}, - {id: 21, name: "Uma Allen", email: "uma@example.com", department: "Engineering", status: "Active", salary: 99_000}, - {id: 22, name: "Victor Scott", email: "victor@example.com", department: "Design", status: "On Leave", salary: 81_000}, - {id: 23, name: "Wendy Green", email: "wendy@example.com", department: "HR", status: "Active", salary: 70_000}, - {id: 24, name: "Xander Baker", email: "xander@example.com", department: "Engineering", status: "Active", salary: 108_000}, - {id: 25, name: "Yara Adams", email: "yara@example.com", department: "Product", status: "Active", salary: 93_000}, - {id: 26, name: "Zoe Nelson", email: "zoe@example.com", department: "Marketing", status: "Inactive", salary: 73_000}, - {id: 27, name: "Aaron Carter", email: "aaron@example.com", department: "Finance", status: "Active", salary: 86_000}, - {id: 28, name: "Bella Mitchell", email: "bella@example.com", department: "Engineering", status: "Active", salary: 101_000}, - {id: 29, name: "Carlos Perez", email: "carlos@example.com", department: "Design", status: "Active", salary: 83_000}, - {id: 30, name: "Diana Roberts", email: "diana@example.com", department: "Product", status: "Active", salary: 90_000} + {id: 1, name: "Alice Johnson", email: "alice.johnson@example.com", department: "Engineering", status: "Active", salary: 95_000}, + {id: 2, name: "Bob Smith", email: "bob.smith@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 3, name: "Carol White", email: "carol.white@example.com", department: "Product", status: "On Leave", salary: 88_000}, + {id: 4, name: "David Brown", email: "david.brown@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 5, name: "Eve Davis", email: "eve.davis@example.com", department: "Marketing", status: "Inactive", salary: 74_000}, + {id: 6, name: "Frank Miller", email: "frank.miller@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 7, name: "Grace Lee", email: "grace.lee@example.com", department: "HR", status: "Active", salary: 71_000}, + {id: 8, name: "Henry Wilson", email: "henry.wilson@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 9, name: "Iris Martinez", email: "iris.martinez@example.com", department: "Design", status: "Inactive", salary: 79_000}, + {id: 10, name: "Jack Taylor", email: "jack.taylor@example.com", department: "Engineering", status: "Active", salary: 110_000}, + {id: 11, name: "Karen Anderson", email: "karen.anderson@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 12, name: "Liam Thomas", email: "liam.thomas@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 13, name: "Mia Jackson", email: "mia.jackson@example.com", department: "Engineering", status: "On Leave", salary: 96_000}, + {id: 14, name: "Noah Harris", email: "noah.harris@example.com", department: "Finance", status: "Active", salary: 89_000}, + {id: 15, name: "Olivia Clark", email: "olivia.clark@example.com", department: "HR", status: "Active", salary: 68_000}, + {id: 16, name: "Paul Lewis", email: "paul.lewis@example.com", department: "Design", status: "Active", salary: 84_000}, + {id: 17, name: "Quinn Robinson", email: "quinn.robinson@example.com", department: "Engineering", status: "Active", salary: 105_000}, + {id: 18, name: "Rachel Walker", email: "rachel.walker@example.com", department: "Product", status: "Inactive", salary: 87_000}, + {id: 19, name: "Sam Young", email: "sam.young@example.com", department: "Marketing", status: "Active", salary: 72_000}, + {id: 20, name: "Tina Hall", email: "tina.hall@example.com", department: "Finance", status: "Active", salary: 91_000}, + {id: 21, name: "Uma Allen", email: "uma.allen@example.com", department: "Engineering", status: "Active", salary: 99_000}, + {id: 22, name: "Victor Scott", email: "victor.scott@example.com", department: "Design", status: "On Leave", salary: 81_000}, + {id: 23, name: "Wendy Green", email: "wendy.green@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 24, name: "Xander Baker", email: "xander.baker@example.com", department: "Engineering", status: "Active", salary: 108_000}, + {id: 25, name: "Yara Adams", email: "yara.adams@example.com", department: "Product", status: "Active", salary: 93_000}, + {id: 26, name: "Zoe Nelson", email: "zoe.nelson@example.com", department: "Marketing", status: "Inactive", salary: 73_000}, + {id: 27, name: "Aaron Carter", email: "aaron.carter@example.com", department: "Finance", status: "Active", salary: 86_000}, + {id: 28, name: "Bella Mitchell", email: "bella.mitchell@example.com", department: "Engineering", status: "Active", salary: 101_000}, + {id: 29, name: "Carlos Perez", email: "carlos.perez@example.com", department: "Design", status: "Active", salary: 83_000}, + {id: 30, name: "Diana Roberts", email: "diana.roberts@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 31, name: "Ethan Turner", email: "ethan.turner@example.com", department: "Engineering", status: "Active", salary: 97_000}, + {id: 32, name: "Fiona Phillips", email: "fiona.phillips@example.com", department: "HR", status: "Inactive", salary: 69_000}, + {id: 33, name: "George Campbell", email: "george.campbell@example.com", department: "Finance", status: "Active", salary: 94_000}, + {id: 34, name: "Hannah Parker", email: "hannah.parker@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 35, name: "Ivan Evans", email: "ivan.evans@example.com", department: "Engineering", status: "On Leave", salary: 103_000}, + {id: 36, name: "Julia Edwards", email: "julia.edwards@example.com", department: "Design", status: "Active", salary: 80_000}, + {id: 37, name: "Kevin Collins", email: "kevin.collins@example.com", department: "Product", status: "Active", salary: 91_000}, + {id: 38, name: "Laura Stewart", email: "laura.stewart@example.com", department: "Engineering", status: "Active", salary: 106_000}, + {id: 39, name: "Marcus Sanchez", email: "marcus.sanchez@example.com", department: "Finance", status: "Active", salary: 88_000}, + {id: 40, name: "Nina Morris", email: "nina.morris@example.com", department: "HR", status: "Active", salary: 72_000}, + {id: 41, name: "Oscar Rogers", email: "oscar.rogers@example.com", department: "Marketing", status: "Inactive", salary: 75_000}, + {id: 42, name: "Penny Reed", email: "penny.reed@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 43, name: "Quincy Cook", email: "quincy.cook@example.com", department: "Engineering", status: "Active", salary: 100_000}, + {id: 44, name: "Rose Morgan", email: "rose.morgan@example.com", department: "Product", status: "Active", salary: 89_000}, + {id: 45, name: "Steve Bell", email: "steve.bell@example.com", department: "Finance", status: "On Leave", salary: 87_000}, + {id: 46, name: "Tara Murphy", email: "tara.murphy@example.com", department: "Engineering", status: "Active", salary: 104_000}, + {id: 47, name: "Umar Bailey", email: "umar.bailey@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 48, name: "Vera Rivera", email: "vera.rivera@example.com", department: "Marketing", status: "Active", salary: 78_000}, + {id: 49, name: "William Cooper", email: "william.cooper@example.com", department: "Design", status: "Inactive", salary: 81_000}, + {id: 50, name: "Xena Richardson", email: "xena.richardson@example.com", department: "Engineering", status: "Active", salary: 107_000}, + {id: 51, name: "Yasmine Cox", email: "yasmine.cox@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 52, name: "Zachary Howard", email: "zachary.howard@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 53, name: "Amber Ward", email: "amber.ward@example.com", department: "Engineering", status: "Active", salary: 96_000}, + {id: 54, name: "Blake Torres", email: "blake.torres@example.com", department: "HR", status: "On Leave", salary: 71_000}, + {id: 55, name: "Chloe Peterson", email: "chloe.peterson@example.com", department: "Marketing", status: "Active", salary: 74_000}, + {id: 56, name: "Derek Gray", email: "derek.gray@example.com", department: "Design", status: "Active", salary: 83_000}, + {id: 57, name: "Elena Ramirez", email: "elena.ramirez@example.com", department: "Engineering", status: "Active", salary: 101_000}, + {id: 58, name: "Felix James", email: "felix.james@example.com", department: "Finance", status: "Inactive", salary: 88_000}, + {id: 59, name: "Gina Watson", email: "gina.watson@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 60, name: "Hugo Brooks", email: "hugo.brooks@example.com", department: "Engineering", status: "Active", salary: 109_000}, + {id: 61, name: "Irene Kelly", email: "irene.kelly@example.com", department: "HR", status: "Active", salary: 68_000}, + {id: 62, name: "Jonas Sanders", email: "jonas.sanders@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 63, name: "Kira Price", email: "kira.price@example.com", department: "Design", status: "On Leave", salary: 80_000}, + {id: 64, name: "Leo Bennett", email: "leo.bennett@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 65, name: "Maya Wood", email: "maya.wood@example.com", department: "Finance", status: "Active", salary: 91_000}, + {id: 66, name: "Nate Barnes", email: "nate.barnes@example.com", department: "Product", status: "Active", salary: 93_000}, + {id: 67, name: "Odessa Ross", email: "odessa.ross@example.com", department: "Engineering", status: "Inactive", salary: 97_000}, + {id: 68, name: "Pierce Henderson", email: "pierce.henderson@example.com", department: "HR", status: "Active", salary: 73_000}, + {id: 69, name: "Quinn Coleman", email: "quinn.coleman@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 70, name: "Ruby Jenkins", email: "ruby.jenkins@example.com", department: "Design", status: "Active", salary: 84_000}, + {id: 71, name: "Seth Perry", email: "seth.perry@example.com", department: "Engineering", status: "Active", salary: 103_000}, + {id: 72, name: "Tatum Powell", email: "tatum.powell@example.com", department: "Finance", status: "On Leave", salary: 86_000}, + {id: 73, name: "Uma Long", email: "uma.long@example.com", department: "Product", status: "Active", salary: 89_000}, + {id: 74, name: "Vince Patterson", email: "vince.patterson@example.com", department: "Engineering", status: "Active", salary: 105_000}, + {id: 75, name: "Willa Hughes", email: "willa.hughes@example.com", department: "HR", status: "Active", salary: 69_000}, + {id: 76, name: "Xander Flores", email: "xander.flores@example.com", department: "Marketing", status: "Inactive", salary: 75_000}, + {id: 77, name: "Yolanda Washington", email: "yolanda.washington@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 78, name: "Zack Butler", email: "zack.butler@example.com", department: "Engineering", status: "Active", salary: 100_000}, + {id: 79, name: "Alicia Simmons", email: "alicia.simmons@example.com", department: "Finance", status: "Active", salary: 87_000}, + {id: 80, name: "Brett Foster", email: "brett.foster@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 81, name: "Cassie Gonzales", email: "cassie.gonzales@example.com", department: "Engineering", status: "On Leave", salary: 99_000}, + {id: 82, name: "Drew Bryant", email: "drew.bryant@example.com", department: "HR", status: "Active", salary: 71_000}, + {id: 83, name: "Elsa Alexander", email: "elsa.alexander@example.com", department: "Marketing", status: "Active", salary: 78_000}, + {id: 84, name: "Floyd Russell", email: "floyd.russell@example.com", department: "Design", status: "Active", salary: 81_000}, + {id: 85, name: "Greta Griffin", email: "greta.griffin@example.com", department: "Engineering", status: "Active", salary: 107_000}, + {id: 86, name: "Hector Diaz", email: "hector.diaz@example.com", department: "Finance", status: "Inactive", salary: 85_000}, + {id: 87, name: "Isla Hayes", email: "isla.hayes@example.com", department: "Product", status: "Active", salary: 91_000}, + {id: 88, name: "Jared Myers", email: "jared.myers@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 89, name: "Kara Ford", email: "kara.ford@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 90, name: "Lionel Hamilton", email: "lionel.hamilton@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 91, name: "Mabel Graham", email: "mabel.graham@example.com", department: "Design", status: "On Leave", salary: 83_000}, + {id: 92, name: "Nolan Sullivan", email: "nolan.sullivan@example.com", department: "Engineering", status: "Active", salary: 106_000}, + {id: 93, name: "Opal Wallace", email: "opal.wallace@example.com", department: "Finance", status: "Active", salary: 88_000}, + {id: 94, name: "Preston Woods", email: "preston.woods@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 95, name: "Queenie Cole", email: "queenie.cole@example.com", department: "Engineering", status: "Inactive", salary: 95_000}, + {id: 96, name: "Regan West", email: "regan.west@example.com", department: "HR", status: "Active", salary: 72_000}, + {id: 97, name: "Spencer Jordan", email: "spencer.jordan@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 98, name: "Tess Owens", email: "tess.owens@example.com", department: "Design", status: "Active", salary: 80_000}, + {id: 99, name: "Uriah Reynolds", email: "uriah.reynolds@example.com", department: "Engineering", status: "Active", salary: 104_000}, + {id: 100, name: "Violet Fisher", email: "violet.fisher@example.com", department: "Finance", status: "Active", salary: 86_000} ].map { |e| Data.define(*e.keys).new(**e) }.freeze def index diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 844a85786..a93db4793 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -17,7 +17,7 @@ class Views::Docs::DataTable < Views::Base {id: e.id, name: e.name, email: e.email, department: e.department, status: e.status, salary: e.salary} end.freeze - def initialize(initial_data: DEMO_EMPLOYEES, total_count: 30, + def initialize(initial_data: DEMO_EMPLOYEES, total_count: 100, page: 1, per_page: 10, sort: nil, direction: nil, search: nil) @initial_data = initial_data @total_count = total_count diff --git a/test/controllers/docs/data_table_demo_controller_test.rb b/test/controllers/docs/data_table_demo_controller_test.rb index 95f05bbca..decce242e 100644 --- a/test/controllers/docs/data_table_demo_controller_test.rb +++ b/test/controllers/docs/data_table_demo_controller_test.rb @@ -24,7 +24,7 @@ class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest assert_response :success json = JSON.parse(response.body) assert_equal 5, json["data"].length - assert_equal 30, json["row_count"] + assert_equal 100, json["row_count"] end test "JSON response respects search param" do @@ -32,7 +32,7 @@ class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest headers: { "Accept" => "application/json" } json = JSON.parse(response.body) assert json["data"].all? { |r| r["name"].downcase.include?("alice") || r["email"].downcase.include?("alice") } - assert json["row_count"] < 30 + assert json["row_count"] < 100 end test "docs data_table page includes pagination data-value" do @@ -59,4 +59,10 @@ class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest names = json["data"].map { |r| r["name"] } assert_equal names.sort.reverse, names end + + test "dataset has 100 employees" do + get docs_data_table_demo_path, headers: {"Accept" => "application/json"} + json = JSON.parse(response.body) + assert_equal 100, json["row_count"] + end end From 7fb4ce38b2a3f883fe47adfb109b394eb6434879 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:32:02 -0300 Subject: [PATCH 29/52] docs(data_table): rewrite with full prose documentation and 100-row demo --- app/views/docs/data_table.rb | 225 ++++++++++++++++++++++++++++++++++- 1 file changed, 221 insertions(+), 4 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index a93db4793..2b3f7eea4 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -34,14 +34,14 @@ def view_template div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new( title: component, - description: "A headless data table powered by TanStack Table Core and Hotwire. Server-side pagination, sorting, and search — no client-side dataset." + description: "A headless, server-side data table powered by TanStack Table Core and Hotwire. Pagination, sorting, and search all hit the Rails backend — no client-side dataset." ) + # ── Full-Featured Demo ────────────────────────────────────────────────── Heading(level: 2) { "Demo" } p(class: "text-sm text-muted-foreground -mt-6") { - "Live demo — pagination, sorting, and search. URL reflects current state; paste it to share or reload to restore." + "100 employees. Pagination, column sorting, free-text search, and configurable rows per page. The URL reflects state — share it or reload to restore." } - div(class: "rounded-lg border p-6") do DataTable( src: docs_data_table_demo_path, @@ -56,7 +56,7 @@ def view_template ) do DataTableToolbar do DataTableSearch(placeholder: "Search by name or email...") - DataTablePerPage(options: [5, 10, 20], current: @per_page) + DataTablePerPage(options: [5, 10, 25, 50], current: @per_page) end DataTableContent() DataTablePagination( @@ -66,6 +66,223 @@ def view_template end end + # ── Overview ──────────────────────────────────────────────────────────── + Heading(level: 2) { "Overview" } + p(class: "text-sm text-muted-foreground") { + "DataTable is headless — it has no built-in visual chrome. Instead, it wires " + "TanStack Table Core (a framework-agnostic state machine) to a Stimulus controller " + "that manages state, builds JSON fetch URLs, and renders " + code(class: "font-mono text-xs") { "" } + " and " + code(class: "font-mono text-xs") { "" } + " into the empty shell emitted by " + code(class: "font-mono text-xs") { "DataTableContent" } + ". Every interaction that changes visible data — pagination, sorting, search, " + "rows-per-page — hits the Rails controller via a plain " + code(class: "font-mono text-xs") { "fetch()" } + " request with " + code(class: "font-mono text-xs") { "Accept: application/json" } + ". The server is always the source of truth." + } + + # ── Installation ──────────────────────────────────────────────────────── + Heading(level: 2) { "Installation" } + p(class: "text-sm text-muted-foreground") { + "DataTable requires " + code(class: "font-mono text-xs") { "@tanstack/table-core" } + " in addition to the standard ruby_ui setup." + } + Codeblock("rails g ruby_ui:component DataTable", syntax: :bash) + Codeblock("pnpm add @tanstack/table-core\npnpm build", syntax: :bash) + + # ── How it works ──────────────────────────────────────────────────────── + Heading(level: 2) { "How it works" } + p(class: "text-sm text-muted-foreground") { + "All data operations are " + strong { "server-side" } + ". TanStack Table Core is configured with " + code(class: "font-mono text-xs") { "manualPagination: true" } + ", " + code(class: "font-mono text-xs") { "manualSorting: true" } + ", and " + code(class: "font-mono text-xs") { "manualFiltering: true" } + ". It never slices or sorts the in-memory array — it only tracks state " + "(current page, sort column, search query) and notifies the Stimulus controller " + "via callbacks (" + code(class: "font-mono text-xs") { "onPaginationChange" } + ", " + code(class: "font-mono text-xs") { "onSortingChange" } + "). The controller fetches a new JSON page from your Rails endpoint and calls " + code(class: "font-mono text-xs") { "table.setOptions({data, rowCount})" } + " to update what TanStack renders." + } + + # ── Rails controller setup ─────────────────────────────────────────────── + Heading(level: 2) { "Rails controller setup" } + p(class: "text-sm text-muted-foreground") { + "Your endpoint must respond to both HTML (initial page load) and JSON (subsequent fetches). " + "It receives the params " + code(class: "font-mono text-xs") { "page" } + ", " + code(class: "font-mono text-xs") { "per_page" } + ", " + code(class: "font-mono text-xs") { "sort" } + ", " + code(class: "font-mono text-xs") { "direction" } + ", and " + code(class: "font-mono text-xs") { "search" } + "." + } + Codeblock(<<~RUBY, syntax: :ruby) + class EmployeesController < ApplicationController + def index + employees = Employee.all + employees = employees.where("name ILIKE ?", "%\#{params[:search]}%") if params[:search].present? + employees = employees.order(params[:sort] => params[:direction] || "asc") if params[:sort].present? + + @total_count = employees.count + @per_page = (params[:per_page] || 10).to_i.clamp(1, 100) + @page = (params[:page] || 1).to_i + @employees = employees.page(@page).per(@per_page) + + respond_to do |format| + format.html # renders view with initial data + format.json { render json: { data: @employees.as_json, row_count: @total_count } } + end + end + end + RUBY + + # ── Basic usage ────────────────────────────────────────────────────────── + Heading(level: 2) { "Basic usage" } + p(class: "text-sm text-muted-foreground") { + "Pass " + code(class: "font-mono text-xs") { "data:" } + " (current page rows as hashes), " + code(class: "font-mono text-xs") { "columns:" } + " (column definitions), " + code(class: "font-mono text-xs") { "src:" } + " (JSON endpoint URL), and " + code(class: "font-mono text-xs") { "row_count:" } + " (total rows). Nest " + code(class: "font-mono text-xs") { "DataTableContent" } + " inside to get the table shell." + } + Codeblock(<<~RUBY, syntax: :ruby) + DataTable( + src: employees_path, + data: @employees.map { |e| {id: e.id, name: e.name, email: e.email} }, + columns: [ + {key: "name", header: "Name"}, + {key: "email", header: "Email"} + ], + row_count: @total_count, + page: params[:page] || 1, + per_page: params[:per_page] || 10 + ) do + DataTableContent() + end + RUBY + + # ── With pagination ─────────────────────────────────────────────────────── + Heading(level: 2) { "Adding pagination" } + p(class: "text-sm text-muted-foreground") { + "Add " + code(class: "font-mono text-xs") { "DataTablePagination" } + " inside the " + code(class: "font-mono text-xs") { "DataTable" } + " block. It reads " + code(class: "font-mono text-xs") { "current_page" } + " and " + code(class: "font-mono text-xs") { "total_pages" } + " for the initial disabled-state of Prev/Next buttons. The Stimulus controller " + "updates these live after each fetch via " + code(class: "font-mono text-xs") { "data-ruby-ui--data-table-target" } + " attributes on the buttons." + } + Codeblock(<<~RUBY, syntax: :ruby) + DataTable(src: employees_path, data: @employees.map(&:to_h), + columns: columns, row_count: @total_count, + page: @page, per_page: @per_page) do + DataTableContent() + DataTablePagination(current_page: @page, total_pages: @total_pages) + end + RUBY + + # ── With toolbar (search + per-page) ───────────────────────────────────── + Heading(level: 2) { "Adding search and per-page" } + p(class: "text-sm text-muted-foreground") { + "Wrap " + code(class: "font-mono text-xs") { "DataTableSearch" } + " and " + code(class: "font-mono text-xs") { "DataTablePerPage" } + " in a " + code(class: "font-mono text-xs") { "DataTableToolbar" } + ". Search is debounced 300ms client-side. Both reset the page to 1 before fetching." + } + Codeblock(<<~RUBY, syntax: :ruby) + DataTable(...) do + DataTableToolbar do + DataTableSearch(placeholder: "Search employees...") + DataTablePerPage(options: [10, 25, 50], current: @per_page) + end + DataTableContent() + DataTablePagination(current_page: @page, total_pages: @total_pages) + end + RUBY + + # ── Cell types ─────────────────────────────────────────────────────────── + Heading(level: 2) { "Cell types" } + p(class: "text-sm text-muted-foreground") { + "Each column definition accepts an optional " + code(class: "font-mono text-xs") { "type:" } + " field. Available built-in types: " + code(class: "font-mono text-xs") { "text" } + " (default), " + code(class: "font-mono text-xs") { "badge" } + " (colored pill — pass " + code(class: "font-mono text-xs") { "colors: {\"Value\" => \"tailwind-classes\"}" } + "), " + code(class: "font-mono text-xs") { "currency" } + " (USD, no decimals), " + code(class: "font-mono text-xs") { "date" } + " (locale date string)." + } + Codeblock(<<~RUBY, syntax: :ruby) + columns: [ + {key: "name", header: "Name", type: "text"}, + {key: "status", header: "Status", type: "badge", colors: { + "Active" => "bg-green-100 text-green-800", + "Inactive" => "bg-red-100 text-red-800" + }}, + {key: "salary", header: "Salary", type: "currency"}, + {key: "hired_at", header: "Hired", type: "date"} + ] + RUBY + + # ── URL state ──────────────────────────────────────────────────────────── + Heading(level: 2) { "URL state" } + p(class: "text-sm text-muted-foreground") { + "The URL is updated via " + code(class: "font-mono text-xs") { "history.replaceState" } + " after every state change. To restore state on page load, read URL params in your " + "Rails action and pass them to the view, which forwards them to " + code(class: "font-mono text-xs") { "DataTable" } + " as " + code(class: "font-mono text-xs") { "page:" } + ", " + code(class: "font-mono text-xs") { "sort:" } + ", " + code(class: "font-mono text-xs") { "direction:" } + ", " + code(class: "font-mono text-xs") { "search:" } + ", and " + code(class: "font-mono text-xs") { "per_page:" } + ". The component serializes them as Stimulus values; " + code(class: "font-mono text-xs") { "connect()" } + " hydrates TanStack with the correct initial state." + } + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) end From 6c5094d136c988f6e69d167e5c9ff9adc2ed7dea Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:32:17 -0300 Subject: [PATCH 30/52] refactor(data_table): clean up demo HTML view, remove dead turbo-frame --- app/views/docs/data_table_demo/index.rb | 99 ++++++++----------------- 1 file changed, 29 insertions(+), 70 deletions(-) diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index d850aa062..e7cb3e965 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -1,79 +1,38 @@ # frozen_string_literal: true -module Views - module Docs - module DataTableDemo - class Index < Views::Base - include Phlex::Rails::Helpers::TurboFrameTag - - def initialize(employees:, current_page:, total_pages:, total_count:, per_page:, sort:, direction:) - @employees = employees - @current_page = current_page - @total_pages = total_pages - @total_count = total_count - @per_page = per_page - @sort = sort - @direction = direction - end +class Views::Docs::DataTableDemo::Index < Views::Base + def initialize(employees:, current_page:, total_pages:, total_count:, per_page:, sort:, direction:) + @employees = employees + @current_page = current_page + @total_pages = total_pages + @total_count = total_count + @per_page = per_page + @sort = sort + @direction = direction + end - def view_template - turbo_frame_tag "data_table_content" do - div(class: "rounded-md border") do - Table do - TableHeader do - TableRow do - TableHead { "Name" } - TableHead { "Email" } - TableHead { "Department" } - TableHead { "Status" } - TableHead { "Salary" } - end - end - TableBody do - if @employees.empty? - TableRow do - TableCell(colspan: 5, class: "text-center text-muted-foreground py-8") { "No results found." } - end - else - @employees.each do |employee| - TableRow do - TableCell(class: "font-medium") { employee.name } - TableCell(class: "text-muted-foreground") { employee.email } - TableCell { employee.department } - TableCell { status_badge(employee.status) } - TableCell { format_salary(employee.salary) } - end - end - end - end - end - end - div(class: "flex items-center justify-between px-2 py-4") do - div(class: "text-sm text-muted-foreground") do - plain "Showing #{@employees.size} of #{@total_count} results" - end - DataTablePagination(current_page: @current_page, total_pages: @total_pages) - end + def view_template + div(class: "rounded-md border") do + Table do + TableHeader do + TableRow do + TableHead { "Name" } + TableHead { "Email" } + TableHead { "Department" } + TableHead { "Status" } + TableHead { "Salary" } end end - - private - - def col_direction(col) - @sort == col ? @direction : nil - end - - def format_salary(amount) - "$#{amount.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse}" - end - - def status_badge(status) - color = case status - when "Active" then "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" - when "Inactive" then "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" - else "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + TableBody do + @employees.each do |employee| + TableRow do + TableCell(class: "font-medium") { employee.name } + TableCell(class: "text-muted-foreground") { employee.email } + TableCell { employee.department } + TableCell { employee.status } + TableCell { "$#{employee.salary.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse}" } + end end - span(class: "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{color}") { status } end end end From 53d73ba936139f17dffb4efb2c4f3edf2d77e8bc Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:33:02 -0300 Subject: [PATCH 31/52] test(data_table): remove Selenium skeleton test (incompatible with devcontainer) --- test/system/docs/data_table_skeleton_test.rb | 22 -------------------- 1 file changed, 22 deletions(-) delete mode 100644 test/system/docs/data_table_skeleton_test.rb diff --git a/test/system/docs/data_table_skeleton_test.rb b/test/system/docs/data_table_skeleton_test.rb deleted file mode 100644 index 3463c81d5..000000000 --- a/test/system/docs/data_table_skeleton_test.rb +++ /dev/null @@ -1,22 +0,0 @@ -require "application_system_test_case" - -class Docs::DataTableSkeletonTest < ApplicationSystemTestCase - test "renders rows from data attribute via TanStack" do - visit "/docs/data_table" - - # Wait for Stimulus to connect and TanStack to render rows - assert_selector "[data-controller='ruby-ui--data-table'] tbody tr", minimum: 3 - - # First row should contain the seed data - within("[data-controller='ruby-ui--data-table'] tbody tr:first-child") do - assert_text "Alice Johnson" - assert_text "alice@example.com" - end - - # Headers should come from columns config - within("[data-controller='ruby-ui--data-table'] thead") do - assert_text "Name" - assert_text "Email" - end - end -end From 0cd0bac78dc1ff66d63cec5c419d223a67770dc4 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:40:51 -0300 Subject: [PATCH 32/52] fix(docs): add plain() to all inline text nodes in data_table docs --- app/views/docs/data_table.rb | 126 +++++++++++++++++------------------ 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 2b3f7eea4..521794701 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -69,28 +69,28 @@ def view_template # ── Overview ──────────────────────────────────────────────────────────── Heading(level: 2) { "Overview" } p(class: "text-sm text-muted-foreground") { - "DataTable is headless — it has no built-in visual chrome. Instead, it wires " - "TanStack Table Core (a framework-agnostic state machine) to a Stimulus controller " - "that manages state, builds JSON fetch URLs, and renders " + plain "DataTable is headless — it has no built-in visual chrome. Instead, it wires " + plain "TanStack Table Core (a framework-agnostic state machine) to a Stimulus controller " + plain "that manages state, builds JSON fetch URLs, and renders " code(class: "font-mono text-xs") { "" } - " and " + plain " and " code(class: "font-mono text-xs") { "" } - " into the empty shell emitted by " + plain " into the empty shell emitted by " code(class: "font-mono text-xs") { "DataTableContent" } - ". Every interaction that changes visible data — pagination, sorting, search, " - "rows-per-page — hits the Rails controller via a plain " + plain ". Every interaction that changes visible data — pagination, sorting, search, " + plain "rows-per-page — hits the Rails controller via a plain " code(class: "font-mono text-xs") { "fetch()" } - " request with " + plain " request with " code(class: "font-mono text-xs") { "Accept: application/json" } - ". The server is always the source of truth." + plain ". The server is always the source of truth." } # ── Installation ──────────────────────────────────────────────────────── Heading(level: 2) { "Installation" } p(class: "text-sm text-muted-foreground") { - "DataTable requires " + plain "DataTable requires " code(class: "font-mono text-xs") { "@tanstack/table-core" } - " in addition to the standard ruby_ui setup." + plain " in addition to the standard ruby_ui setup." } Codeblock("rails g ruby_ui:component DataTable", syntax: :bash) Codeblock("pnpm add @tanstack/table-core\npnpm build", syntax: :bash) @@ -98,40 +98,40 @@ def view_template # ── How it works ──────────────────────────────────────────────────────── Heading(level: 2) { "How it works" } p(class: "text-sm text-muted-foreground") { - "All data operations are " + plain "All data operations are " strong { "server-side" } - ". TanStack Table Core is configured with " + plain ". TanStack Table Core is configured with " code(class: "font-mono text-xs") { "manualPagination: true" } - ", " + plain ", " code(class: "font-mono text-xs") { "manualSorting: true" } - ", and " + plain ", and " code(class: "font-mono text-xs") { "manualFiltering: true" } - ". It never slices or sorts the in-memory array — it only tracks state " - "(current page, sort column, search query) and notifies the Stimulus controller " - "via callbacks (" + plain ". It never slices or sorts the in-memory array — it only tracks state " + plain "(current page, sort column, search query) and notifies the Stimulus controller " + plain "via callbacks (" code(class: "font-mono text-xs") { "onPaginationChange" } - ", " + plain ", " code(class: "font-mono text-xs") { "onSortingChange" } - "). The controller fetches a new JSON page from your Rails endpoint and calls " + plain "). The controller fetches a new JSON page from your Rails endpoint and calls " code(class: "font-mono text-xs") { "table.setOptions({data, rowCount})" } - " to update what TanStack renders." + plain " to update what TanStack renders." } # ── Rails controller setup ─────────────────────────────────────────────── Heading(level: 2) { "Rails controller setup" } p(class: "text-sm text-muted-foreground") { - "Your endpoint must respond to both HTML (initial page load) and JSON (subsequent fetches). " - "It receives the params " + plain "Your endpoint must respond to both HTML (initial page load) and JSON (subsequent fetches). " + plain "It receives the params " code(class: "font-mono text-xs") { "page" } - ", " + plain ", " code(class: "font-mono text-xs") { "per_page" } - ", " + plain ", " code(class: "font-mono text-xs") { "sort" } - ", " + plain ", " code(class: "font-mono text-xs") { "direction" } - ", and " + plain ", and " code(class: "font-mono text-xs") { "search" } - "." + plain "." } Codeblock(<<~RUBY, syntax: :ruby) class EmployeesController < ApplicationController @@ -156,17 +156,17 @@ def index # ── Basic usage ────────────────────────────────────────────────────────── Heading(level: 2) { "Basic usage" } p(class: "text-sm text-muted-foreground") { - "Pass " + plain "Pass " code(class: "font-mono text-xs") { "data:" } - " (current page rows as hashes), " + plain " (current page rows as hashes), " code(class: "font-mono text-xs") { "columns:" } - " (column definitions), " + plain " (column definitions), " code(class: "font-mono text-xs") { "src:" } - " (JSON endpoint URL), and " + plain " (JSON endpoint URL), and " code(class: "font-mono text-xs") { "row_count:" } - " (total rows). Nest " + plain " (total rows). Nest " code(class: "font-mono text-xs") { "DataTableContent" } - " inside to get the table shell." + plain " inside to get the table shell." } Codeblock(<<~RUBY, syntax: :ruby) DataTable( @@ -187,18 +187,18 @@ def index # ── With pagination ─────────────────────────────────────────────────────── Heading(level: 2) { "Adding pagination" } p(class: "text-sm text-muted-foreground") { - "Add " + plain "Add " code(class: "font-mono text-xs") { "DataTablePagination" } - " inside the " + plain " inside the " code(class: "font-mono text-xs") { "DataTable" } - " block. It reads " + plain " block. It reads " code(class: "font-mono text-xs") { "current_page" } - " and " + plain " and " code(class: "font-mono text-xs") { "total_pages" } - " for the initial disabled-state of Prev/Next buttons. The Stimulus controller " - "updates these live after each fetch via " + plain " for the initial disabled-state of Prev/Next buttons. The Stimulus controller " + plain "updates these live after each fetch via " code(class: "font-mono text-xs") { "data-ruby-ui--data-table-target" } - " attributes on the buttons." + plain " attributes on the buttons." } Codeblock(<<~RUBY, syntax: :ruby) DataTable(src: employees_path, data: @employees.map(&:to_h), @@ -212,13 +212,13 @@ def index # ── With toolbar (search + per-page) ───────────────────────────────────── Heading(level: 2) { "Adding search and per-page" } p(class: "text-sm text-muted-foreground") { - "Wrap " + plain "Wrap " code(class: "font-mono text-xs") { "DataTableSearch" } - " and " + plain " and " code(class: "font-mono text-xs") { "DataTablePerPage" } - " in a " + plain " in a " code(class: "font-mono text-xs") { "DataTableToolbar" } - ". Search is debounced 300ms client-side. Both reset the page to 1 before fetching." + plain ". Search is debounced 300ms client-side. Both reset the page to 1 before fetching." } Codeblock(<<~RUBY, syntax: :ruby) DataTable(...) do @@ -234,19 +234,19 @@ def index # ── Cell types ─────────────────────────────────────────────────────────── Heading(level: 2) { "Cell types" } p(class: "text-sm text-muted-foreground") { - "Each column definition accepts an optional " + plain "Each column definition accepts an optional " code(class: "font-mono text-xs") { "type:" } - " field. Available built-in types: " + plain " field. Available built-in types: " code(class: "font-mono text-xs") { "text" } - " (default), " + plain " (default), " code(class: "font-mono text-xs") { "badge" } - " (colored pill — pass " - code(class: "font-mono text-xs") { "colors: {\"Value\" => \"tailwind-classes\"}" } - "), " + plain " (colored pill — pass " + code(class: "font-mono text-xs") { 'colors: {"Value" => "tailwind-classes"}' } + plain "), " code(class: "font-mono text-xs") { "currency" } - " (USD, no decimals), " + plain " (USD, no decimals), " code(class: "font-mono text-xs") { "date" } - " (locale date string)." + plain " (locale date string)." } Codeblock(<<~RUBY, syntax: :ruby) columns: [ @@ -263,24 +263,24 @@ def index # ── URL state ──────────────────────────────────────────────────────────── Heading(level: 2) { "URL state" } p(class: "text-sm text-muted-foreground") { - "The URL is updated via " + plain "The URL is updated via " code(class: "font-mono text-xs") { "history.replaceState" } - " after every state change. To restore state on page load, read URL params in your " - "Rails action and pass them to the view, which forwards them to " + plain " after every state change. To restore state on page load, read URL params in your " + plain "Rails action and pass them to the view, which forwards them to " code(class: "font-mono text-xs") { "DataTable" } - " as " + plain " as " code(class: "font-mono text-xs") { "page:" } - ", " + plain ", " code(class: "font-mono text-xs") { "sort:" } - ", " + plain ", " code(class: "font-mono text-xs") { "direction:" } - ", " + plain ", " code(class: "font-mono text-xs") { "search:" } - ", and " + plain ", and " code(class: "font-mono text-xs") { "per_page:" } - ". The component serializes them as Stimulus values; " + plain ". The component serializes them as Stimulus values; " code(class: "font-mono text-xs") { "connect()" } - " hydrates TanStack with the correct initial state." + plain " hydrates TanStack with the correct initial state." } render Components::ComponentSetup::Tabs.new(component_name: component) From 755a4bfe243d6d7a1e8049762de3737d24ad59c6 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:44:35 -0300 Subject: [PATCH 33/52] fix(docs): rewrite data_table docs with VisualCodeExample, plain() text, Components table, TanStack links --- app/views/docs/data_table.rb | 308 +++++++++++++++++++++-------------- 1 file changed, 185 insertions(+), 123 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 521794701..fef9f52a2 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -18,7 +18,7 @@ class Views::Docs::DataTable < Views::Base end.freeze def initialize(initial_data: DEMO_EMPLOYEES, total_count: 100, - page: 1, per_page: 10, sort: nil, direction: nil, search: nil) + page: 1, per_page: 10, sort: nil, direction: nil, search: nil) @initial_data = initial_data @total_count = total_count @page = page @@ -40,8 +40,10 @@ def view_template # ── Full-Featured Demo ────────────────────────────────────────────────── Heading(level: 2) { "Demo" } p(class: "text-sm text-muted-foreground -mt-6") { - "100 employees. Pagination, column sorting, free-text search, and configurable rows per page. The URL reflects state — share it or reload to restore." + plain "100 employees. Pagination, column sorting, free-text search, and configurable rows per page. " + plain "The URL reflects state — share it or reload to restore." } + div(class: "rounded-lg border p-6") do DataTable( src: docs_data_table_demo_path, @@ -66,21 +68,110 @@ def view_template end end + # ── Usage ────────────────────────────────────────────────────────────── + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Basic — static data, no server", context: self) do + <<~RUBY + DataTable( + data: [ + {name: "Alice Johnson", department: "Engineering"}, + {name: "Bob Smith", department: "Design"}, + {name: "Carol White", department: "Product"} + ], + columns: [ + {key: "name", header: "Name"}, + {key: "department", header: "Department"} + ] + ) do + DataTableContent() + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With cell types (badge + currency)", context: self) do + <<~RUBY + DataTable( + data: [ + {name: "Alice Johnson", status: "Active", salary: 95_000}, + {name: "Bob Smith", status: "Inactive", salary: 82_000}, + {name: "Carol White", status: "On Leave", salary: 88_000} + ], + columns: [ + {key: "name", header: "Name", type: "text"}, + {key: "status", header: "Status", type: "badge", colors: { + "Active" => "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + "Inactive" => "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + "On Leave" => "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + }}, + {key: "salary", header: "Salary", type: "currency"} + ] + ) do + DataTableContent() + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With pagination (server-side)", context: self) do + <<~RUBY + DataTable( + src: docs_data_table_demo_path, + data: ::Docs::DataTableDemoController::EMPLOYEES.first(5).map { |e| + {name: e.name, department: e.department} + }, + columns: [{key: "name", header: "Name"}, {key: "department", header: "Department"}], + row_count: 100, + page: 1, + per_page: 5 + ) do + DataTableContent() + DataTablePagination(current_page: 1, total_pages: 20) + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With toolbar (search + rows per page)", context: self) do + <<~RUBY + DataTable( + src: docs_data_table_demo_path, + data: ::Docs::DataTableDemoController::EMPLOYEES.first(5).map { |e| + {name: e.name, email: e.email, department: e.department} + }, + columns: [ + {key: "name", header: "Name"}, + {key: "email", header: "Email"}, + {key: "department", header: "Department"} + ], + row_count: 100, + page: 1, + per_page: 5 + ) do + DataTableToolbar do + DataTableSearch(placeholder: "Search employees...") + DataTablePerPage(options: [5, 10, 25], current: 5) + end + DataTableContent() + DataTablePagination(current_page: 1, total_pages: 20) + end + RUBY + end + # ── Overview ──────────────────────────────────────────────────────────── Heading(level: 2) { "Overview" } p(class: "text-sm text-muted-foreground") { - plain "DataTable is headless — it has no built-in visual chrome. Instead, it wires " - plain "TanStack Table Core (a framework-agnostic state machine) to a Stimulus controller " - plain "that manages state, builds JSON fetch URLs, and renders " + plain "DataTable is headless — it has no built-in visual chrome. It wires " + a(href: "https://tanstack.com/table/latest/docs/vanilla", target: "_blank", class: "underline underline-offset-2") { "TanStack Table Core (vanilla)" } + plain " — a framework-agnostic state machine — to a Stimulus controller that manages state, " + plain "builds fetch URLs, and renders " code(class: "font-mono text-xs") { "" } plain " and " code(class: "font-mono text-xs") { "" } plain " into the empty shell emitted by " code(class: "font-mono text-xs") { "DataTableContent" } - plain ". Every interaction that changes visible data — pagination, sorting, search, " - plain "rows-per-page — hits the Rails controller via a plain " + plain ". Every interaction — pagination, sorting, search, rows-per-page — hits the Rails " + plain "controller via " code(class: "font-mono text-xs") { "fetch()" } - plain " request with " + plain " with " code(class: "font-mono text-xs") { "Accept: application/json" } plain ". The server is always the source of truth." } @@ -92,7 +183,10 @@ def view_template code(class: "font-mono text-xs") { "@tanstack/table-core" } plain " in addition to the standard ruby_ui setup." } + + p(class: "text-xs font-semibold text-muted-foreground uppercase tracking-wide") { plain "1. Add component" } Codeblock("rails g ruby_ui:component DataTable", syntax: :bash) + p(class: "text-xs font-semibold text-muted-foreground uppercase tracking-wide") { plain "2. Install TanStack Table Core" } Codeblock("pnpm add @tanstack/table-core\npnpm build", syntax: :bash) # ── How it works ──────────────────────────────────────────────────────── @@ -100,28 +194,27 @@ def view_template p(class: "text-sm text-muted-foreground") { plain "All data operations are " strong { "server-side" } - plain ". TanStack Table Core is configured with " + plain ". TanStack is configured with " code(class: "font-mono text-xs") { "manualPagination: true" } plain ", " code(class: "font-mono text-xs") { "manualSorting: true" } plain ", and " code(class: "font-mono text-xs") { "manualFiltering: true" } - plain ". It never slices or sorts the in-memory array — it only tracks state " - plain "(current page, sort column, search query) and notifies the Stimulus controller " - plain "via callbacks (" + plain ". It never slices or sorts the data array — it only tracks state " + plain "and fires callbacks (" code(class: "font-mono text-xs") { "onPaginationChange" } plain ", " code(class: "font-mono text-xs") { "onSortingChange" } - plain "). The controller fetches a new JSON page from your Rails endpoint and calls " + plain "). The Stimulus controller fetches a fresh JSON page from Rails and calls " code(class: "font-mono text-xs") { "table.setOptions({data, rowCount})" } - plain " to update what TanStack renders." + plain " to update what renders." } # ── Rails controller setup ─────────────────────────────────────────────── Heading(level: 2) { "Rails controller setup" } p(class: "text-sm text-muted-foreground") { - plain "Your endpoint must respond to both HTML (initial page load) and JSON (subsequent fetches). " - plain "It receives the params " + plain "Your endpoint responds to both HTML (initial load) and JSON (subsequent fetches). " + plain "It accepts params: " code(class: "font-mono text-xs") { "page" } plain ", " code(class: "font-mono text-xs") { "per_page" } @@ -129,10 +222,11 @@ def view_template code(class: "font-mono text-xs") { "sort" } plain ", " code(class: "font-mono text-xs") { "direction" } - plain ", and " + plain ", " code(class: "font-mono text-xs") { "search" } plain "." } + Codeblock(<<~RUBY, syntax: :ruby) class EmployeesController < ApplicationController def index @@ -147,128 +241,39 @@ def index respond_to do |format| format.html # renders view with initial data - format.json { render json: { data: @employees.as_json, row_count: @total_count } } + format.json { render json: {data: @employees.as_json, row_count: @total_count} } end end end RUBY - # ── Basic usage ────────────────────────────────────────────────────────── - Heading(level: 2) { "Basic usage" } - p(class: "text-sm text-muted-foreground") { - plain "Pass " - code(class: "font-mono text-xs") { "data:" } - plain " (current page rows as hashes), " - code(class: "font-mono text-xs") { "columns:" } - plain " (column definitions), " - code(class: "font-mono text-xs") { "src:" } - plain " (JSON endpoint URL), and " - code(class: "font-mono text-xs") { "row_count:" } - plain " (total rows). Nest " - code(class: "font-mono text-xs") { "DataTableContent" } - plain " inside to get the table shell." - } - Codeblock(<<~RUBY, syntax: :ruby) - DataTable( - src: employees_path, - data: @employees.map { |e| {id: e.id, name: e.name, email: e.email} }, - columns: [ - {key: "name", header: "Name"}, - {key: "email", header: "Email"} - ], - row_count: @total_count, - page: params[:page] || 1, - per_page: params[:per_page] || 10 - ) do - DataTableContent() - end - RUBY - - # ── With pagination ─────────────────────────────────────────────────────── - Heading(level: 2) { "Adding pagination" } - p(class: "text-sm text-muted-foreground") { - plain "Add " - code(class: "font-mono text-xs") { "DataTablePagination" } - plain " inside the " - code(class: "font-mono text-xs") { "DataTable" } - plain " block. It reads " - code(class: "font-mono text-xs") { "current_page" } - plain " and " - code(class: "font-mono text-xs") { "total_pages" } - plain " for the initial disabled-state of Prev/Next buttons. The Stimulus controller " - plain "updates these live after each fetch via " - code(class: "font-mono text-xs") { "data-ruby-ui--data-table-target" } - plain " attributes on the buttons." - } - Codeblock(<<~RUBY, syntax: :ruby) - DataTable(src: employees_path, data: @employees.map(&:to_h), - columns: columns, row_count: @total_count, - page: @page, per_page: @per_page) do - DataTableContent() - DataTablePagination(current_page: @page, total_pages: @total_pages) - end - RUBY - - # ── With toolbar (search + per-page) ───────────────────────────────────── - Heading(level: 2) { "Adding search and per-page" } - p(class: "text-sm text-muted-foreground") { - plain "Wrap " - code(class: "font-mono text-xs") { "DataTableSearch" } - plain " and " - code(class: "font-mono text-xs") { "DataTablePerPage" } - plain " in a " - code(class: "font-mono text-xs") { "DataTableToolbar" } - plain ". Search is debounced 300ms client-side. Both reset the page to 1 before fetching." - } - Codeblock(<<~RUBY, syntax: :ruby) - DataTable(...) do - DataTableToolbar do - DataTableSearch(placeholder: "Search employees...") - DataTablePerPage(options: [10, 25, 50], current: @per_page) - end - DataTableContent() - DataTablePagination(current_page: @page, total_pages: @total_pages) - end - RUBY - # ── Cell types ─────────────────────────────────────────────────────────── Heading(level: 2) { "Cell types" } p(class: "text-sm text-muted-foreground") { - plain "Each column definition accepts an optional " + plain "Each column accepts an optional " code(class: "font-mono text-xs") { "type:" } - plain " field. Available built-in types: " + plain " field. Built-in types: " code(class: "font-mono text-xs") { "text" } - plain " (default), " + plain " (default — HTML-escaped string), " code(class: "font-mono text-xs") { "badge" } - plain " (colored pill — pass " - code(class: "font-mono text-xs") { 'colors: {"Value" => "tailwind-classes"}' } - plain "), " + plain " (colored pill — pass a " + code(class: "font-mono text-xs") { "colors:" } + plain " hash mapping values to Tailwind classes), " code(class: "font-mono text-xs") { "currency" } - plain " (USD, no decimals), " + plain " (USD, no decimals via " + code(class: "font-mono text-xs") { "Intl.NumberFormat" } + plain "), " code(class: "font-mono text-xs") { "date" } plain " (locale date string)." } - Codeblock(<<~RUBY, syntax: :ruby) - columns: [ - {key: "name", header: "Name", type: "text"}, - {key: "status", header: "Status", type: "badge", colors: { - "Active" => "bg-green-100 text-green-800", - "Inactive" => "bg-red-100 text-red-800" - }}, - {key: "salary", header: "Salary", type: "currency"}, - {key: "hired_at", header: "Hired", type: "date"} - ] - RUBY # ── URL state ──────────────────────────────────────────────────────────── Heading(level: 2) { "URL state" } p(class: "text-sm text-muted-foreground") { - plain "The URL is updated via " + plain "The URL updates via " code(class: "font-mono text-xs") { "history.replaceState" } - plain " after every state change. To restore state on page load, read URL params in your " - plain "Rails action and pass them to the view, which forwards them to " - code(class: "font-mono text-xs") { "DataTable" } - plain " as " + plain " after every interaction. On page load, your Rails action reads those params " + plain "and passes them to the view as " code(class: "font-mono text-xs") { "page:" } plain ", " code(class: "font-mono text-xs") { "sort:" } @@ -276,15 +281,72 @@ def index code(class: "font-mono text-xs") { "direction:" } plain ", " code(class: "font-mono text-xs") { "search:" } - plain ", and " + plain ", " code(class: "font-mono text-xs") { "per_page:" } - plain ". The component serializes them as Stimulus values; " + plain ". The " + code(class: "font-mono text-xs") { "DataTable" } + plain " component serializes them as Stimulus values so " code(class: "font-mono text-xs") { "connect()" } - plain " hydrates TanStack with the correct initial state." + plain " hydrates TanStack with the correct initial state. Shared URLs and page reloads restore exact table state." + } + + # ── TanStack reference ─────────────────────────────────────────────────── + Heading(level: 2) { "TanStack Table reference" } + p(class: "text-sm text-muted-foreground") { + plain "This component uses the " + a(href: "https://tanstack.com/table/latest/docs/vanilla", target: "_blank", class: "underline underline-offset-2") { "TanStack Table vanilla adapter" } + plain ". Useful docs for customisation:" } + ul(class: "text-sm text-muted-foreground list-disc list-inside space-y-1 mt-2") do + li { + a(href: "https://tanstack.com/table/latest/docs/api/core/table", target: "_blank", class: "underline underline-offset-2") { "Table instance API" } + plain " — all options passed to " + code(class: "font-mono text-xs") { "createTable()" } + } + li { + a(href: "https://tanstack.com/table/latest/docs/guide/pagination", target: "_blank", class: "underline underline-offset-2") { "Manual pagination" } + plain " — " + code(class: "font-mono text-xs") { "manualPagination" } + plain ", " + code(class: "font-mono text-xs") { "rowCount" } + plain ", " + code(class: "font-mono text-xs") { "onPaginationChange" } + } + li { + a(href: "https://tanstack.com/table/latest/docs/guide/sorting", target: "_blank", class: "underline underline-offset-2") { "Manual sorting" } + plain " — " + code(class: "font-mono text-xs") { "manualSorting" } + plain ", " + code(class: "font-mono text-xs") { "onSortingChange" } + } + li { + a(href: "https://tanstack.com/table/latest/docs/guide/column-defs", target: "_blank", class: "underline underline-offset-2") { "Column definitions" } + plain " — " + code(class: "font-mono text-xs") { "accessorKey" } + plain ", " + code(class: "font-mono text-xs") { "header" } + plain ", " + code(class: "font-mono text-xs") { "meta" } + } + end render Components::ComponentSetup::Tabs.new(component_name: component) - render Docs::ComponentsTable.new(component_files(component)) + render Docs::ComponentsTable.new(local_component_files) end end + + private + + def local_component_files + base = "https://github.com/ruby-ui/ruby_ui/blob/main/lib/ruby_ui/data_table" + [ + ::Docs::ComponentStruct.new(name: "DataTable", source: "#{base}/data_table.rb", built_using: :phlex), + ::Docs::ComponentStruct.new(name: "DataTableContent", source: "#{base}/data_table_content.rb", built_using: :phlex), + ::Docs::ComponentStruct.new(name: "DataTablePagination", source: "#{base}/data_table_pagination.rb", built_using: :phlex), + ::Docs::ComponentStruct.new(name: "DataTableSearch", source: "#{base}/data_table_search.rb", built_using: :phlex), + ::Docs::ComponentStruct.new(name: "DataTablePerPage", source: "#{base}/data_table_per_page.rb", built_using: :phlex), + ::Docs::ComponentStruct.new(name: "DataTableToolbar", source: "#{base}/data_table_toolbar.rb", built_using: :phlex), + ::Docs::ComponentStruct.new(name: "DataTableController", source: "#{base}/data_table_controller.js", built_using: :stimulus) + ] + end end From 460f2ef2d295d94670c9eea5aab9724fab99becc Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:48:48 -0300 Subject: [PATCH 34/52] feat(data_table): add DataTableBulkActions component --- .../data_table/data_table_bulk_actions.rb | 18 ++++++++++++++++++ .../docs/data_table_selection_test.rb | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_bulk_actions.rb create mode 100644 test/controllers/docs/data_table_selection_test.rb diff --git a/app/components/ruby_ui/data_table/data_table_bulk_actions.rb b/app/components/ruby_ui/data_table/data_table_bulk_actions.rb new file mode 100644 index 000000000..2d669c88d --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_bulk_actions.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableBulkActions < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "hidden items-center gap-3", + data: {ruby_ui__data_table_target: "bulkActions"} + } + end + end +end diff --git a/test/controllers/docs/data_table_selection_test.rb b/test/controllers/docs/data_table_selection_test.rb new file mode 100644 index 000000000..958622def --- /dev/null +++ b/test/controllers/docs/data_table_selection_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "test_helper" + +class Docs::DataTableSelectionTest < ActionDispatch::IntegrationTest + test "data_table page renders with selectable value when selectable" do + get docs_data_table_path + assert_response :success + # The demo page now has a selectable example + assert_match "data-ruby-ui--data-table-selectable-value", response.body + end + + test "DataTableBulkActions renders with correct target" do + html = RubyUI::DataTableBulkActions.new.call + assert_match "data-ruby-ui--data-table-target=\"bulkActions\"", html + end +end From 5742e78be921e20a1aa87a3e00dc1eaf38f81c5b Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:49:14 -0300 Subject: [PATCH 35/52] feat(data_table): add selectable prop, serialize selectable value --- app/components/ruby_ui/data_table/data_table.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index 10203e4c8..571dc81a0 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -3,7 +3,7 @@ module RubyUI class DataTable < Base # @param data [Array] current page rows - # @param columns [Array] [{ key:, header:, type: (optional) }] + # @param columns [Array] [{ key:, header:, type: (optional), colors: (optional) }] # @param src [String, nil] URL for JSON fetches # @param row_count [Integer] total rows across all pages # @param page [Integer] current page, 1-based @@ -11,8 +11,10 @@ class DataTable < Base # @param sort [String, nil] sorted column key # @param direction [String, nil] "asc" or "desc" # @param search [String, nil] initial search query + # @param selectable [Boolean] enable row selection with checkboxes def initialize(data: [], columns: [], src: nil, row_count: 0, - page: 1, per_page: 10, sort: nil, direction: nil, search: nil, **attrs) + page: 1, per_page: 10, sort: nil, direction: nil, search: nil, + selectable: false, **attrs) @data = data @columns = columns @src = src @@ -22,6 +24,7 @@ def initialize(data: [], columns: [], src: nil, row_count: 0, @sort = sort @direction = direction @search = search + @selectable = selectable super(**attrs) end @@ -43,7 +46,8 @@ def default_attrs ruby_ui__data_table_row_count_value: @row_count, ruby_ui__data_table_pagination_value: {pageIndex: @page - 1, pageSize: @per_page}.to_json, ruby_ui__data_table_sorting_value: sorting.to_json, - ruby_ui__data_table_search_value: @search.to_s + ruby_ui__data_table_search_value: @search.to_s, + ruby_ui__data_table_selectable_value: @selectable } } end From 253ce4bd49444eaaf2c0941f2c6c06512f560b06 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:50:06 -0300 Subject: [PATCH 36/52] feat(data_table): wire row selection with checkboxes and bulk actions sync --- .../ruby_ui/data_table_controller.js | 189 +++++++++++++----- 1 file changed, 141 insertions(+), 48 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index d6f2f0bf2..d4245badb 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus" import { createTable, getCoreRowModel } from "@tanstack/table-core" export default class extends Controller { - static targets = ["thead", "tbody", "prevButton", "nextButton", "pageIndicator", "search", "perPage"] + static targets = ["thead", "tbody", "prevButton", "nextButton", "pageIndicator", "search", "perPage", "bulkActions"] static values = { src: String, data: { type: Array, default: [] }, @@ -10,47 +10,33 @@ export default class extends Controller { rowCount: { type: Number, default: 0 }, pagination: { type: Object, default: { pageIndex: 0, pageSize: 10 } }, sorting: { type: Array, default: [] }, - search: { type: String, default: "" } - } - - static CELL_RENDERERS = { - text: (value) => escapeHtml(value ?? ""), - - badge: (value, meta) => { - const colors = meta?.colors ?? {} - const colorClass = colors[value] ?? "bg-secondary text-secondary-foreground" - return `${escapeHtml(value ?? "")}` - }, - - date: (value) => { - if (!value) return "" - const d = new Date(value) - return isNaN(d.getTime()) ? escapeHtml(value) : d.toLocaleDateString() - }, - - currency: (value) => { - if (value == null || value === "") return "" - return new Intl.NumberFormat("en-US", {style: "currency", currency: "USD", maximumFractionDigits: 0}).format(Number(value)) - } + search: { type: String, default: "" }, + selectable: { type: Boolean, default: false } } connect() { this.searchTimeout = null + this.rowSelection = {} + + const columnDefs = this.columnsValue.map((c) => ({ + id: c.key, + accessorKey: c.key, + header: c.header, + meta: { type: c.type ?? "text", colors: c.colors ?? null } + })) this.table = createTable({ data: this.dataValue, - columns: this.columnsValue.map((c) => ({ - id: c.key, - accessorKey: c.key, - header: c.header, - meta: { type: c.type ?? "text", colors: c.colors ?? null } - })), + columns: columnDefs, getCoreRowModel: getCoreRowModel(), renderFallbackValue: null, manualPagination: true, manualSorting: true, manualFiltering: true, rowCount: this.rowCountValue, + enableRowSelection: this.selectableValue, + enableMultiRowSelection: true, + getRowId: (row) => String(row.id ?? row[Object.keys(row)[0]]), state: {}, onStateChange: () => {} }) @@ -59,7 +45,8 @@ export default class extends Controller { ...this.table.initialState, pagination: this.paginationValue, sorting: this.sortingValue, - globalFilter: this.searchValue + globalFilter: this.searchValue, + rowSelection: this.rowSelection } this.table.setOptions((prev) => ({ @@ -67,20 +54,30 @@ export default class extends Controller { state: this.tableState, onPaginationChange: (updater) => { const next = typeof updater === "function" ? updater(this.tableState.pagination) : updater - this.tableState = { ...this.tableState, pagination: next } + this.rowSelection = {} + this.tableState = { ...this.tableState, pagination: next, rowSelection: {} } this.table.setOptions((p) => ({ ...p, state: this.tableState })) this.#fetchAndRender() }, onSortingChange: (updater) => { const next = typeof updater === "function" ? updater(this.tableState.sorting) : updater + this.rowSelection = {} this.tableState = { ...this.tableState, sorting: next, - pagination: { ...this.tableState.pagination, pageIndex: 0 } + pagination: { ...this.tableState.pagination, pageIndex: 0 }, + rowSelection: {} } this.table.setOptions((p) => ({ ...p, state: this.tableState })) this.#fetchAndRender() }, + onRowSelectionChange: (updater) => { + const next = typeof updater === "function" ? updater(this.tableState.rowSelection) : updater + this.rowSelection = next + this.tableState = { ...this.tableState, rowSelection: next } + this.table.setOptions((p) => ({ ...p, state: this.tableState })) + this.render() + }, onStateChange: (updater) => { const next = typeof updater === "function" ? updater(this.tableState) : updater this.tableState = next @@ -89,7 +86,6 @@ export default class extends Controller { } })) - // Pre-fill search input if hydrating from initial state if (this.hasSearchTarget && this.searchValue) { this.searchTarget.value = this.searchValue } @@ -108,10 +104,12 @@ export default class extends Controller { if (this.searchTimeout) clearTimeout(this.searchTimeout) this.searchTimeout = setTimeout(() => { const query = this.searchTarget.value + this.rowSelection = {} this.tableState = { ...this.tableState, globalFilter: query, - pagination: { ...this.tableState.pagination, pageIndex: 0 } + pagination: { ...this.tableState.pagination, pageIndex: 0 }, + rowSelection: {} } this.table.setOptions((p) => ({ ...p, state: this.tableState })) this.#fetchAndRender() @@ -120,9 +118,11 @@ export default class extends Controller { changePerPage() { const pageSize = parseInt(this.perPageTarget.value) + this.rowSelection = {} this.tableState = { ...this.tableState, - pagination: { pageIndex: 0, pageSize } + pagination: { pageIndex: 0, pageSize }, + rowSelection: {} } this.table.setOptions((p) => ({ ...p, state: this.tableState })) this.#fetchAndRender() @@ -132,12 +132,12 @@ export default class extends Controller { this.#renderHeaders() this.#renderRows() this.#syncPaginationUI() + this.#syncBulkActionsUI() this.#syncURL() } #fetchAndRender() { if (!this.hasSrcValue || !this.srcValue) return - fetch(this.#buildURL(), { headers: { Accept: "application/json" } }) .then((r) => r.json()) .then(({ data, row_count }) => { @@ -156,25 +156,26 @@ export default class extends Controller { const { pageIndex, pageSize } = this.tableState.pagination url.searchParams.set("page", pageIndex + 1) url.searchParams.set("per_page", pageSize) - if (this.tableState.sorting.length > 0) { const { id, desc } = this.tableState.sorting[0] url.searchParams.set("sort", id) url.searchParams.set("direction", desc ? "desc" : "asc") } - if (this.tableState.globalFilter) { url.searchParams.set("search", this.tableState.globalFilter) } - return url.toString() } + #syncURL() { + if (!this.hasSrcValue || !this.srcValue) return + history.replaceState(null, "", this.#buildURL()) + } + #syncPaginationUI() { if (this.hasPageIndicatorTarget) { const { pageIndex } = this.tableState.pagination - const pageCount = this.table.getPageCount() - this.pageIndicatorTarget.textContent = `Page ${pageIndex + 1} of ${pageCount}` + this.pageIndicatorTarget.textContent = `Page ${pageIndex + 1} of ${this.table.getPageCount()}` } if (this.hasPrevButtonTarget) { const can = this.table.getCanPreviousPage() @@ -190,16 +191,52 @@ export default class extends Controller { } } - #syncURL() { - if (!this.hasSrcValue || !this.srcValue) return - history.replaceState(null, "", this.#buildURL()) + #syncBulkActionsUI() { + if (!this.hasBulkActionsTarget) return + + const selected = this.table.getSelectedRowModel().rows + const count = selected.length + const ids = selected.map((r) => r.id) + + if (count > 0) { + this.bulkActionsTarget.classList.remove("hidden") + this.bulkActionsTarget.classList.add("flex") + this.bulkActionsTarget.dataset.selectedIds = JSON.stringify(ids) + this.bulkActionsTarget.dataset.selectedCount = count + + const countEl = this.bulkActionsTarget.querySelector("[data-selection-count]") + if (countEl) countEl.textContent = `${count} row${count === 1 ? "" : "s"} selected` + } else { + this.bulkActionsTarget.classList.add("hidden") + this.bulkActionsTarget.classList.remove("flex") + this.bulkActionsTarget.dataset.selectedIds = "[]" + this.bulkActionsTarget.dataset.selectedCount = 0 + } + + // Dispatch custom event so consumers can react + this.dispatch("selection-change", { detail: { count, ids } }) } #renderHeaders() { if (!this.hasTheadTarget) return const html = this.table.getHeaderGroups().map((group) => { - const cells = group.headers.map((header) => { + let cells = "" + + if (this.selectableValue) { + const isAll = this.table.getIsAllPageRowsSelected() + const isSome = this.table.getIsSomePageRowsSelected() + cells += ` + + ` + } + + cells += group.headers.map((header) => { const def = header.column.columnDef.header const label = typeof def === "function" ? def(header.getContext()) : (def ?? "") const sorted = header.column.getIsSorted() @@ -221,17 +258,29 @@ export default class extends Controller { ` }).join("") + return `${cells}` }).join("") this.theadTarget.innerHTML = html + // Wire sort buttons this.theadTarget.querySelectorAll("[data-sort-col]").forEach((btn) => { btn.addEventListener("click", () => { const col = this.table.getColumn(btn.dataset.sortCol) if (col) col.toggleSorting() }) }) + + // Wire indeterminate state on select-all checkbox + if (this.selectableValue) { + const cb = this.theadTarget.querySelector("input[type=checkbox]") + if (cb) cb.indeterminate = cb.dataset.indeterminate === "true" + } + } + + toggleAllRows(event) { + this.table.toggleAllPageRowsSelected(event.target.checked) } #renderRows() { @@ -239,23 +288,67 @@ export default class extends Controller { const rows = this.table.getRowModel().rows if (rows.length === 0) { - this.tbodyTarget.innerHTML = `No results.` + const colspan = this.columnsValue.length + (this.selectableValue ? 1 : 0) + this.tbodyTarget.innerHTML = `No results.` return } const html = rows.map((row) => { - const cells = row.getVisibleCells().map((cell) => { + let cells = "" + + if (this.selectableValue) { + const isSelected = row.getIsSelected() + cells += ` + + ` + } + + cells += row.getVisibleCells().map((cell) => { const value = cell.getValue() const meta = cell.column.columnDef.meta ?? {} const type = meta.type ?? "text" const renderer = this.constructor.CELL_RENDERERS[type] ?? this.constructor.CELL_RENDERERS.text return `${renderer(value, meta)}` }).join("") - return `${cells}` + + const selectedClass = row.getIsSelected() ? "bg-muted/50" : "" + return `${cells}` }).join("") this.tbodyTarget.innerHTML = html } + + toggleRow(event) { + const rowId = event.target.dataset.rowId + const row = this.table.getRowModel().rows.find((r) => r.id === rowId) + if (row) row.toggleSelected(event.target.checked) + } + + static CELL_RENDERERS = { + text: (value) => escapeHtml(value ?? ""), + + badge: (value, meta) => { + const colors = meta?.colors ?? {} + const colorClass = colors[value] ?? "bg-secondary text-secondary-foreground" + return `${escapeHtml(value ?? "")}` + }, + + date: (value) => { + if (!value) return "" + const d = new Date(value) + return isNaN(d.getTime()) ? escapeHtml(value) : d.toLocaleDateString() + }, + + currency: (value) => { + if (value == null || value === "") return "" + return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(Number(value)) + } + } } function escapeHtml(value) { From 7f72dc31249fa48fbe413800efd560e0f73ecedc Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:50:43 -0300 Subject: [PATCH 37/52] feat(docs): add row selection example with DataTableBulkActions --- app/views/docs/data_table.rb | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index fef9f52a2..679cb10a6 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -156,6 +156,39 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "With row selection and bulk actions", context: self) do + <<~RUBY + DataTable( + data: [ + {id: 1, name: "Alice Johnson", department: "Engineering", status: "Active"}, + {id: 2, name: "Bob Smith", department: "Design", status: "Active"}, + {id: 3, name: "Carol White", department: "Product", status: "On Leave"}, + {id: 4, name: "David Brown", department: "Engineering", status: "Active"}, + {id: 5, name: "Eve Davis", department: "Marketing", status: "Inactive"} + ], + columns: [ + {key: "name", header: "Name"}, + {key: "department", header: "Department"}, + {key: "status", header: "Status", type: "badge", colors: { + "Active" => "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + "Inactive" => "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + "On Leave" => "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + }} + ], + selectable: true + ) do + DataTableToolbar do + DataTableSearch(placeholder: "Search...") + DataTableBulkActions do + span(class: "text-sm text-muted-foreground", data: {selection_count: true}) {} + Button(variant: :destructive, size: :sm) { "Delete selected" } + end + end + DataTableContent() + end + RUBY + end + # ── Overview ──────────────────────────────────────────────────────────── Heading(level: 2) { "Overview" } p(class: "text-sm text-muted-foreground") { @@ -346,6 +379,7 @@ def local_component_files ::Docs::ComponentStruct.new(name: "DataTableSearch", source: "#{base}/data_table_search.rb", built_using: :phlex), ::Docs::ComponentStruct.new(name: "DataTablePerPage", source: "#{base}/data_table_per_page.rb", built_using: :phlex), ::Docs::ComponentStruct.new(name: "DataTableToolbar", source: "#{base}/data_table_toolbar.rb", built_using: :phlex), + ::Docs::ComponentStruct.new(name: "DataTableBulkActions", source: "#{base}/data_table_bulk_actions.rb", built_using: :phlex), ::Docs::ComponentStruct.new(name: "DataTableController", source: "#{base}/data_table_controller.js", built_using: :stimulus) ] end From b558b182c6e5c7907dda21527cb4b87fe00c4c20 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:55:58 -0300 Subject: [PATCH 38/52] feat(data_table): client-side sort without src, row selection docs, remove p text-sm classes --- .../ruby_ui/data_table_controller.js | 17 +++- app/views/docs/data_table.rb | 99 ++++++++++++++----- 2 files changed, 87 insertions(+), 29 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index d4245badb..09f54bcec 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -1,5 +1,5 @@ import { Controller } from "@hotwired/stimulus" -import { createTable, getCoreRowModel } from "@tanstack/table-core" +import { createTable, getCoreRowModel, getSortedRowModel } from "@tanstack/table-core" export default class extends Controller { static targets = ["thead", "tbody", "prevButton", "nextButton", "pageIndicator", "search", "perPage", "bulkActions"] @@ -18,6 +18,8 @@ export default class extends Controller { this.searchTimeout = null this.rowSelection = {} + this.hasServer = this.hasSrcValue && !!this.srcValue + const columnDefs = this.columnsValue.map((c) => ({ id: c.key, accessorKey: c.key, @@ -29,10 +31,11 @@ export default class extends Controller { data: this.dataValue, columns: columnDefs, getCoreRowModel: getCoreRowModel(), + getSortedRowModel: this.hasServer ? undefined : getSortedRowModel(), renderFallbackValue: null, - manualPagination: true, - manualSorting: true, - manualFiltering: true, + manualPagination: this.hasServer, + manualSorting: this.hasServer, + manualFiltering: this.hasServer, rowCount: this.rowCountValue, enableRowSelection: this.selectableValue, enableMultiRowSelection: true, @@ -69,7 +72,11 @@ export default class extends Controller { rowSelection: {} } this.table.setOptions((p) => ({ ...p, state: this.tableState })) - this.#fetchAndRender() + if (this.hasServer) { + this.#fetchAndRender() + } else { + this.render() + } }, onRowSelectionChange: (updater) => { const next = typeof updater === "function" ? updater(this.tableState.rowSelection) : updater diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 679cb10a6..a94a6baf8 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -39,7 +39,7 @@ def view_template # ── Full-Featured Demo ────────────────────────────────────────────────── Heading(level: 2) { "Demo" } - p(class: "text-sm text-muted-foreground -mt-6") { + p(class: "-mt-6") { plain "100 employees. Pagination, column sorting, free-text search, and configurable rows per page. " plain "The URL reflects state — share it or reload to restore." } @@ -191,7 +191,7 @@ def view_template # ── Overview ──────────────────────────────────────────────────────────── Heading(level: 2) { "Overview" } - p(class: "text-sm text-muted-foreground") { + p { plain "DataTable is headless — it has no built-in visual chrome. It wires " a(href: "https://tanstack.com/table/latest/docs/vanilla", target: "_blank", class: "underline underline-offset-2") { "TanStack Table Core (vanilla)" } plain " — a framework-agnostic state machine — to a Stimulus controller that manages state, " @@ -211,7 +211,7 @@ def view_template # ── Installation ──────────────────────────────────────────────────────── Heading(level: 2) { "Installation" } - p(class: "text-sm text-muted-foreground") { + p { plain "DataTable requires " code(class: "font-mono text-xs") { "@tanstack/table-core" } plain " in addition to the standard ruby_ui setup." @@ -224,28 +224,24 @@ def view_template # ── How it works ──────────────────────────────────────────────────────── Heading(level: 2) { "How it works" } - p(class: "text-sm text-muted-foreground") { - plain "All data operations are " + p { + plain "When " + code(class: "font-mono text-xs") { "src:" } + plain " is provided, all operations are " strong { "server-side" } - plain ". TanStack is configured with " - code(class: "font-mono text-xs") { "manualPagination: true" } - plain ", " - code(class: "font-mono text-xs") { "manualSorting: true" } - plain ", and " - code(class: "font-mono text-xs") { "manualFiltering: true" } - plain ". It never slices or sorts the data array — it only tracks state " - plain "and fires callbacks (" - code(class: "font-mono text-xs") { "onPaginationChange" } - plain ", " - code(class: "font-mono text-xs") { "onSortingChange" } - plain "). The Stimulus controller fetches a fresh JSON page from Rails and calls " - code(class: "font-mono text-xs") { "table.setOptions({data, rowCount})" } - plain " to update what renders." + plain " — TanStack tracks state and the Stimulus controller fetches a fresh JSON page from Rails on every change. " + plain "Without " + code(class: "font-mono text-xs") { "src:" } + plain ", sorting works " + strong { "client-side" } + plain " via TanStack's " + code(class: "font-mono text-xs") { "getSortedRowModel()" } + plain " — useful for static data demos or small datasets that don't need a server." } # ── Rails controller setup ─────────────────────────────────────────────── Heading(level: 2) { "Rails controller setup" } - p(class: "text-sm text-muted-foreground") { + p { plain "Your endpoint responds to both HTML (initial load) and JSON (subsequent fetches). " plain "It accepts params: " code(class: "font-mono text-xs") { "page" } @@ -282,7 +278,7 @@ def index # ── Cell types ─────────────────────────────────────────────────────────── Heading(level: 2) { "Cell types" } - p(class: "text-sm text-muted-foreground") { + p { plain "Each column accepts an optional " code(class: "font-mono text-xs") { "type:" } plain " field. Built-in types: " @@ -302,7 +298,7 @@ def index # ── URL state ──────────────────────────────────────────────────────────── Heading(level: 2) { "URL state" } - p(class: "text-sm text-muted-foreground") { + p { plain "The URL updates via " code(class: "font-mono text-xs") { "history.replaceState" } plain " after every interaction. On page load, your Rails action reads those params " @@ -323,14 +319,69 @@ def index plain " hydrates TanStack with the correct initial state. Shared URLs and page reloads restore exact table state." } + # ── Row selection ──────────────────────────────────────────────────────── + Heading(level: 2) { "Row selection" } + p { + plain "Add " + code(class: "font-mono text-xs") { "selectable: true" } + plain " to enable checkboxes. Pair with " + code(class: "font-mono text-xs") { "DataTableBulkActions" } + plain " inside " + code(class: "font-mono text-xs") { "DataTableToolbar" } + plain " to expose bulk action buttons when rows are checked." + } + p(class: "mt-2") { + plain "The " + code(class: "font-mono text-xs") { "DataTableBulkActions" } + plain " container gains two data attributes as selection changes:" + } + ul(class: "list-disc list-inside space-y-1 mt-2") do + li { + code(class: "font-mono text-xs") { "data-selected-ids" } + plain " — JSON array of selected row IDs (uses the " + code(class: "font-mono text-xs") { "id" } + plain " field from each data row)." + } + li { + code(class: "font-mono text-xs") { "data-selected-count" } + plain " — integer count of currently selected rows." + } + end + p(class: "mt-2") { + plain "A " + code(class: "font-mono text-xs") { "ruby-ui--data-table:selection-change" } + plain " custom event is dispatched on the root element with " + code(class: "font-mono text-xs") { "{ count, ids }" } + plain " in " + code(class: "font-mono text-xs") { "event.detail" } + plain ". Use it to wire Turbo actions or custom JS." + } + p(class: "mt-2") { + plain "To read selected IDs from an action button:" + } + Codeblock(<<~JS, syntax: :javascript) + // Inside any click handler within the DataTable wrapper: + const bulkBar = event.target.closest("[data-controller]") + .querySelector("[data-ruby-ui--data-table-target='bulkActions']") + const ids = JSON.parse(bulkBar.dataset.selectedIds) // => [1, 3, 5] + + // Or listen globally: + document.addEventListener("ruby-ui--data-table:selection-change", (e) => { + console.log(e.detail.count, e.detail.ids) + }) + JS + p(class: "mt-2") { + plain "Selection is automatically cleared when the page, sort column, search query, or rows-per-page changes." + } + # ── TanStack reference ─────────────────────────────────────────────────── Heading(level: 2) { "TanStack Table reference" } - p(class: "text-sm text-muted-foreground") { + p { plain "This component uses the " a(href: "https://tanstack.com/table/latest/docs/vanilla", target: "_blank", class: "underline underline-offset-2") { "TanStack Table vanilla adapter" } plain ". Useful docs for customisation:" } - ul(class: "text-sm text-muted-foreground list-disc list-inside space-y-1 mt-2") do + ul(class: "list-disc list-inside space-y-1 mt-2") do li { a(href: "https://tanstack.com/table/latest/docs/api/core/table", target: "_blank", class: "underline underline-offset-2") { "Table instance API" } plain " — all options passed to " From 4d19517f4cb42a125cbae5b6ae321bf226cb97d6 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 17:58:04 -0300 Subject: [PATCH 39/52] fix(data_table): only sync URL on user interaction, not on initial render --- app/javascript/controllers/ruby_ui/data_table_controller.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 09f54bcec..cd3410438 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -76,6 +76,7 @@ export default class extends Controller { this.#fetchAndRender() } else { this.render() + this.#syncURL() } }, onRowSelectionChange: (updater) => { @@ -140,7 +141,6 @@ export default class extends Controller { this.#renderRows() this.#syncPaginationUI() this.#syncBulkActionsUI() - this.#syncURL() } #fetchAndRender() { @@ -155,6 +155,7 @@ export default class extends Controller { state: this.tableState })) this.render() + this.#syncURL() }) } From 98acf31f3322c20edc9ec6b0f72e5dab0c5bbcf6 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 15 Apr 2026 18:02:57 -0300 Subject: [PATCH 40/52] refactor(data_table): replace inline SVG/HTML strings with Phlex template cloning --- .../ruby_ui/data_table/data_table_content.rb | 39 +- .../ruby_ui/data_table_controller.js | 455 ++++++++++-------- 2 files changed, 294 insertions(+), 200 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_content.rb b/app/components/ruby_ui/data_table/data_table_content.rb index 796f83c77..1bb8e8a45 100644 --- a/app/components/ruby_ui/data_table/data_table_content.rb +++ b/app/components/ruby_ui/data_table/data_table_content.rb @@ -8,15 +8,48 @@ def view_template thead(class: "[&_tr]:border-b", data: {ruby_ui__data_table_target: "thead"}) tbody(class: "[&_tr:last-child]:border-0", data: {ruby_ui__data_table_target: "tbody"}) end + + # Icon templates — rendered by Phlex, cloned by Stimulus. No SVG in JS. + template(data: {ruby_ui__data_table_target: "tplSortAsc"}) do + svg( + xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", stroke_width: "2", + stroke_linecap: "round", stroke_linejoin: "round", + class: "ml-1 inline-block w-3 h-3" + ) { |s| s.path(d: "m18 15-6-6-6 6") } + end + + template(data: {ruby_ui__data_table_target: "tplSortDesc"}) do + svg( + xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", stroke_width: "2", + stroke_linecap: "round", stroke_linejoin: "round", + class: "ml-1 inline-block w-3 h-3" + ) { |s| s.path(d: "m6 9 6 6 6-6") } + end + + template(data: {ruby_ui__data_table_target: "tplSortNone"}) do + svg( + xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", stroke_width: "2", + stroke_linecap: "round", stroke_linejoin: "round", + class: "ml-1 inline-block w-3 h-3 opacity-30" + ) { |s| s.path(d: "m7 15 5 5 5-5M7 9l5-5 5 5") } + end + + template(data: {ruby_ui__data_table_target: "tplCheckbox"}) do + input( + type: "checkbox", + class: "h-4 w-4 rounded border border-input accent-primary cursor-pointer" + ) + end end end private def default_attrs - { - class: "rounded-md border" - } + {class: "rounded-md border"} end end end diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index cd3410438..82d5cce0c 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -1,8 +1,25 @@ -import { Controller } from "@hotwired/stimulus" -import { createTable, getCoreRowModel, getSortedRowModel } from "@tanstack/table-core" +import { Controller } from "@hotwired/stimulus"; +import { + createTable, + getCoreRowModel, + getSortedRowModel, +} from "@tanstack/table-core"; export default class extends Controller { - static targets = ["thead", "tbody", "prevButton", "nextButton", "pageIndicator", "search", "perPage", "bulkActions"] + static targets = [ + "thead", + "tbody", + "prevButton", + "nextButton", + "pageIndicator", + "search", + "perPage", + "bulkActions", + "tplSortAsc", + "tplSortDesc", + "tplSortNone", + "tplCheckbox", + ]; static values = { src: String, data: { type: Array, default: [] }, @@ -11,21 +28,21 @@ export default class extends Controller { pagination: { type: Object, default: { pageIndex: 0, pageSize: 10 } }, sorting: { type: Array, default: [] }, search: { type: String, default: "" }, - selectable: { type: Boolean, default: false } - } + selectable: { type: Boolean, default: false }, + }; connect() { - this.searchTimeout = null - this.rowSelection = {} + this.searchTimeout = null; + this.rowSelection = {}; - this.hasServer = this.hasSrcValue && !!this.srcValue + this.hasServer = this.hasSrcValue && !!this.srcValue; const columnDefs = this.columnsValue.map((c) => ({ id: c.key, accessorKey: c.key, header: c.header, - meta: { type: c.type ?? "text", colors: c.colors ?? null } - })) + meta: { type: c.type ?? "text", colors: c.colors ?? null }, + })); this.table = createTable({ data: this.dataValue, @@ -41,110 +58,128 @@ export default class extends Controller { enableMultiRowSelection: true, getRowId: (row) => String(row.id ?? row[Object.keys(row)[0]]), state: {}, - onStateChange: () => {} - }) + onStateChange: () => {}, + }); this.tableState = { ...this.table.initialState, pagination: this.paginationValue, sorting: this.sortingValue, globalFilter: this.searchValue, - rowSelection: this.rowSelection - } + rowSelection: this.rowSelection, + }; this.table.setOptions((prev) => ({ ...prev, state: this.tableState, onPaginationChange: (updater) => { - const next = typeof updater === "function" ? updater(this.tableState.pagination) : updater - this.rowSelection = {} - this.tableState = { ...this.tableState, pagination: next, rowSelection: {} } - this.table.setOptions((p) => ({ ...p, state: this.tableState })) - this.#fetchAndRender() + const next = + typeof updater === "function" + ? updater(this.tableState.pagination) + : updater; + this.rowSelection = {}; + this.tableState = { + ...this.tableState, + pagination: next, + rowSelection: {}, + }; + this.table.setOptions((p) => ({ ...p, state: this.tableState })); + this.#fetchAndRender(); }, onSortingChange: (updater) => { - const next = typeof updater === "function" ? updater(this.tableState.sorting) : updater - this.rowSelection = {} + const next = + typeof updater === "function" + ? updater(this.tableState.sorting) + : updater; + this.rowSelection = {}; this.tableState = { ...this.tableState, sorting: next, pagination: { ...this.tableState.pagination, pageIndex: 0 }, - rowSelection: {} - } - this.table.setOptions((p) => ({ ...p, state: this.tableState })) + rowSelection: {}, + }; + this.table.setOptions((p) => ({ ...p, state: this.tableState })); if (this.hasServer) { - this.#fetchAndRender() + this.#fetchAndRender(); } else { - this.render() - this.#syncURL() + this.render(); + this.#syncURL(); } }, onRowSelectionChange: (updater) => { - const next = typeof updater === "function" ? updater(this.tableState.rowSelection) : updater - this.rowSelection = next - this.tableState = { ...this.tableState, rowSelection: next } - this.table.setOptions((p) => ({ ...p, state: this.tableState })) - this.render() + const next = + typeof updater === "function" + ? updater(this.tableState.rowSelection) + : updater; + this.rowSelection = next; + this.tableState = { ...this.tableState, rowSelection: next }; + this.table.setOptions((p) => ({ ...p, state: this.tableState })); + this.render(); }, onStateChange: (updater) => { - const next = typeof updater === "function" ? updater(this.tableState) : updater - this.tableState = next - this.table.setOptions((p) => ({ ...p, state: this.tableState })) - this.render() - } - })) + const next = + typeof updater === "function" ? updater(this.tableState) : updater; + this.tableState = next; + this.table.setOptions((p) => ({ ...p, state: this.tableState })); + this.render(); + }, + })); if (this.hasSearchTarget && this.searchValue) { - this.searchTarget.value = this.searchValue + this.searchTarget.value = this.searchValue; } - this.render() + this.render(); } disconnect() { - if (this.searchTimeout) clearTimeout(this.searchTimeout) + if (this.searchTimeout) clearTimeout(this.searchTimeout); } - previousPage() { this.table.previousPage() } - nextPage() { this.table.nextPage() } + previousPage() { + this.table.previousPage(); + } + nextPage() { + this.table.nextPage(); + } search() { - if (this.searchTimeout) clearTimeout(this.searchTimeout) + if (this.searchTimeout) clearTimeout(this.searchTimeout); this.searchTimeout = setTimeout(() => { - const query = this.searchTarget.value - this.rowSelection = {} + const query = this.searchTarget.value; + this.rowSelection = {}; this.tableState = { ...this.tableState, globalFilter: query, pagination: { ...this.tableState.pagination, pageIndex: 0 }, - rowSelection: {} - } - this.table.setOptions((p) => ({ ...p, state: this.tableState })) - this.#fetchAndRender() - }, 300) + rowSelection: {}, + }; + this.table.setOptions((p) => ({ ...p, state: this.tableState })); + this.#fetchAndRender(); + }, 300); } changePerPage() { - const pageSize = parseInt(this.perPageTarget.value) - this.rowSelection = {} + const pageSize = parseInt(this.perPageTarget.value); + this.rowSelection = {}; this.tableState = { ...this.tableState, pagination: { pageIndex: 0, pageSize }, - rowSelection: {} - } - this.table.setOptions((p) => ({ ...p, state: this.tableState })) - this.#fetchAndRender() + rowSelection: {}, + }; + this.table.setOptions((p) => ({ ...p, state: this.tableState })); + this.#fetchAndRender(); } render() { - this.#renderHeaders() - this.#renderRows() - this.#syncPaginationUI() - this.#syncBulkActionsUI() + this.#renderHeaders(); + this.#renderRows(); + this.#syncPaginationUI(); + this.#syncBulkActionsUI(); } #fetchAndRender() { - if (!this.hasSrcValue || !this.srcValue) return + if (!this.hasSrcValue || !this.srcValue) return; fetch(this.#buildURL(), { headers: { Accept: "application/json" } }) .then((r) => r.json()) .then(({ data, row_count }) => { @@ -152,211 +187,237 @@ export default class extends Controller { ...prev, data, rowCount: row_count, - state: this.tableState - })) - this.render() - this.#syncURL() - }) + state: this.tableState, + })); + this.render(); + this.#syncURL(); + }); } #buildURL() { - const url = new URL(this.srcValue, window.location.origin) - const { pageIndex, pageSize } = this.tableState.pagination - url.searchParams.set("page", pageIndex + 1) - url.searchParams.set("per_page", pageSize) + const url = new URL(this.srcValue, window.location.origin); + const { pageIndex, pageSize } = this.tableState.pagination; + url.searchParams.set("page", pageIndex + 1); + url.searchParams.set("per_page", pageSize); if (this.tableState.sorting.length > 0) { - const { id, desc } = this.tableState.sorting[0] - url.searchParams.set("sort", id) - url.searchParams.set("direction", desc ? "desc" : "asc") + const { id, desc } = this.tableState.sorting[0]; + url.searchParams.set("sort", id); + url.searchParams.set("direction", desc ? "desc" : "asc"); } if (this.tableState.globalFilter) { - url.searchParams.set("search", this.tableState.globalFilter) + url.searchParams.set("search", this.tableState.globalFilter); } - return url.toString() + return url.toString(); } #syncURL() { - if (!this.hasSrcValue || !this.srcValue) return - history.replaceState(null, "", this.#buildURL()) + if (!this.hasSrcValue || !this.srcValue) return; + history.replaceState(null, "", this.#buildURL()); } #syncPaginationUI() { if (this.hasPageIndicatorTarget) { - const { pageIndex } = this.tableState.pagination - this.pageIndicatorTarget.textContent = `Page ${pageIndex + 1} of ${this.table.getPageCount()}` + const { pageIndex } = this.tableState.pagination; + this.pageIndicatorTarget.textContent = `Page ${pageIndex + 1} of ${this.table.getPageCount()}`; } if (this.hasPrevButtonTarget) { - const can = this.table.getCanPreviousPage() - this.prevButtonTarget.disabled = !can - this.prevButtonTarget.classList.toggle("opacity-50", !can) - this.prevButtonTarget.classList.toggle("pointer-events-none", !can) + const can = this.table.getCanPreviousPage(); + this.prevButtonTarget.disabled = !can; + this.prevButtonTarget.classList.toggle("opacity-50", !can); + this.prevButtonTarget.classList.toggle("pointer-events-none", !can); } if (this.hasNextButtonTarget) { - const can = this.table.getCanNextPage() - this.nextButtonTarget.disabled = !can - this.nextButtonTarget.classList.toggle("opacity-50", !can) - this.nextButtonTarget.classList.toggle("pointer-events-none", !can) + const can = this.table.getCanNextPage(); + this.nextButtonTarget.disabled = !can; + this.nextButtonTarget.classList.toggle("opacity-50", !can); + this.nextButtonTarget.classList.toggle("pointer-events-none", !can); } } #syncBulkActionsUI() { - if (!this.hasBulkActionsTarget) return + if (!this.hasBulkActionsTarget) return; - const selected = this.table.getSelectedRowModel().rows - const count = selected.length - const ids = selected.map((r) => r.id) + const selected = this.table.getSelectedRowModel().rows; + const count = selected.length; + const ids = selected.map((r) => r.id); if (count > 0) { - this.bulkActionsTarget.classList.remove("hidden") - this.bulkActionsTarget.classList.add("flex") - this.bulkActionsTarget.dataset.selectedIds = JSON.stringify(ids) - this.bulkActionsTarget.dataset.selectedCount = count - - const countEl = this.bulkActionsTarget.querySelector("[data-selection-count]") - if (countEl) countEl.textContent = `${count} row${count === 1 ? "" : "s"} selected` + this.bulkActionsTarget.classList.remove("hidden"); + this.bulkActionsTarget.classList.add("flex"); + this.bulkActionsTarget.dataset.selectedIds = JSON.stringify(ids); + this.bulkActionsTarget.dataset.selectedCount = count; + + const countEl = this.bulkActionsTarget.querySelector( + "[data-selection-count]", + ); + if (countEl) + countEl.textContent = `${count} row${count === 1 ? "" : "s"} selected`; } else { - this.bulkActionsTarget.classList.add("hidden") - this.bulkActionsTarget.classList.remove("flex") - this.bulkActionsTarget.dataset.selectedIds = "[]" - this.bulkActionsTarget.dataset.selectedCount = 0 + this.bulkActionsTarget.classList.add("hidden"); + this.bulkActionsTarget.classList.remove("flex"); + this.bulkActionsTarget.dataset.selectedIds = "[]"; + this.bulkActionsTarget.dataset.selectedCount = 0; } // Dispatch custom event so consumers can react - this.dispatch("selection-change", { detail: { count, ids } }) + this.dispatch("selection-change", { detail: { count, ids } }); + } + + // Clone a