Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion audit/component_map/data_table.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ parts:
selectors:
DataTableColumnToggle: '[data-controller~="ruby-ui--data-table-column-visibility"] button'
DropdownMenuContent: '[data-slot="dropdown-menu-content"]'
DataTablePerPageSelect: 'select[name="per_page"]'
DataTablePerPageSelect: 'form:has(input[name="per_page"]) [data-slot="select-trigger"]'
# Same slot as the row checkboxes: compared together under DataTableRowCheckbox.
DataTableSelectAllCheckbox: null
DataTableRowCheckbox: '[data-slot="checkbox"]'
Expand Down
20 changes: 20 additions & 0 deletions audit/divergences/data_table.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,23 @@
shadcn: "dashboard-01 block: div with four <button>s calling TanStack Table's page setters; disabled ends use the disabled attribute"
reason: "RubyUI's DataTable is server-driven: paging is a GET answered with HTML and swapped by a Turbo Frame, so links work without JS and can be opened in a new tab. Look and accessible names match the block."
since: "2026-11-02"
- component: data_table
dimension: Behavior
rubyui: "DataTablePerPageSelect: picking a size fires change on SelectInput and the ruby-ui--data-table-per-page-select controller submits the GET form with the size as a query param (per_page unless name: renames it), plus the preserved_params the caller passes, into the Turbo Frame when frame_id: is given; the page restarts at 1 unless the caller preserves page"
shadcn: "dashboard-01 block: Select onValueChange calls TanStack Table's setPageSize on the client"
reason: "RubyUI's DataTable is server-driven, as for pagination: the page size is a query param the server reads, so the list comes back from the server and the URL can be shared. The Select itself (open, arrows, typeahead, Enter, Esc) behaves as shadcn's."
since: "2026-11-05"
- component: data_table
dimension: Look
match: {part: DataTableRowCheckbox, property: display, rubyui: "block", shadcn: "flex"}
rubyui: "row checkbox is RubyUI::Checkbox, a native <input type=checkbox> with no children; as a flex item of the cell's div.flex it computes display: block"
shadcn: "Base UI Checkbox is a <span class=\"flex\"> that centers its Indicator child"
reason: "flex only lays out children, and the native input has none (its check mark is an absolutely positioned ::before), so block and flex render the same box. Adding flex to the input would change nothing on screen."
since: "2026-11-06"
- component: data_table
dimension: Look
match: {part: DataTableRowCheckbox, property: text-align, rubyui: "start", shadcn: "left"}
rubyui: "select-all checkbox (header cell, text-left) is a native input: the browser's form-control defaults reset text-align to start instead of inheriting left"
shadcn: "the Base UI <span> inherits text-align: left from the header cell (TableHead text-left); row checkboxes in body cells are start on both sides"
reason: "In a left-to-right document start resolves to left, and the input has no text to align either way."
since: "2026-11-06"
31 changes: 29 additions & 2 deletions audit/fixtures/data_table/rubyui.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,15 @@ def view_template
TableRow do
TableCell(class: "w-8") { div(class: "flex items-center justify-center") { DataTableRowCheckbox(value: id, label: "Select row") } }
TableCell { header }
TableCell(data: {column: "type"}) { Badge(variant: :outline, size: :sm) { type } }
TableCell(data: {column: "status"}) { Badge(variant: :outline, size: :sm) { status } }
TableCell(data: {column: "type"}) do
div(class: "w-32") { Badge(variant: :outline, class: "px-1.5 text-muted-foreground") { type } }
end
TableCell(data: {column: "status"}) do
Badge(variant: :outline, class: "px-1.5 text-muted-foreground") do
(status == "Done") ? circle_check_icon : loader_icon
plain status
end
end
TableCell(data: {column: "reviewer"}) { reviewer }
end
end
Expand All @@ -64,5 +71,25 @@ def view_template
end
end
end

private

# lucide circle-check, as the block's status cell for "Done"
def circle_check_icon
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", aria_hidden: "true",
class: "fill-green-500 dark:fill-green-400") do |s|
s.circle(cx: "12", cy: "12", r: "10")
s.path(d: "m16 9-5.5 5.5L8 12")
end
end

# lucide loader, as the block's status cell otherwise
def loader_icon
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", aria_hidden: "true") do |s|
["M12 2v4", "m16.2 7.8 2.9-2.9", "M18 12h4", "m16.2 16.2 2.9 2.9", "M12 18v4", "m4.9 19.1 2.9-2.9", "M2 12h4", "m4.9 4.9 2.9 2.9"].each { |d| s.path(d:) }
end
end
end
end
10 changes: 9 additions & 1 deletion audit/fixtures/data_table/shadcn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
ChevronRightIcon,
ChevronsLeftIcon,
ChevronsRightIcon,
CircleCheckIcon,
Columns3Icon,
LoaderIcon,
} from "lucide-react"
import { useState } from "react"

Expand Down Expand Up @@ -35,7 +37,8 @@ import {
TableRow,
} from "@/components/ui/table"

// JSX and classes from bases/base/blocks/dashboard-01/components/data-table.tsx,
// JSX and classes from bases/base/blocks/dashboard-01/components/data-table.tsx
// (status icons: the lucide picks of its IconPlaceholder),
// without drag-and-drop, tabs, the drawer and the inline target/limit inputs.
const rows = [
{ id: 1, header: "Cover page", type: "Cover page", status: "In Process", reviewer: "Eddie Lake" },
Expand Down Expand Up @@ -125,6 +128,11 @@ export default function DataTableFixture() {
{visible.Status && (
<TableCell>
<Badge variant="outline" className="px-1.5 text-muted-foreground">
{row.status === "Done" ? (
<CircleCheckIcon className="fill-green-500 dark:fill-green-400" />
) : (
<LoaderIcon />
)}
{row.status}
</Badge>
</TableCell>
Expand Down
3 changes: 3 additions & 0 deletions docs/app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ application.register("ruby-ui--data-table-column-visibility", RubyUi__DataTableC
import RubyUi__DataTableController from "./ruby_ui/data_table_controller"
application.register("ruby-ui--data-table", RubyUi__DataTableController)

import RubyUi__DataTablePerPageSelectController from "./ruby_ui/data_table_per_page_select_controller"
application.register("ruby-ui--data-table-per-page-select", RubyUi__DataTablePerPageSelectController)

import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller"
application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController)

Expand Down
4 changes: 3 additions & 1 deletion gem/lib/generators/ruby_ui/dependencies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ data_table:
- "Checkbox"
- "DropdownMenu"
- "Input"
- "NativeSelect"
- "Select"
- "Pagination"
- "Table"
js_packages:
- "@floating-ui/dom"

dialog:
components:
Expand Down
6 changes: 4 additions & 2 deletions gem/lib/ruby_ui/data_table/data_table_column_toggle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ def initialize(columns:, label: "Columns", **attrs)

def view_template
div(**attrs) do
render RubyUI::DropdownMenu.new do
# Both wrappers are display: contents so the button is a flex item of
# the toolbar, as shadcn's trigger (inline-flex blockifies to flex).
render RubyUI::DropdownMenu.new(class: "contents") do
render RubyUI::DropdownMenuTrigger.new do
render RubyUI::Button.new(variant: :outline, size: :sm) do
columns_icon
Expand Down Expand Up @@ -55,7 +57,7 @@ def chevron_down_icon

def default_attrs
{
class: "relative",
class: "contents",
data: {
controller: "ruby-ui--data-table-column-visibility",
action: "ruby-ui--dropdown-menu:change->ruby-ui--data-table-column-visibility#toggle"
Expand Down
29 changes: 23 additions & 6 deletions gem/lib/ruby_ui/data_table/data_table_per_page_select.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,21 @@ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [5,
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
form(**mix(attrs, form_attrs)) do
label(for: @id, class: "text-sm font-medium") { plain @label }
render RubyUI::NativeSelect.new(id: @id, name: @name, size: :sm, 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 }
# Same parts as shadcn's dashboard-01 block: SelectTrigger size=sm w-20
# labelled by id, and the list opening above it.
Select do
SelectInput(name: @name, value: @value&.to_s, data: input_data)
SelectTrigger(id: @id, size: :sm, class: "w-20") do
SelectValue(placeholder: @value&.to_s) { @value&.to_s }
end
SelectContent(side: :top) do
SelectGroup do
@options.each do |opt|
SelectItem(value: opt.to_s) { plain opt.to_s }
end
end
end
end
# Keep search/sort when the page size changes; page is left out on
Expand All @@ -41,6 +49,15 @@ def view_template

private

# Select's controller fires change on SelectInput when an item is picked;
# submitting from there keeps working under a CSP without 'unsafe-inline'.
def input_data
{
controller: "ruby-ui--data-table-per-page-select",
action: "change->ruby-ui--data-table-per-page-select#submit"
}
end

def default_attrs
{class: "flex items-center gap-2"}
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
submit() {
this.element.form.requestSubmit();
}
}
5 changes: 4 additions & 1 deletion gem/lib/ruby_ui/data_table/data_table_toolbar.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ def view_template(&)
private

def default_attrs
{class: "flex items-center justify-between gap-2"}
# mb-6 overrides DataTable's space-y-4 (zero specificity) below the
# toolbar: the dashboard-01 block puts 24px (gap-6) between the toolbar
# and the table, 16px between the table and the pagination.
{class: "mb-6 flex items-center justify-between gap-2"}
end
end
end
6 changes: 6 additions & 0 deletions gem/test/ruby_ui/data_table_column_toggle_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,10 @@ def test_trigger_icons_carry_data_icon
assert_match(/<svg[^>]*data-icon="inline-start"/, output)
assert_match(/<svg[^>]*data-icon="inline-end"/, output)
end

def test_wrappers_stay_out_of_layout
output = phlex { RubyUI.DataTableColumnToggle(columns: [{key: :email, label: "Email"}]) }
assert_match(/<div class="contents" data-controller="ruby-ui--data-table-column-visibility"/, output)
assert_match(/<div[^>]*class="contents"[^>]*data-slot="dropdown-menu"|<div[^>]*data-slot="dropdown-menu"[^>]*class="contents"/, output)
end
end
79 changes: 63 additions & 16 deletions gem/test/ruby_ui/data_table_per_page_select_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,90 @@
require "test_helper"

class RubyUI::DataTablePerPageSelectTest < ComponentTest
def test_renders_get_form_with_select_and_options
ATTRS = %q{(?:\s[^\s=>]+(?:="[^"]*")?)*}

def test_renders_get_form_with_select_and_value
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", value: 25, options: [5, 10, 25, 50]) }
assert_match(/<form[^>]*(method="get"[^>]*action="\/x"|action="\/x"[^>]*method="get")/, output)
assert_match(/name="per_page"/, output)
assert_match(/value="25"[^>]*selected|selected[^>]*value="25"/, output)
assert_match(/onchange="this\.form\.requestSubmit\(\)"/, output)
assert_match(/<form#{ATTRS}\s(method="get"#{ATTRS}\saction="\/x"|action="\/x"#{ATTRS}\smethod="get")/o, output)
assert_match(/<input#{ATTRS}\sname="per_page" value="25"/o, output)
assert_match(/data-slot="select-value"#{ATTRS}>25<\/span>/o, output)
end

def test_matches_shadcn_block_parts
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x") }
trigger = output[/<button#{ATTRS}\sdata-slot="select-trigger"#{ATTRS}>/o]
assert_includes trigger, 'data-size="sm"'
assert_match(/class="[^"]*\bw-20\b/, trigger)
refute_match(/\bw-fit\b/, trigger)
assert_match(/data-slot="select-content"#{ATTRS}\sdata-side="top"/o, output)
refute_match(/<select\b/, output)
end

def test_submits_on_change_through_stimulus_without_inline_handler
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x") }
input_tag = output[/<input#{ATTRS}\sname="per_page"#{ATTRS}>/o]
assert_includes input_tag, 'data-controller="ruby-ui--data-table-per-page-select"'
assert_match(/data-action="[^"]*change->ruby-ui--data-table-per-page-select#submit"/, input_tag)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
refute_match(/onchange/, output)
end

def test_keeps_select_input_targets_and_actions
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x") }
input_tag = output[/<input#{ATTRS}\sname="per_page"#{ATTRS}>/o]
assert_includes input_tag, 'data-ruby-ui--select-target="input"'
actions = input_tag[/data-action="([^"]*)"/, 1].split
assert_includes actions, "change->ruby-ui--form-field#onChange"
assert_includes actions, "invalid->ruby-ui--form-field#onInvalid"
assert_includes actions, "invalid->ruby-ui--select#focusTrigger"
assert_includes actions, "change->ruby-ui--data-table-per-page-select#submit"
end

def test_targets_turbo_frame_from_the_form
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", frame_id: "employees") }
assert_match(/<form#{ATTRS}\sdata-turbo-frame="employees"/o, output)
end

def test_keeps_caller_nested_data_on_the_form
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", data: {controller: "filters", turbo_action: "advance"}) }
form_tag = output[/<form#{ATTRS}>/o]
assert_equal ['data-controller="filters"'], form_tag.scan(/data-controller="[^"]*"/)
assert_match(/data-turbo-action="advance"/, form_tag)
end

def test_keeps_caller_flat_data_attributes_on_the_form
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", data_controller: "filters") }
form_tag = output[/<form#{ATTRS}>/o]
assert_equal ['data-controller="filters"'], form_tag.scan(/data-controller="[^"]*"/)
end

def test_renames_param_via_name
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", name: "size") }
assert_match(/name="size"/, output)
assert_match(/<input#{ATTRS}\sname="size"/o, output)
end

def test_includes_given_options
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", options: [5, 10, 25]) }
assert_match(/<option[^>]*value="5"/, output)
assert_match(/<option[^>]*value="10"/, output)
assert_match(/<option[^>]*value="25"/, output)
assert_equal %w[5 10 25], output.scan(/role="option"#{ATTRS}\sdata-value="([^"]+)"/o).flatten
end

def test_labels_the_select
def test_labels_the_trigger
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x") }
id = output[/<select [^<]*id="([^"]+)"/, 1]
id = output[/<button#{ATTRS}\sid="([^"]+)"/o, 1]
refute_nil id
assert_match(/<label for="#{id}"[^>]*>Rows per page<\/label>/, output)
assert_match(/<label for="#{id}"#{ATTRS}>Rows per page<\/label>/, output)
end

def test_default_ids_are_unique_per_instance
first = phlex { RubyUI.DataTablePerPageSelect(path: "/a") }[/<select [^<]*id="([^"]+)"/, 1]
second = phlex { RubyUI.DataTablePerPageSelect(path: "/b") }[/<select [^<]*id="([^"]+)"/, 1]
first = phlex { RubyUI.DataTablePerPageSelect(path: "/a") }[/<button#{ATTRS}\sid="([^"]+)"/o, 1]
second = phlex { RubyUI.DataTablePerPageSelect(path: "/b") }[/<button#{ATTRS}\sid="([^"]+)"/o, 1]
refute_nil first
refute_equal first, second
end

def test_custom_label_and_id
output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", label: "Por página", id: "pp") }
assert_match(/<label for="pp"[^>]*>Por página<\/label>/, output)
assert_match(/<select [^<]*id="pp"/, output)
assert_match(/<label for="pp"#{ATTRS}>Por página<\/label>/o, output)
assert_match(/<button#{ATTRS}\sid="pp"/o, output)
end

def test_preserved_params_emit_hidden_inputs_skipping_blank_and_own_name
Expand Down
5 changes: 5 additions & 0 deletions gem/test/ruby_ui/data_table_toolbar_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,9 @@ def test_renders_div_with_flex_layout_and_children
assert_match(/<div[^>]*class="[^"]*flex[^"]*"/, output)
assert_match(/INNER/, output)
end

def test_leaves_24px_before_the_table
output = phlex { RubyUI.DataTableToolbar { "INNER" } }
assert_match(/<div[^>]*class="[^"]*\bmb-6\b[^"]*"/, output)
end
end
Loading
Loading