From f28cc80e2fa2c3e1edf285359257a6f224ea624e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Mendon=C3=A7a?= Date: Tue, 27 Oct 2026 17:56:45 -0300 Subject: [PATCH 1/8] Submit DataTablePerPageSelect through Stimulus instead of an inline onchange Co-Authored-By: Claude Opus 5.5 --- docs/app/javascript/controllers/index.js | 3 +++ .../data_table_per_page_select_controller.js | 1 + .../data_table/data_table_per_page_select.rb | 13 ++++++++---- .../data_table_per_page_select_controller.js | 7 +++++++ .../data_table_per_page_select_test.rb | 20 ++++++++++++++++++- 5 files changed, 39 insertions(+), 5 deletions(-) create mode 120000 docs/app/javascript/controllers/ruby_ui/data_table_per_page_select_controller.js create mode 100644 gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index fb037ba7a..68745cb48 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -58,6 +58,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) diff --git a/docs/app/javascript/controllers/ruby_ui/data_table_per_page_select_controller.js b/docs/app/javascript/controllers/ruby_ui/data_table_per_page_select_controller.js new file mode 120000 index 000000000..6b48f3d81 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/data_table_per_page_select_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js \ No newline at end of file diff --git a/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb b/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb index d3c88f8b3..985bfaa88 100644 --- a/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb +++ b/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb @@ -12,11 +12,10 @@ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [5, end def view_template - form_attrs = {action: @path, method: "get"} - form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id + form_attrs = {action: @path, method: "get", data: form_data} - form(**attrs.merge(form_attrs)) do - render RubyUI::NativeSelect.new(name: @name, onchange: safe("this.form.requestSubmit()")) do + form(**mix(attrs, form_attrs)) do + render RubyUI::NativeSelect.new(name: @name, data: {action: "change->ruby-ui--data-table-per-page-select#submit"}) do @options.each do |opt| option_attrs = {value: opt.to_s} option_attrs[:selected] = true if opt.to_s == @value.to_s @@ -28,6 +27,12 @@ def view_template private + def form_data + base = {controller: "ruby-ui--data-table-per-page-select"} + base[:turbo_frame] = @frame_id if @frame_id + base + end + def default_attrs {} end diff --git a/gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js b/gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js new file mode 100644 index 000000000..e4dd87243 --- /dev/null +++ b/gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + submit() { + this.element.requestSubmit(); + } +} diff --git a/gem/test/ruby_ui/data_table_per_page_select_test.rb b/gem/test/ruby_ui/data_table_per_page_select_test.rb index dff847477..b88ef7ae1 100644 --- a/gem/test/ruby_ui/data_table_per_page_select_test.rb +++ b/gem/test/ruby_ui/data_table_per_page_select_test.rb @@ -8,7 +8,25 @@ def test_renders_get_form_with_select_and_options assert_match(/]*(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) + end + + def test_submits_on_change_through_stimulus_without_inline_handler + output = phlex { RubyUI.DataTablePerPageSelect(path: "/x") } + assert_match(/]*data-controller="ruby-ui--data-table-per-page-select"/, output) + assert_match(/]*data-action="[^"]*change->ruby-ui--data-table-per-page-select#submit"/, output) + refute_match(/onchange/, output) + end + + def test_keeps_controller_alongside_turbo_frame + output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", frame_id: "employees") } + assert_match(/data-turbo-frame="employees"/, output) + assert_match(/data-controller="ruby-ui--data-table-per-page-select"/, output) + end + + def test_keeps_controller_alongside_caller_data + output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", data: {controller: "filters", turbo_action: "advance"}) } + assert_match(/data-controller="filters ruby-ui--data-table-per-page-select"/, output) + assert_match(/data-turbo-action="advance"/, output) end def test_renames_param_via_name From 14ccb4991df39a57c3417a6316bdac9d3194bf91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Mendon=C3=A7a?= Date: Fri, 30 Oct 2026 12:39:19 -0300 Subject: [PATCH 2/8] Attach the per-page select controller to the select so caller form data is left untouched Co-Authored-By: Claude Opus 5.5 --- .../data_table/data_table_per_page_select.rb | 14 ++++++----- .../data_table_per_page_select_controller.js | 2 +- .../data_table_per_page_select_test.rb | 23 ++++++++++++------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb b/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb index 985bfaa88..74e1442d4 100644 --- a/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb +++ b/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb @@ -12,10 +12,11 @@ def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [5, end def view_template - form_attrs = {action: @path, method: "get", data: form_data} + form_attrs = {action: @path, method: "get"} + form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id form(**mix(attrs, form_attrs)) do - render RubyUI::NativeSelect.new(name: @name, data: {action: "change->ruby-ui--data-table-per-page-select#submit"}) do + render RubyUI::NativeSelect.new(name: @name, data: select_data) do @options.each do |opt| option_attrs = {value: opt.to_s} option_attrs[:selected] = true if opt.to_s == @value.to_s @@ -27,10 +28,11 @@ def view_template private - def form_data - base = {controller: "ruby-ui--data-table-per-page-select"} - base[:turbo_frame] = @frame_id if @frame_id - base + def select_data + { + controller: "ruby-ui--data-table-per-page-select", + action: "change->ruby-ui--data-table-per-page-select#submit" + } end def default_attrs diff --git a/gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js b/gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js index e4dd87243..3529493e9 100644 --- a/gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js +++ b/gem/lib/ruby_ui/data_table/data_table_per_page_select_controller.js @@ -2,6 +2,6 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { submit() { - this.element.requestSubmit(); + this.element.form.requestSubmit(); } } diff --git a/gem/test/ruby_ui/data_table_per_page_select_test.rb b/gem/test/ruby_ui/data_table_per_page_select_test.rb index b88ef7ae1..26ecd1010 100644 --- a/gem/test/ruby_ui/data_table_per_page_select_test.rb +++ b/gem/test/ruby_ui/data_table_per_page_select_test.rb @@ -12,21 +12,28 @@ def test_renders_get_form_with_select_and_options def test_submits_on_change_through_stimulus_without_inline_handler output = phlex { RubyUI.DataTablePerPageSelect(path: "/x") } - assert_match(/]*data-controller="ruby-ui--data-table-per-page-select"/, output) - assert_match(/]*data-action="[^"]*change->ruby-ui--data-table-per-page-select#submit"/, output) + select_tag = output[/ruby-ui--data-table-per-page-select#submit"/, select_tag) refute_match(/onchange/, output) end - def test_keeps_controller_alongside_turbo_frame + def test_targets_turbo_frame_from_the_form output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", frame_id: "employees") } - assert_match(/data-turbo-frame="employees"/, output) - assert_match(/data-controller="ruby-ui--data-table-per-page-select"/, output) + assert_match(/]*data-turbo-frame="employees"/, output) end - def test_keeps_controller_alongside_caller_data + def test_keeps_caller_nested_data_on_the_form output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", data: {controller: "filters", turbo_action: "advance"}) } - assert_match(/data-controller="filters ruby-ui--data-table-per-page-select"/, output) - assert_match(/data-turbo-action="advance"/, output) + form_tag = output[/]*>/] + 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[/]*>/] + assert_equal ['data-controller="filters"'], form_tag.scan(/data-controller="[^"]*"/) end def test_renames_param_via_name From d008a9701ec7839ddfeb4f5fa2f93c956ff4e012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Mendon=C3=A7a?= Date: Fri, 30 Oct 2026 12:42:22 -0300 Subject: [PATCH 3/8] Regenerate the MCP registry with the per-page select controller Co-Authored-By: Claude Opus 5.5 --- mcp/data/registry.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mcp/data/registry.json b/mcp/data/registry.json index c5505d648..995ac01be 100644 --- a/mcp/data/registry.json +++ b/mcp/data/registry.json @@ -1222,7 +1222,11 @@ }, { "path": "data_table_per_page_select.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTablePerPageSelect < Base\n def initialize(path:, name: \"per_page\", value: nil, frame_id: nil, options: [5, 10, 25, 50], **attrs)\n @path = path\n @name = name\n @value = value\n @frame_id = frame_id\n @options = options\n super(**attrs)\n end\n\n def view_template\n form_attrs = {action: @path, method: \"get\"}\n form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id\n\n form(**attrs.merge(form_attrs)) do\n render RubyUI::NativeSelect.new(name: @name, onchange: safe(\"this.form.requestSubmit()\")) do\n @options.each do |opt|\n option_attrs = {value: opt.to_s}\n option_attrs[:selected] = true if opt.to_s == @value.to_s\n option(**option_attrs) { plain opt.to_s }\n end\n end\n end\n end\n\n private\n\n def default_attrs\n {}\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTablePerPageSelect < Base\n def initialize(path:, name: \"per_page\", value: nil, frame_id: nil, options: [5, 10, 25, 50], **attrs)\n @path = path\n @name = name\n @value = value\n @frame_id = frame_id\n @options = options\n super(**attrs)\n end\n\n def view_template\n form_attrs = {action: @path, method: \"get\"}\n form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id\n\n form(**mix(attrs, form_attrs)) do\n render RubyUI::NativeSelect.new(name: @name, data: select_data) do\n @options.each do |opt|\n option_attrs = {value: opt.to_s}\n option_attrs[:selected] = true if opt.to_s == @value.to_s\n option(**option_attrs) { plain opt.to_s }\n end\n end\n end\n end\n\n private\n\n def select_data\n {\n controller: \"ruby-ui--data-table-per-page-select\",\n action: \"change->ruby-ui--data-table-per-page-select#submit\"\n }\n end\n\n def default_attrs\n {}\n end\n end\nend\n" + }, + { + "path": "data_table_per_page_select_controller.js", + "content": "import { Controller } from \"@hotwired/stimulus\";\n\nexport default class extends Controller {\n submit() {\n this.element.form.requestSubmit();\n }\n}\n" }, { "path": "data_table_row_checkbox.rb", From 89ebd10a9694a84905f49b7a2680ac07eb24c4d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean=20Mendon=C3=A7a?= Date: Thu, 5 Nov 2026 13:30:35 -0300 Subject: [PATCH 4/8] Render DataTablePerPageSelect with Select, as shadcn's data table block does --- audit/component_map/data_table.yml | 2 +- .../data_table/data_table_per_page_select.rb | 22 +++++--- .../data_table_per_page_select_test.rb | 53 +++++++++++-------- mcp/data/registry/components/data_table.json | 2 +- 4 files changed, 50 insertions(+), 29 deletions(-) diff --git a/audit/component_map/data_table.yml b/audit/component_map/data_table.yml index 6eca926dc..f0cc851b6 100644 --- a/audit/component_map/data_table.yml +++ b/audit/component_map/data_table.yml @@ -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"]' diff --git a/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb b/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb index df21a2d17..9dbf22e4d 100644 --- a/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb +++ b/gem/lib/ruby_ui/data_table/data_table_per_page_select.rb @@ -22,11 +22,19 @@ def view_template form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id 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, data: select_data) 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 @@ -41,7 +49,9 @@ def view_template private - def select_data + # 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" diff --git a/gem/test/ruby_ui/data_table_per_page_select_test.rb b/gem/test/ruby_ui/data_table_per_page_select_test.rb index aee323779..fa757a659 100644 --- a/gem/test/ruby_ui/data_table_per_page_select_test.rb +++ b/gem/test/ruby_ui/data_table_per_page_select_test.rb @@ -3,68 +3,79 @@ 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(/]*(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(/25<\/span>/o, output) + end + + def test_matches_shadcn_block_parts + output = phlex { RubyUI.DataTablePerPageSelect(path: "/x") } + trigger = output[//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(/ruby-ui--data-table-per-page-select#submit"/, select_tag) + input_tag = output[//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) refute_match(/onchange/, output) end def test_targets_turbo_frame_from_the_form output = phlex { RubyUI.DataTablePerPageSelect(path: "/x", frame_id: "employees") } - assert_match(/]*data-turbo-frame="employees"/, output) + assert_match(/]*>/] + form_tag = output[//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_tag = output[//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(/]*value="5"/, output) - assert_match(/]*value="10"/, output) - assert_match(/]*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[/]*>Por página<\/label>/, output) - assert_match(/ with no children; as a flex item of the cell's div.flex it computes display: block" + shadcn: "Base UI Checkbox is a 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 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" diff --git a/audit/fixtures/data_table/rubyui.rb b/audit/fixtures/data_table/rubyui.rb index 40a07c810..49754118b 100644 --- a/audit/fixtures/data_table/rubyui.rb +++ b/audit/fixtures/data_table/rubyui.rb @@ -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 @@ -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 diff --git a/audit/fixtures/data_table/shadcn.tsx b/audit/fixtures/data_table/shadcn.tsx index 28c6f4665..52592af3f 100644 --- a/audit/fixtures/data_table/shadcn.tsx +++ b/audit/fixtures/data_table/shadcn.tsx @@ -4,7 +4,9 @@ import { ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, + CircleCheckIcon, Columns3Icon, + LoaderIcon, } from "lucide-react" import { useState } from "react" @@ -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" }, @@ -125,6 +128,11 @@ export default function DataTableFixture() { {visible.Status && ( + {row.status === "Done" ? ( + + ) : ( + + )} {row.status} diff --git a/gem/lib/ruby_ui/data_table/data_table_column_toggle.rb b/gem/lib/ruby_ui/data_table/data_table_column_toggle.rb index 340a2d9fe..b3edf4900 100644 --- a/gem/lib/ruby_ui/data_table/data_table_column_toggle.rb +++ b/gem/lib/ruby_ui/data_table/data_table_column_toggle.rb @@ -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 @@ -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" diff --git a/gem/lib/ruby_ui/data_table/data_table_toolbar.rb b/gem/lib/ruby_ui/data_table/data_table_toolbar.rb index e94867a2a..76099ed65 100644 --- a/gem/lib/ruby_ui/data_table/data_table_toolbar.rb +++ b/gem/lib/ruby_ui/data_table/data_table_toolbar.rb @@ -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 diff --git a/gem/test/ruby_ui/data_table_column_toggle_test.rb b/gem/test/ruby_ui/data_table_column_toggle_test.rb index cc990be5a..fd4c05206 100644 --- a/gem/test/ruby_ui/data_table_column_toggle_test.rb +++ b/gem/test/ruby_ui/data_table_column_toggle_test.rb @@ -44,4 +44,10 @@ def test_trigger_icons_carry_data_icon assert_match(/]*data-icon="inline-start"/, output) assert_match(/]*data-icon="inline-end"/, output) end + + def test_wrappers_stay_out_of_layout + output = phlex { RubyUI.DataTableColumnToggle(columns: [{key: :email, label: "Email"}]) } + assert_match(/
]*class="contents"[^>]*data-slot="dropdown-menu"|]*data-slot="dropdown-menu"[^>]*class="contents"/, output) + end end diff --git a/gem/test/ruby_ui/data_table_toolbar_test.rb b/gem/test/ruby_ui/data_table_toolbar_test.rb index e78b9f9d9..17ae288f5 100644 --- a/gem/test/ruby_ui/data_table_toolbar_test.rb +++ b/gem/test/ruby_ui/data_table_toolbar_test.rb @@ -8,4 +8,9 @@ def test_renders_div_with_flex_layout_and_children assert_match(/]*class="[^"]*flex[^"]*"/, output) assert_match(/INNER/, output) end + + def test_leaves_24px_before_the_table + output = phlex { RubyUI.DataTableToolbar { "INNER" } } + assert_match(/]*class="[^"]*\bmb-6\b[^"]*"/, output) + end end diff --git a/mcp/data/registry/components/data_table.json b/mcp/data/registry/components/data_table.json index d763f01d6..861359fde 100644 --- a/mcp/data/registry/components/data_table.json +++ b/mcp/data/registry/components/data_table.json @@ -12,7 +12,7 @@ }, { "path": "data_table_column_toggle.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableColumnToggle < Base\n def initialize(columns:, label: \"Columns\", **attrs)\n @columns = columns\n @label = label\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n render RubyUI::DropdownMenu.new do\n render RubyUI::DropdownMenuTrigger.new do\n render RubyUI::Button.new(variant: :outline, size: :sm) do\n columns_icon\n plain @label\n chevron_down_icon\n end\n end\n render RubyUI::DropdownMenuContent.new(align: :end, class: \"w-32\") do\n @columns.each do |col|\n render RubyUI::DropdownMenuCheckboxItem.new(\n checked: col.fetch(:visible, true),\n class: \"capitalize\",\n data: {column_key: col[:key].to_s}\n ) { plain col[:label] }\n end\n end\n end\n end\n end\n\n private\n\n # lucide columns-3, as shadcn's trigger (data-icon tightens the button's padding)\n def columns_icon\n svg(xmlns: \"http://www.w3.org/2000/svg\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\",\n stroke_width: \"2\", stroke_linecap: \"round\", stroke_linejoin: \"round\", aria_hidden: \"true\",\n data: {icon: \"inline-start\"}) do |s|\n s.rect(width: \"18\", height: \"18\", x: \"3\", y: \"3\", rx: \"2\")\n s.path(d: \"M9 3v18\")\n s.path(d: \"M15 3v18\")\n end\n end\n\n # lucide chevron-down\n def chevron_down_icon\n svg(xmlns: \"http://www.w3.org/2000/svg\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\",\n stroke_width: \"2\", stroke_linecap: \"round\", stroke_linejoin: \"round\", aria_hidden: \"true\",\n data: {icon: \"inline-end\"}) do |s|\n s.path(d: \"m6 9 6 6 6-6\")\n end\n end\n\n def default_attrs\n {\n class: \"relative\",\n data: {\n controller: \"ruby-ui--data-table-column-visibility\",\n action: \"ruby-ui--dropdown-menu:change->ruby-ui--data-table-column-visibility#toggle\"\n }\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableColumnToggle < Base\n def initialize(columns:, label: \"Columns\", **attrs)\n @columns = columns\n @label = label\n super(**attrs)\n end\n\n def view_template\n div(**attrs) do\n # Both wrappers are display: contents so the button is a flex item of\n # the toolbar, as shadcn's trigger (inline-flex blockifies to flex).\n render RubyUI::DropdownMenu.new(class: \"contents\") do\n render RubyUI::DropdownMenuTrigger.new do\n render RubyUI::Button.new(variant: :outline, size: :sm) do\n columns_icon\n plain @label\n chevron_down_icon\n end\n end\n render RubyUI::DropdownMenuContent.new(align: :end, class: \"w-32\") do\n @columns.each do |col|\n render RubyUI::DropdownMenuCheckboxItem.new(\n checked: col.fetch(:visible, true),\n class: \"capitalize\",\n data: {column_key: col[:key].to_s}\n ) { plain col[:label] }\n end\n end\n end\n end\n end\n\n private\n\n # lucide columns-3, as shadcn's trigger (data-icon tightens the button's padding)\n def columns_icon\n svg(xmlns: \"http://www.w3.org/2000/svg\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\",\n stroke_width: \"2\", stroke_linecap: \"round\", stroke_linejoin: \"round\", aria_hidden: \"true\",\n data: {icon: \"inline-start\"}) do |s|\n s.rect(width: \"18\", height: \"18\", x: \"3\", y: \"3\", rx: \"2\")\n s.path(d: \"M9 3v18\")\n s.path(d: \"M15 3v18\")\n end\n end\n\n # lucide chevron-down\n def chevron_down_icon\n svg(xmlns: \"http://www.w3.org/2000/svg\", viewBox: \"0 0 24 24\", fill: \"none\", stroke: \"currentColor\",\n stroke_width: \"2\", stroke_linecap: \"round\", stroke_linejoin: \"round\", aria_hidden: \"true\",\n data: {icon: \"inline-end\"}) do |s|\n s.path(d: \"m6 9 6 6 6-6\")\n end\n end\n\n def default_attrs\n {\n class: \"contents\",\n data: {\n controller: \"ruby-ui--data-table-column-visibility\",\n action: \"ruby-ui--dropdown-menu:change->ruby-ui--data-table-column-visibility#toggle\"\n }\n }\n end\n end\nend\n" }, { "path": "data_table_column_visibility_controller.js", @@ -84,7 +84,7 @@ }, { "path": "data_table_toolbar.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableToolbar < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n {class: \"flex items-center justify-between gap-2\"}\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class DataTableToolbar < Base\n def view_template(&)\n div(**attrs, &)\n end\n\n private\n\n def default_attrs\n # mb-6 overrides DataTable's space-y-4 (zero specificity) below the\n # toolbar: the dashboard-01 block puts 24px (gap-6) between the toolbar\n # and the table, 16px between the table and the pagination.\n {class: \"mb-6 flex items-center justify-between gap-2\"}\n end\n end\nend\n" } ], "dependencies": {