diff --git a/.gitignore b/.gitignore
index 011574f2..3f5c8c1b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@
# Ignore bundler config.
/.bundle
+/vendor/bundle
# Ignore all logfiles and tempfiles.
/log/*
@@ -46,3 +47,6 @@ yarn-error.log
# Pnpm
.pnpm-store
+
+# Local design artifacts (not checked in)
+/docs/superpowers/
diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb
index 9f8f2757..84189039 100644
--- a/app/components/docs/visual_code_example.rb
+++ b/app/components/docs/visual_code_example.rb
@@ -78,8 +78,8 @@ def render_preview_tab(&block)
end
def iframe_preview
- div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do
- div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do
+ div(class: "relative min-h-[500px] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do
+ div(class: "absolute inset-0 hidden w-full bg-background md:block") do
iframe(src: @src, class: "size-full", data: {iframe_theme_target: "iframe"})
end
end
@@ -87,7 +87,7 @@ def iframe_preview
def raw_preview
div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do
- div(class: "preview flex min-h-[350px] w-full justify-center p-10 items-center") do
+ div(class: "preview min-h-[350px] w-full p-6") do
decoded_code = CGI.unescapeHTML(@display_code)
@context.instance_eval(decoded_code)
end
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 00000000..8a64aed2
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTable < Base
+ register_element :turbo_frame, tag: "turbo-frame"
+
+ def initialize(id:, **attrs)
+ @id = id
+ super(**attrs)
+ end
+
+ def view_template(&block)
+ turbo_frame(id: @id, target: "_top") do
+ div(**attrs) do
+ yield if block
+ end
+ end
+ end
+
+ private
+
+ def default_attrs
+ {
+ class: "w-full space-y-4",
+ data: {controller: "ruby-ui--data-table"}
+ }
+ end
+ end
+end
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 00000000..d5ccb50b
--- /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-2",
+ data: {"ruby-ui--data-table-target": "bulkActions"}
+ }
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_column_toggle.rb b/app/components/ruby_ui/data_table/data_table_column_toggle.rb
new file mode 100644
index 00000000..9bf311f8
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_column_toggle.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableColumnToggle < Base
+ def initialize(columns:, **attrs)
+ @columns = columns
+ super(**attrs)
+ end
+
+ def view_template
+ div(**attrs) do
+ render RubyUI::DropdownMenu.new do
+ render RubyUI::DropdownMenuTrigger.new do
+ render RubyUI::Button.new(variant: :outline, size: :sm) do
+ plain "Columns"
+ raw view_context.lucide_icon("chevron-down", class: "w-4 h-4 ml-1")
+ end
+ end
+ render RubyUI::DropdownMenuContent.new do
+ @columns.each do |col|
+ label(class: "flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent") do
+ input(
+ type: "checkbox",
+ checked: true,
+ class: "h-4 w-4 rounded border border-input accent-primary cursor-pointer",
+ data: {
+ column_key: col[:key].to_s,
+ action: "change->ruby-ui--data-table-column-visibility#toggle"
+ }
+ )
+ span { plain col[:label] }
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def default_attrs
+ {
+ class: "relative",
+ data: {controller: "ruby-ui--data-table-column-visibility"}
+ }
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_expand_toggle.rb b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb
new file mode 100644
index 00000000..445a05ba
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableExpandToggle < Base
+ def initialize(controls:, expanded: false, label: "Toggle row details", **attrs)
+ @controls = controls
+ @expanded = expanded
+ @label = label
+ super(**attrs)
+ end
+
+ def view_template
+ button(
+ type: "button",
+ aria_expanded: @expanded.to_s,
+ aria_controls: @controls,
+ aria_label: @label,
+ data: {
+ action: "click->ruby-ui--data-table#toggleRowDetail"
+ },
+ **attrs
+ ) do
+ render_icon
+ end
+ end
+
+ private
+
+ def render_icon
+ raw view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 group-aria-expanded:rotate-90")
+ end
+
+ def default_attrs
+ {
+ class: "group inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ }
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_form.rb b/app/components/ruby_ui/data_table/data_table_form.rb
new file mode 100644
index 00000000..cb45e675
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_form.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableForm < Base
+ def initialize(action: "", method: "post", id: nil, **attrs)
+ @action = action
+ @method = method
+ @id = id
+ super(**attrs)
+ end
+
+ def view_template(&block)
+ form_attrs = {action: @action, method: @method}
+ form_attrs[:id] = @id if @id
+ form(**form_attrs, **attrs) do
+ input(type: "hidden", name: "authenticity_token", value: csrf_token)
+ yield if block
+ end
+ end
+
+ private
+
+ def csrf_token
+ view_context.form_authenticity_token
+ end
+
+ def default_attrs
+ {}
+ 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 00000000..ab5be1c0
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_pagination.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTablePagination < Base
+ def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, window: 1, **attrs)
+ @adapter = resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:)
+ @page_param = page_param
+ @path = path
+ @query = query.to_h.transform_keys(&:to_s)
+ @window = window
+ super(**attrs)
+ end
+
+ def view_template
+ render RubyUI::Pagination.new(class: "mx-0 w-auto justify-end", **attrs) do
+ render RubyUI::PaginationContent.new do
+ prev_item
+ number_items
+ next_item
+ end
+ end
+ end
+
+ private
+
+ def resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:)
+ return with if with
+ return RubyUI::DataTablePaginationAdapters::Pagy.new(pagy) if pagy
+ return RubyUI::DataTablePaginationAdapters::Kaminari.new(kaminari) if kaminari
+ if page && per_page && total_count
+ return RubyUI::DataTablePaginationAdapters::Manual.new(page:, per_page:, total_count:)
+ end
+ raise ArgumentError, "DataTablePagination requires one of: with:, pagy:, kaminari:, or page:+per_page:+total_count:"
+ end
+
+ def current = @adapter.current_page
+ def total = @adapter.total_pages
+
+ def page_href(p)
+ qs = @query.merge(@page_param => p.to_s).to_query
+ qs.empty? ? @path : "#{@path}?#{qs}"
+ end
+
+ def prev_item
+ if current <= 1
+ li do
+ span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Previous" }
+ end
+ else
+ render RubyUI::PaginationItem.new(href: page_href(current - 1)) { plain "Previous" }
+ end
+ end
+
+ def next_item
+ if current >= total
+ li do
+ span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Next" }
+ end
+ else
+ render RubyUI::PaginationItem.new(href: page_href(current + 1)) { plain "Next" }
+ end
+ end
+
+ def number_items
+ windowed_pages.each do |p|
+ if p == :gap
+ render RubyUI::PaginationEllipsis.new
+ else
+ render RubyUI::PaginationItem.new(href: page_href(p), active: p == current) { plain p.to_s }
+ end
+ end
+ end
+
+ def windowed_pages
+ return (1..total).to_a if total <= 7
+ pages = [1]
+ pages << :gap if current - @window > 2
+ ((current - @window)..(current + @window)).each { |p| pages << p if p > 1 && p < total }
+ pages << :gap if current + @window < total - 1
+ pages << total
+ pages
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_pagination_bar.rb b/app/components/ruby_ui/data_table/data_table_pagination_bar.rb
new file mode 100644
index 00000000..c980890e
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_pagination_bar.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTablePaginationBar < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {class: "flex items-center justify-between gap-4 py-2"}
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_per_page_select.rb b/app/components/ruby_ui/data_table/data_table_per_page_select.rb
new file mode 100644
index 00000000..d3c88f8b
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_per_page_select.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTablePerPageSelect < Base
+ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [5, 10, 25, 50], **attrs)
+ @path = path
+ @name = name
+ @value = value
+ @frame_id = frame_id
+ @options = options
+ super(**attrs)
+ end
+
+ def view_template
+ form_attrs = {action: @path, method: "get"}
+ form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id
+
+ form(**attrs.merge(form_attrs)) do
+ render RubyUI::NativeSelect.new(name: @name, onchange: safe("this.form.requestSubmit()")) do
+ @options.each do |opt|
+ option_attrs = {value: opt.to_s}
+ option_attrs[:selected] = true if opt.to_s == @value.to_s
+ option(**option_attrs) { plain opt.to_s }
+ end
+ end
+ end
+ end
+
+ private
+
+ def default_attrs
+ {}
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_row_checkbox.rb b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb
new file mode 100644
index 00000000..0eba666a
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableRowCheckbox < Base
+ def initialize(value:, name: "ids[]", label: nil, **attrs)
+ @value = value
+ @name = name
+ @label = label
+ super(**attrs)
+ end
+
+ def view_template
+ render RubyUI::Checkbox.new(**attrs)
+ end
+
+ private
+
+ def default_attrs
+ {
+ name: @name,
+ value: @value,
+ aria_label: @label || "Select row #{@value}",
+ data: {
+ "ruby-ui--data-table-target": "rowCheckbox",
+ action: "change->ruby-ui--data-table#toggleRow"
+ }
+ }
+ 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 00000000..672aa053
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_search.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableSearch < Base
+ def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, **attrs)
+ @path = path
+ @name = name
+ @value = value
+ @frame_id = frame_id
+ @placeholder = placeholder
+ @debounce = debounce
+ @preserved_params = preserved_params
+ super(**attrs)
+ end
+
+ def view_template
+ form_attrs = {method: "get", action: @path}
+ form_attrs[:data] = form_data
+
+ form(**attrs.merge(form_attrs)) do
+ render RubyUI::Input.new(
+ type: :search,
+ name: @name,
+ value: @value,
+ placeholder: @placeholder,
+ autocomplete: "off"
+ )
+ @preserved_params.each do |k, v|
+ next if v.blank?
+ next if k.to_s == @name
+ input(type: "hidden", name: k.to_s, value: v.to_s)
+ end
+ end
+ end
+
+ private
+
+ def debounce_enabled?
+ @debounce && @debounce.to_i > 0
+ end
+
+ def form_data
+ base = {}
+ base[:turbo_frame] = @frame_id if @frame_id
+ if debounce_enabled?
+ base[:controller] = "ruby-ui--data-table-search"
+ base[:"ruby-ui--data-table-search-delay-value"] = @debounce.to_i
+ base[:action] = "input->ruby-ui--data-table-search#submit"
+ end
+ base
+ end
+
+ def default_attrs
+ {class: "max-w-sm flex-1"}
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb b/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb
new file mode 100644
index 00000000..d1478f9e
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableSelectAllCheckbox < Base
+ def view_template
+ render RubyUI::Checkbox.new(**attrs)
+ end
+
+ private
+
+ def default_attrs
+ {
+ aria_label: "Select all",
+ data: {
+ "ruby-ui--data-table-target": "selectAll",
+ action: "change->ruby-ui--data-table#toggleAll"
+ }
+ }
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_selection_summary.rb b/app/components/ruby_ui/data_table/data_table_selection_summary.rb
new file mode 100644
index 00000000..455e5f1c
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_selection_summary.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableSelectionSummary < Base
+ def initialize(total_on_page: 0, **attrs)
+ @total_on_page = total_on_page
+ super(**attrs)
+ end
+
+ def view_template
+ div(**attrs) do
+ plain "0 of #{@total_on_page} row(s) selected."
+ end
+ end
+
+ private
+
+ def default_attrs
+ {
+ class: "text-sm text-muted-foreground",
+ data: {"ruby-ui--data-table-target": "selectionSummary"}
+ }
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table/data_table_sort_head.rb b/app/components/ruby_ui/data_table/data_table_sort_head.rb
new file mode 100644
index 00000000..29cedd33
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_sort_head.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class DataTableSortHead < Base
+ def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort", direction_param: "direction", page_param: "page", path: "", query: {}, **attrs)
+ @column_key = column_key
+ @label = label
+ @sort = sort
+ @direction = direction
+ @sort_param = sort_param
+ @direction_param = direction_param
+ @page_param = page_param
+ @path = path
+ @query = query.to_h.transform_keys(&:to_s)
+ super(**attrs)
+ end
+
+ def view_template
+ render RubyUI::TableHead.new(class: "text-foreground whitespace-nowrap", **attrs) do
+ a(href: sort_href, class: "inline-flex items-center gap-1 text-inherit no-underline hover:text-foreground transition-colors") do
+ plain @label
+ sort_icon
+ end
+ end
+ end
+
+ private
+
+ def current_direction
+ (@sort.to_s == @column_key.to_s) ? @direction : nil
+ end
+
+ def next_params
+ next_dir = {nil => "asc", "asc" => "desc", "desc" => nil}[current_direction]
+ base = @query.except(@sort_param, @direction_param, @page_param)
+ next_dir ? base.merge(@sort_param => @column_key.to_s, @direction_param => next_dir) : base
+ end
+
+ def sort_href
+ qs = next_params.to_query
+ qs.empty? ? @path : "#{@path}?#{qs}"
+ end
+
+ def sort_icon
+ icon_name = case current_direction
+ when "asc" then "chevron-up"
+ when "desc" then "chevron-down"
+ else "chevrons-up-down"
+ end
+ icon_class = current_direction ? "inline-block w-3 h-3" : "inline-block w-3 h-3 opacity-30"
+
+ raw view_context.lucide_icon(icon_name, class: icon_class)
+ 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 00000000..e94867a2
--- /dev/null
+++ b/app/components/ruby_ui/data_table/data_table_toolbar.rb
@@ -0,0 +1,15 @@
+# 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/ruby_ui/data_table_pagination_adapters/kaminari.rb b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb
new file mode 100644
index 00000000..d9597eec
--- /dev/null
+++ b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module RubyUI
+ module DataTablePaginationAdapters
+ class Kaminari
+ def initialize(collection)
+ @collection = collection
+ end
+
+ def current_page = @collection.current_page
+ def total_pages = @collection.total_pages
+ def total_count = @collection.total_count
+ def per_page = @collection.limit_value
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table_pagination_adapters/manual.rb b/app/components/ruby_ui/data_table_pagination_adapters/manual.rb
new file mode 100644
index 00000000..b038ff1c
--- /dev/null
+++ b/app/components/ruby_ui/data_table_pagination_adapters/manual.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module RubyUI
+ module DataTablePaginationAdapters
+ class Manual
+ attr_reader :current_page, :per_page, :total_count
+
+ def initialize(page:, per_page:, total_count:)
+ @current_page = page.to_i
+ @per_page = [per_page.to_i, 1].max
+ @total_count = total_count.to_i
+ end
+
+ def total_pages
+ [(@total_count.to_f / @per_page).ceil, 1].max
+ end
+ end
+ end
+end
diff --git a/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb
new file mode 100644
index 00000000..16a418db
--- /dev/null
+++ b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module RubyUI
+ module DataTablePaginationAdapters
+ class Pagy
+ def initialize(pagy)
+ @pagy = pagy
+ end
+
+ def current_page = @pagy.page
+ def total_pages = @pagy.pages
+ def total_count = @pagy.count
+ def per_page = @pagy.items
+ end
+ end
+end
diff --git a/app/components/shared/components_list.rb b/app/components/shared/components_list.rb
index 49c82490..9f2775c1 100644
--- a/app/components/shared/components_list.rb
+++ b/app/components/shared/components_list.rb
@@ -25,6 +25,7 @@ def components
{name: "Combobox", path: docs_combobox_path},
{name: "Command", path: docs_command_path},
{name: "Context Menu", path: docs_context_menu_path},
+ {name: "Data Table", path: docs_data_table_path},
{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 00000000..92035286
--- /dev/null
+++ b/app/controllers/docs/data_table_demo_controller.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Docs
+ class DataTableDemoController < ApplicationController
+ layout -> { Views::Layouts::ExamplesLayout }
+
+ def index
+ employees = DataTableDemoData::EMPLOYEES.dup
+
+ if params[:search].present?
+ q = params[:search].downcase
+ employees = employees.select { |e| e.name.downcase.include?(q) || e.email.downcase.include?(q) }
+ end
+
+ if params[:sort].present?
+ col = params[:sort].to_sym
+ if employees.first&.respond_to?(col)
+ employees = employees.sort_by do |e|
+ v = e.send(col)
+ v.is_a?(Numeric) ? v : v.to_s.downcase
+ end
+ employees = employees.reverse if params[:direction] == "desc"
+ end
+ end
+
+ @total_count = employees.size
+ @per_page = (params[:per_page] || 5).to_i.clamp(1, 100)
+ @total_pages = [(@total_count.to_f / @per_page).ceil, 1].max
+ @page = (params[:page] || 1).to_i.clamp(1, @total_pages)
+
+ offset = (@page - 1) * @per_page
+ @employees = employees.slice(offset, @per_page) || []
+
+ render Views::Docs::DataTableDemo::Index.new(
+ employees: @employees,
+ total_count: @total_count,
+ page: @page,
+ per_page: @per_page,
+ sort: params[:sort],
+ direction: params[:direction],
+ search: params[:search]
+ )
+ end
+
+ def bulk_delete
+ ids = Array(params[:ids]).map(&:to_s)
+ flash[:notice] = "Would delete: #{ids.join(", ")}"
+ redirect_to docs_data_table_demo_path
+ end
+
+ def bulk_export
+ ids = Array(params[:ids]).map(&:to_s)
+ flash[:notice] = "Would export: #{ids.join(", ")}"
+ redirect_to docs_data_table_demo_path
+ end
+ end
+end
diff --git a/app/controllers/docs/data_table_demo_data.rb b/app/controllers/docs/data_table_demo_data.rb
new file mode 100644
index 00000000..a826b3e1
--- /dev/null
+++ b/app/controllers/docs/data_table_demo_data.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+module Docs
+ module DataTableDemoData
+ EMPLOYEES = [
+ {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: 60_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
+ end
+end
diff --git a/app/controllers/docs_controller.rb b/app/controllers/docs_controller.rb
index 071964b0..f60ca123 100644
--- a/app/controllers/docs_controller.rb
+++ b/app/controllers/docs_controller.rb
@@ -122,6 +122,10 @@ def context_menu
render Views::Docs::ContextMenu.new
end
+ def data_table
+ render Views::Docs::DataTable.new
+ end
+
def date_picker
render Views::Docs::DatePicker.new
end
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index e92a12d6..0c2e8e14 100644
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -43,6 +43,16 @@ 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__DataTableColumnVisibilityController from "./ruby_ui/data_table_column_visibility_controller"
+application.register("ruby-ui--data-table-column-visibility", RubyUi__DataTableColumnVisibilityController)
+
+
+import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller"
+application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController)
+
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_column_visibility_controller.js b/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js
new file mode 100644
index 00000000..d3cb0584
--- /dev/null
+++ b/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js
@@ -0,0 +1,14 @@
+// app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ toggle(event) {
+ const key = event.target.dataset.columnKey;
+ const visible = event.target.checked;
+ const root = this.element.closest('[data-controller~="ruby-ui--data-table"]');
+ if (!root) return;
+ root
+ .querySelectorAll(`[data-column="${key}"]`)
+ .forEach((el) => el.classList.toggle("hidden", !visible));
+ }
+}
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 00000000..1ffb8fb2
--- /dev/null
+++ b/app/javascript/controllers/ruby_ui/data_table_controller.js
@@ -0,0 +1,57 @@
+// app/javascript/controllers/ruby_ui/data_table_controller.js
+import { Controller } from "@hotwired/stimulus";
+
+export default class extends Controller {
+ static targets = [
+ "selectAll",
+ "rowCheckbox",
+ "selectionSummary",
+ "selectionBar",
+ "bulkActions",
+ ];
+
+ connect() {
+ this.updateState();
+ }
+
+ toggleAll(event) {
+ const checked = event.target.checked;
+ this.rowCheckboxTargets.forEach((cb) => {
+ cb.checked = checked;
+ });
+ this.updateState();
+ }
+
+ toggleRow() {
+ this.updateState();
+ }
+
+ toggleRowDetail(event) {
+ const button = event.currentTarget;
+ const id = button.getAttribute("aria-controls");
+ if (!id) return;
+ const target = document.getElementById(id);
+ if (!target) return;
+ const expanded = button.getAttribute("aria-expanded") === "true";
+ button.setAttribute("aria-expanded", String(!expanded));
+ target.classList.toggle("hidden", expanded);
+ }
+
+ updateState() {
+ const total = this.rowCheckboxTargets.length;
+ const selected = this.rowCheckboxTargets.filter((cb) => cb.checked).length;
+
+ if (this.hasSelectAllTarget) {
+ this.selectAllTarget.checked = total > 0 && selected === total;
+ this.selectAllTarget.indeterminate = selected > 0 && selected < total;
+ }
+
+ if (this.hasSelectionSummaryTarget) {
+ this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`;
+ }
+
+ if (this.hasBulkActionsTarget) {
+ this.bulkActionsTarget.classList.toggle("hidden", selected === 0);
+ }
+ }
+}
diff --git a/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/app/javascript/controllers/ruby_ui/data_table_search_controller.js
new file mode 100644
index 00000000..0dc4101c
--- /dev/null
+++ b/app/javascript/controllers/ruby_ui/data_table_search_controller.js
@@ -0,0 +1,62 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Module-level map survives controller disconnect/connect across Turbo Frame swaps.
+// Keyed by the search form's action URL.
+const PENDING_FOCUS = new Map();
+
+export default class extends Controller {
+ static values = { delay: { type: Number, default: 300 } };
+
+ connect() {
+ this.timer = null;
+ this.beforeFrameRender = this.captureBeforeRender.bind(this);
+ document.addEventListener("turbo:before-frame-render", this.beforeFrameRender);
+ // New instance after a Turbo Frame swap — check for captured state.
+ this.restoreIfPending();
+ }
+
+ disconnect() {
+ clearTimeout(this.timer);
+ document.removeEventListener("turbo:before-frame-render", this.beforeFrameRender);
+ }
+
+ submit(event) {
+ if (event && event.type !== "input") return;
+ clearTimeout(this.timer);
+ if (this.delayValue <= 0) return;
+ this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue);
+ }
+
+ captureBeforeRender() {
+ const input = this.input();
+ if (!input || document.activeElement !== input) return;
+ PENDING_FOCUS.set(this.key(), {
+ selectionStart: input.selectionStart,
+ selectionEnd: input.selectionEnd
+ });
+ }
+
+ restoreIfPending() {
+ const state = PENDING_FOCUS.get(this.key());
+ if (!state) return;
+ PENDING_FOCUS.delete(this.key());
+ const input = this.input();
+ if (!input) return;
+ input.focus();
+ const len = input.value.length;
+ try {
+ input.setSelectionRange(
+ Math.min(state.selectionStart ?? len, len),
+ Math.min(state.selectionEnd ?? len, len)
+ );
+ } catch (e) {}
+ }
+
+ input() {
+ return this.element.querySelector('input[type="search"]');
+ }
+
+ key() {
+ return this.element.action || "_";
+ }
+}
diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb
new file mode 100644
index 00000000..d9b1a82e
--- /dev/null
+++ b/app/views/docs/data_table.rb
@@ -0,0 +1,373 @@
+# app/views/docs/data_table.rb
+# frozen_string_literal: true
+
+class Views::Docs::DataTable < Views::Base
+ # Stub data used by code-snippet previews (examples 2-6)
+ Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true)
+
+ def view_template
+ # Stubs so instance_eval'd preview snippets don't raise NameError
+ @rows = [
+ Row.new(id: 1, name: "Alice", email: "alice@example.com", salary: 90_000, status: "Active"),
+ Row.new(id: 2, name: "Bob", email: "bob@example.com", salary: 75_000, status: "Inactive")
+ ]
+ @page = 1
+ @per_page = 10
+ @total = 2
+
+ div(class: "mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(
+ title: "Data Table",
+ description: "A Hotwire-first data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission."
+ )
+
+ # ── Example 1: Complete demo (primary) ─────────────────────────────────
+ Heading(level: 2) { "Complete demo" }
+ p(class: "-mt-6") { "Full feature set — search, sort, numbered pagination, per-page, select-all, row checkboxes, bulk actions, row actions dropdown, column visibility, badge cells." }
+
+ render Docs::VisualCodeExample.new(title: "Complete demo", src: "/docs/data_table_demo", context: self) do
+ <<~RUBY
+ FORM_ID = "employees_form"
+
+ DataTable(id: "employees_list") do
+ DataTableToolbar do
+ DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search)
+ div(class: "flex items-center gap-2") do
+ DataTableColumnToggle(columns: [
+ {key: :email, label: "Email"},
+ {key: :department, label: "Department"}
+ ])
+ DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page)
+ DataTableBulkActions do
+ Button(type: "submit", form: FORM_ID, formaction: bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" }
+ Button(type: "submit", form: FORM_ID, formaction: bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" }
+ end
+ end
+ end
+
+ DataTableForm(id: FORM_ID, action: "") do
+ div(class: "rounded-md border") do
+ Table do
+ TableHeader do
+ TableRow do
+ TableHead(class: "w-10") { DataTableSelectAllCheckbox() }
+ DataTableSortHead(column_key: :name, label: "Name", sort: @sort, direction: @direction, path: docs_data_table_demo_path)
+ DataTableSortHead(column_key: :salary, label: "Salary", sort: @sort, direction: @direction, path: docs_data_table_demo_path)
+ end
+ end
+ TableBody do
+ @employees.each do |e|
+ TableRow do
+ TableCell { DataTableRowCheckbox(value: e.id) }
+ TableCell { e.name }
+ TableCell { e.salary }
+ end
+ end
+ end
+ end
+ end
+ end
+
+ DataTablePaginationBar do
+ DataTableSelectionSummary(total_on_page: @employees.size)
+ DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path)
+ end
+ end
+ RUBY
+ end
+
+ # ── Example 2: Server-driven (search + sort + pagination) ─────────────
+ Heading(level: 2) { "Server-driven" }
+ p(class: "-mt-6") { "Turbo Frame GET on each sort/search/page. No client-only state." }
+
+ render Docs::VisualCodeExample.new(title: "Server-driven", context: self) do
+ <<~RUBY
+ DataTable(id: "server") do
+ DataTableToolbar do
+ # Pass preserved_params so sort/direction survive a new search.
+ DataTableSearch(path: my_path, preserved_params: {"sort" => @sort, "direction" => @direction}.compact_blank)
+ end
+
+ Table do
+ TableHeader do
+ TableRow do
+ DataTableSortHead(column_key: :name, label: "Name", path: my_path)
+ end
+ end
+ TableBody do
+ @rows.each { |r| TableRow { TableCell { r.name } } }
+ end
+ end
+
+ DataTablePagination(page: @page, per_page: @per_page, total_count: @total, path: my_path)
+ end
+ RUBY
+ end
+
+ # ── Example 3: Selection + bulk actions ───────────────────────────────
+ Heading(level: 2) { "Selection + bulk actions" }
+ p(class: "-mt-6") { "DataTableBulkActions is a plain slot — put any Phlex content inside. Row checkboxes are elements inside DataTableForm. Bulk action buttons submit that form with the selected IDs via HTML5 form-association attributes." }
+
+ render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do
+ <<~RUBY
+ DataTable(id: "selection") do
+ DataTableToolbar do
+ div # empty left slot — or place filters here
+ DataTableBulkActions do
+ Button(type: "submit", form: "selection_form",
+ formaction: bulk_delete_path, formmethod: "post",
+ data: {turbo_confirm: "Delete selected?"},
+ variant: :destructive, size: :sm) { "Delete" }
+ Button(type: "submit", form: "selection_form",
+ formaction: bulk_export_path, formmethod: "post",
+ variant: :outline, size: :sm) { "Export" }
+ end
+ end
+
+ DataTableForm(id: "selection_form", action: "") do
+ Table do
+ TableHeader do
+ TableRow do
+ TableHead { DataTableSelectAllCheckbox() }
+ TableHead { "Name" }
+ end
+ end
+ TableBody do
+ @rows.each do |r|
+ TableRow do
+ TableCell { DataTableRowCheckbox(value: r.id) }
+ TableCell { r.name }
+ end
+ end
+ end
+ end
+ end
+
+ DataTableSelectionSummary(total_on_page: @rows.size)
+ end
+ RUBY
+ end
+
+ Heading(level: 3) { "Bulk action button attributes" }
+ p { "Because the submit buttons live inside DataTableToolbar (outside DataTableForm), you must use HTML5 form-association attributes to wire them up. Server receives params[:ids] as an array." }
+
+ Table do
+ TableHeader do
+ TableRow do
+ TableHead { "Attribute" }
+ TableHead { "Required" }
+ TableHead { "Purpose" }
+ end
+ end
+ TableBody do
+ [
+ ['type: "submit"', "yes", "native submit button"],
+ ["form: FORM_ID", "yes (button is outside DataTableForm)", "HTML5 form-association — lets the button submit a form located elsewhere in the DOM"],
+ ["formaction: \"/path\"", "yes", "target URL, overrides the form's action"],
+ ["formmethod: \"post\"", "yes", "HTTP verb, overrides the form's method"],
+ ["formnovalidate: true", "optional", "skip HTML5 validation"],
+ ["data: {turbo_confirm: \"Are you sure?\"}", "optional", "Rails/Turbo confirmation dialog before submit"]
+ ].each do |attr, required, purpose|
+ TableRow do
+ TableCell { code(class: "font-mono text-xs") { plain attr } }
+ TableCell { plain required }
+ TableCell { plain purpose }
+ end
+ end
+ end
+ end
+
+ p { "For simpler bulk actions that include CSRF and Turbo confirms out of the box, you can use Rails' #{code(class: "font-mono text-xs") { "button_to" }} helper — e.g. #{code(class: "font-mono text-xs") { 'button_to "Delete", path, method: :delete, form: {data: {turbo_confirm: "..."}}' }} — the button will carry a nested form that submits to the given path." }
+
+ Heading(level: 3) { "Rails controller example" }
+ p { "Your endpoint receives the selected IDs as params[:ids] (an array of strings):" }
+
+ Codeblock(<<~RUBY, syntax: :ruby)
+ class EmployeesController < ApplicationController
+ def bulk_delete
+ ids = Array(params[:ids]).map(&:to_i)
+ Employee.where(id: ids).destroy_all
+ redirect_to employees_path, notice: "Deleted \#{ids.size} employees"
+ end
+
+ def bulk_export
+ ids = Array(params[:ids]).map(&:to_i)
+ employees = Employee.where(id: ids)
+ send_data employees.to_csv, filename: "employees.csv"
+ end
+ end
+ RUBY
+
+ # ── Example 4: Column visibility ──────────────────────────────────────
+ Heading(level: 2) { "Column visibility" }
+ p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." }
+ p { "Column visibility is client-side and resets on every Turbo Frame swap (sort/search/page re-renders). If you need it to persist, encode it in a URL param (e.g. `?columns=name,status`) or store in localStorage." }
+
+ render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do
+ <<~RUBY
+ DataTable(id: "columns") do
+ DataTableToolbar do
+ DataTableColumnToggle(columns: [
+ {key: :email, label: "Email"},
+ {key: :salary, label: "Salary"}
+ ])
+ end
+
+ Table do
+ TableHeader do
+ TableRow do
+ TableHead { "Name" }
+ TableHead(data: {column: "email"}) { "Email" }
+ TableHead(data: {column: "salary"}) { "Salary" }
+ end
+ end
+ TableBody do
+ @rows.each do |r|
+ TableRow do
+ TableCell { r.name }
+ TableCell(data: {column: "email"}) { r.email }
+ TableCell(data: {column: "salary"}) { r.salary }
+ end
+ end
+ end
+ end
+ end
+ RUBY
+ end
+
+ # ── Example 5: Custom cell renderers ──────────────────────────────────
+ Heading(level: 2) { "Custom cell renderers" }
+ p(class: "-mt-6") { "Plain Ruby helpers for badge/date/currency — the gem does not ship renderers." }
+
+ render Docs::VisualCodeExample.new(title: "Custom cell renderers", context: self) do
+ <<~RUBY
+ def status_badge(status)
+ variant = {"Active" => :success, "Inactive" => :destructive}.fetch(status, :outline)
+ Badge(variant: variant, size: :sm) { plain status }
+ end
+
+ DataTable(id: "renderers") do
+ Table do
+ TableHeader do
+ TableRow do
+ TableHead { "Name" }
+ TableHead { "Status" }
+ TableHead(class: "text-right") { "Salary" }
+ end
+ end
+ TableBody do
+ @rows.each do |r|
+ TableRow do
+ TableCell { r.name }
+ TableCell { status_badge(r.status) }
+ TableCell(class: "text-right") { plain view_context.number_to_currency(r.salary, precision: 0) }
+ end
+ end
+ end
+ end
+ end
+ RUBY
+ end
+
+ # ── Example 6: Expandable rows ────────────────────────────────────────
+ Heading(level: 2) { "Expandable rows" }
+ p(class: "-mt-6") { "Toggle a detail region below each row. Accessible: aria-expanded, aria-controls, keyboard-focusable button, region role on the expanded content." }
+
+ render Docs::VisualCodeExample.new(title: "Expandable rows", context: self) do
+ <<~RUBY
+ DataTable(id: "expand_demo") do
+ Table do
+ TableHeader do
+ TableRow do
+ TableHead(class: "w-10") { }
+ TableHead { "Name" }
+ TableHead { "Role" }
+ end
+ end
+ TableBody do
+ @rows.each do |r|
+ detail_id = "row-\#{r.id}-detail"
+ TableRow do
+ TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle details for \#{r.name}") }
+ TableCell { r.name }
+ TableCell { r.email }
+ end
+ TableRow(id: detail_id, class: "hidden", role: "region") do
+ TableCell(colspan: 3, class: "bg-muted/40") do
+ div(class: "p-4 space-y-1") do
+ p { "Salary: $\#{r.salary}" }
+ p { "Status: \#{r.status}" }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ RUBY
+ end
+
+ # ── Pagination adapters ───────────────────────────────────────────────
+ Heading(level: 2) { "Pagination adapters" }
+ p { "DataTablePagination accepts a pagination source via one of four keyword forms. Each resolves to an internal adapter exposing current_page, total_pages, total_count, and per_page." }
+
+ Heading(level: 3) { "Manual" }
+ p { "No gem required. Pass page/per_page/total_count directly." }
+ Codeblock(<<~RUBY, syntax: :ruby)
+ DataTablePagination(
+ page: @page,
+ per_page: @per_page,
+ total_count: @total_count,
+ path: employees_path
+ )
+ RUBY
+
+ Heading(level: 3) { "Pagy" }
+ p { "If you use Pagy, pass the pagy object directly." }
+ Codeblock(<<~RUBY, syntax: :ruby)
+ @pagy, @employees = pagy(Employee.all)
+
+ DataTablePagination(pagy: @pagy, path: employees_path)
+ RUBY
+
+ Heading(level: 3) { "Kaminari" }
+ p { "If you use Kaminari, pass the paginated collection." }
+ Codeblock(<<~RUBY, syntax: :ruby)
+ @employees = Employee.page(params[:page]).per(25)
+
+ DataTablePagination(kaminari: @employees, path: employees_path)
+ RUBY
+
+ Heading(level: 3) { "Custom adapter" }
+ p { "Any object responding to current_page, total_pages, total_count and per_page works via the with: keyword. Useful when wrapping a different gem or custom pagination logic." }
+ Codeblock(<<~RUBY, syntax: :ruby)
+ class MyAdapter
+ def initialize(result)
+ @result = result
+ end
+
+ def current_page = @result.page
+ def total_pages = @result.total_pages
+ def total_count = @result.count
+ def per_page = @result.limit
+ end
+
+ DataTablePagination(with: MyAdapter.new(@result), path: employees_path)
+ RUBY
+ end
+ end
+
+ private
+
+ def my_path
+ "#"
+ end
+
+ def bulk_delete_path
+ "#"
+ end
+
+ def bulk_export_path
+ "#"
+ 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 00000000..19c8ee40
--- /dev/null
+++ b/app/views/docs/data_table_demo/index.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+class Views::Docs::DataTableDemo::Index < Views::Base
+ FRAME_ID = "employees_list"
+ FORM_ID = "employees_form"
+
+ TOGGLABLE_COLUMNS = [
+ {key: :email, label: "Email"},
+ {key: :department, label: "Department"},
+ {key: :status, label: "Status"},
+ {key: :salary, label: "Salary"}
+ ].freeze
+
+ BADGE_VARIANTS = {
+ "Active" => :success,
+ "Inactive" => :destructive,
+ "On Leave" => :warning
+ }.freeze
+
+ def initialize(employees:, total_count:, page:, per_page:, sort:, direction:, search:)
+ @employees = employees
+ @total_count = total_count
+ @page = page
+ @per_page = per_page
+ @sort = sort
+ @direction = direction
+ @search = search
+ end
+
+ def view_template
+ div(class: "p-6") { render_table }
+ end
+
+ private
+
+ def render_table
+ DataTable(id: FRAME_ID) do
+ DataTableToolbar do
+ DataTableSearch(
+ path: docs_data_table_demo_path,
+ frame_id: FRAME_ID,
+ value: @search,
+ placeholder: "Filter emails...",
+ preserved_params: preserved_query.except("search")
+ )
+ div(class: "flex items-center gap-2") do
+ DataTableColumnToggle(columns: TOGGLABLE_COLUMNS)
+ DataTablePerPageSelect(
+ path: docs_data_table_demo_path,
+ frame_id: FRAME_ID,
+ value: @per_page
+ )
+ DataTableBulkActions do
+ Button(type: "submit", form: FORM_ID, formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" }
+ Button(type: "submit", form: FORM_ID, formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" }
+ end
+ end
+ end
+
+ DataTableForm(id: FORM_ID, action: "") do
+ div(class: "rounded-md border") do
+ Table do
+ TableHeader do
+ TableRow do
+ TableHead(class: "w-10") { DataTableSelectAllCheckbox() }
+ DataTableSortHead(column_key: :name, label: "Name", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query)
+ DataTableSortHead(column_key: :email, label: "Email", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, data: {column: "email"})
+ DataTableSortHead(column_key: :department, label: "Department", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, data: {column: "department"})
+ TableHead(data: {column: "status"}) { plain "Status" }
+ DataTableSortHead(column_key: :salary, label: "Salary", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, class: "text-right [&>a]:justify-end", data: {column: "salary"})
+ TableHead(class: "w-12")
+ end
+ end
+
+ TableBody do
+ if @employees.empty?
+ TableRow do
+ TableCell(colspan: 7, class: "h-24 text-center text-muted-foreground") { plain "No results." }
+ end
+ else
+ @employees.each do |e|
+ TableRow do
+ TableCell(class: "w-10") { DataTableRowCheckbox(value: e.id, label: "Select row for #{e.name}") }
+ TableCell(class: "font-medium") { plain e.name }
+ TableCell(data: {column: "email"}) { plain e.email }
+ TableCell(data: {column: "department"}) { plain e.department }
+ TableCell(data: {column: "status"}) do
+ Badge(variant: BADGE_VARIANTS.fetch(e.status, :outline), size: :sm) { plain e.status }
+ end
+ TableCell(class: "text-right", data: {column: "salary"}) { plain view_context.number_to_currency(e.salary, precision: 0, unit: "$") }
+ TableCell(class: "w-12 text-right") { row_actions(e) }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+
+ DataTablePaginationBar do
+ DataTableSelectionSummary(total_on_page: @employees.size)
+ DataTablePagination(
+ page: @page,
+ per_page: @per_page,
+ total_count: @total_count,
+ path: docs_data_table_demo_path,
+ query: preserved_query
+ )
+ end
+ end
+ end
+
+ def row_actions(employee)
+ DropdownMenu do
+ DropdownMenuTrigger do
+ Button(type: "button", variant: :ghost, size: :icon, aria_label: "Open menu") do
+ raw view_context.lucide_icon("ellipsis-vertical", class: "h-4 w-4")
+ end
+ end
+ DropdownMenuContent do
+ DropdownMenuLabel { plain "Actions" }
+ DropdownMenuItem(href: "#") { plain "Copy employee ID" }
+ DropdownMenuSeparator()
+ DropdownMenuItem(href: "#") { plain "View details" }
+ DropdownMenuItem(href: "#") { plain "View payments" }
+ end
+ end
+ end
+
+ def preserved_query
+ {
+ "search" => @search,
+ "sort" => @sort,
+ "direction" => @direction,
+ "per_page" => @per_page.to_s
+ }.compact_blank
+ end
+end
diff --git a/app/views/layouts/docs_layout.rb b/app/views/layouts/docs_layout.rb
index 85fe4adc..70c0ffb0 100644
--- a/app/views/layouts/docs_layout.rb
+++ b/app/views/layouts/docs_layout.rb
@@ -17,7 +17,7 @@ def view_template(&block)
div(class: "border-b") do
div(class: "container px-4 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10") do
render Shared::Sidebar.new
- main(class: "relative py-6 lg:gap-10 lg:py-8 xl:grid xl:grid-cols-[1fr_300px]", &block)
+ main(class: "relative py-6 lg:py-8 w-full min-w-0", &block)
end
end
end
diff --git a/config/initializers/ruby_ui.rb b/config/initializers/ruby_ui.rb
index a5b0a4d0..eb98b4e8 100644
--- a/config/initializers/ruby_ui.rb
+++ b/config/initializers/ruby_ui.rb
@@ -15,4 +15,9 @@ module RubyUI
)
# Allow using RubyUI::ComponentName instead RubyUI::ComponentName::ComponentName
-Rails.autoloaders.main.collapse(Rails.root.join("app/components/ruby_ui/*"))
+# data_table_pagination_adapters/ is intentionally excluded from collapse so that
+# RubyUI::DataTablePaginationAdapters is a proper module (adapter namespace).
+collapse_dirs = Dir.glob(Rails.root.join("app/components/ruby_ui/*")).reject do |path|
+ path.end_with?("data_table_pagination_adapters")
+end
+Rails.autoloaders.main.collapse(collapse_dirs) unless collapse_dirs.empty?
diff --git a/config/routes.rb b/config/routes.rb
index bee40040..32060507 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -64,6 +64,12 @@
get "theme_toggle", to: "docs#theme_toggle", as: :docs_theme_toggle
get "tooltip", to: "docs#tooltip", as: :docs_tooltip
get "typography", to: "docs#typography", as: :docs_typography
+
+ # DATA TABLE
+ 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
+ post "data_table_demo/bulk_delete", to: "docs/data_table_demo#bulk_delete", as: :docs_data_table_demo_bulk_delete
+ post "data_table_demo/bulk_export", to: "docs/data_table_demo#bulk_export", as: :docs_data_table_demo_bulk_export
end
match "/404", to: "errors#not_found", via: :all
diff --git a/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb b/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb
new file mode 100644
index 00000000..33124e25
--- /dev/null
+++ b/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb
@@ -0,0 +1,10 @@
+require "test_helper"
+
+class RubyUI::DataTableBulkActionsTest < ActiveSupport::TestCase
+ test "starts hidden with bulkActions target + renders children" do
+ out = RubyUI::DataTableBulkActions.new.call { "BUTTONS" }
+ assert_match(/class="[^"]*hidden[^"]*"/, out)
+ assert_match(/data-ruby-ui--data-table-target="bulkActions"/, out)
+ assert_match(/BUTTONS/, out)
+ end
+end
diff --git a/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb
new file mode 100644
index 00000000..4dc433a9
--- /dev/null
+++ b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb
@@ -0,0 +1,16 @@
+require "test_helper"
+
+class RubyUI::DataTableColumnToggleTest < ActiveSupport::TestCase
+ test "renders dropdown with checkbox per column" do
+ out = render_component(RubyUI::DataTableColumnToggle.new(columns: [
+ {key: :email, label: "Email"},
+ {key: :salary, label: "Salary"}
+ ]))
+ assert_match(/Columns/, out)
+ assert_match(/data-controller="[^"]*ruby-ui--data-table-column-visibility/, out)
+ assert_match(/data-column-key="email"/, out)
+ assert_match(/data-column-key="salary"/, out)
+ assert_match(/Email/, out)
+ assert_match(/Salary/, out)
+ end
+end
diff --git a/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb
new file mode 100644
index 00000000..f6cedb30
--- /dev/null
+++ b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb
@@ -0,0 +1,19 @@
+require "test_helper"
+
+class RubyUI::DataTableExpandToggleTest < ActiveSupport::TestCase
+ test "renders a button with aria attributes and delegated action" do
+ out = render_component(RubyUI::DataTableExpandToggle.new(controls: "emp-1-detail"))
+ assert_match(/