diff --git a/audit/component_map/table.yml b/audit/component_map/table.yml new file mode 100644 index 000000000..9e3e60433 --- /dev/null +++ b/audit/component_map/table.yml @@ -0,0 +1,27 @@ +shadcn: table +shadcn_sha: 8a3536678a528cc012b9e62484290e79d0be410f +kind: static +docs_view: table +# RubyUI::Table renders the scroll container
(data-slot="table-container") +# around the , as shadcn's Table does. +parts: + Table: table + TableContainer: table-container + TableHeader: table-header + TableBody: table-body + TableFooter: table-footer + TableRow: table-row + TableHead: table-head + TableCell: table-cell + TableCaption: table-caption +selectors: + TableContainer: '[data-slot="table-container"]' + Table: '[data-slot="table"]' + TableHeader: '[data-slot="table-header"]' + TableBody: '[data-slot="table-body"]' + TableFooter: '[data-slot="table-footer"]' + TableRow: '[data-slot="table-row"]' + TableHead: '[data-slot="table-head"]' + TableCell: '[data-slot="table-cell"]' + TableCaption: '[data-slot="table-caption"]' +props: {} diff --git a/audit/fixtures/table/content.yml b/audit/fixtures/table/content.yml new file mode 100644 index 000000000..f190bc6cd --- /dev/null +++ b/audit/fixtures/table/content.yml @@ -0,0 +1,23 @@ +# The shadcn Base UI "With Footer" table example (bases/base/examples/table-example.tsx): +# caption, header, three body rows, footer. The second body row is selected +# (data-state="selected") and carries data-audit-trigger (static kind: only the +# closed state is collected). +caption: A list of your recent invoices. +head_1: Invoice +head_2: Status +head_3: Method +head_4: Amount +row_1_invoice: INV001 +row_1_status: Paid +row_1_method: Credit Card +row_1_amount: $250.00 +row_2_invoice: INV002 +row_2_status: Pending +row_2_method: PayPal +row_2_amount: $150.00 +row_3_invoice: INV003 +row_3_status: Unpaid +row_3_method: Bank Transfer +row_3_amount: $350.00 +footer_label: Total +footer_amount: $2,500.00 diff --git a/audit/fixtures/table/rubyui.rb b/audit/fixtures/table/rubyui.rb new file mode 100644 index 000000000..c5fc651cc --- /dev/null +++ b/audit/fixtures/table/rubyui.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module ShadcnAuditFixtures + # RubyUI side of audit/fixtures/table (strings in content.yml). + class Table < Phlex::HTML + include RubyUI + + INVOICES = [ + ["INV001", "Paid", "Credit Card", "$250.00"], + ["INV002", "Pending", "PayPal", "$150.00"], + ["INV003", "Unpaid", "Bank Transfer", "$350.00"] + ].freeze + + def view_template + div(class: "w-[640px]") do + Table do + TableCaption { "A list of your recent invoices." } + TableHeader do + TableRow do + TableHead(class: "w-[100px]") { "Invoice" } + TableHead { "Status" } + TableHead { "Method" } + TableHead(class: "text-right") { "Amount" } + end + end + TableBody do + INVOICES.each_with_index do |(invoice, status, method, amount), i| + row_attrs = (i == 1) ? {data: {state: "selected", audit_trigger: true}} : {} + TableRow(**row_attrs) do + TableCell(class: "font-medium") { invoice } + TableCell { status } + TableCell { method } + TableCell(class: "text-right") { amount } + end + end + end + TableFooter do + TableRow do + TableCell(colspan: 3) { "Total" } + TableCell(class: "text-right") { "$2,500.00" } + end + end + end + end + end + end +end diff --git a/audit/fixtures/table/shadcn.tsx b/audit/fixtures/table/shadcn.tsx new file mode 100644 index 000000000..c9f35505a --- /dev/null +++ b/audit/fixtures/table/shadcn.tsx @@ -0,0 +1,53 @@ +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +const invoices = [ + { invoice: "INV001", status: "Paid", method: "Credit Card", amount: "$250.00" }, + { invoice: "INV002", status: "Pending", method: "PayPal", amount: "$150.00" }, + { invoice: "INV003", status: "Unpaid", method: "Bank Transfer", amount: "$350.00" }, +] + +export default function TableFixture() { + return ( +
+
+ A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + {invoices.map((row, i) => ( + + {row.invoice} + {row.status} + {row.method} + {row.amount} + + ))} + + + + Total + $2,500.00 + + +
+
+ ) +} diff --git a/docs/app/views/docs/table.rb b/docs/app/views/docs/table.rb index ad0d22c74..405c38130 100644 --- a/docs/app/views/docs/table.rb +++ b/docs/app/views/docs/table.rb @@ -2,33 +2,32 @@ class Views::Docs::Table < Views::Base Invoice = Struct.new(:identifier, :status, :method, :amount) - User = Struct.new(:avatar_url, :name, :username, :commits, :github_url) def view_template component = "Table" - div(class: "max-w-2xl mx-auto w-full py-10 space-y-8") do + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new(title: "Table", description: "A responsive table component.") Heading(level: 2) { "Usage" } - render Docs::VisualCodeExample.new(title: "Without builder", context: self) do + render Docs::VisualCodeExample.new(title: "With footer", description: "Cells don't wrap: when the table is wider than its container, the container scrolls horizontally.", context: self) do <<~RUBY Table do - TableCaption { "Employees at Acme inc." } + TableCaption { "A list of your recent invoices." } TableHeader do TableRow do - TableHead { "Name" } - TableHead { "Email" } + TableHead(class: "w-[100px]") { "Invoice" } TableHead { "Status" } - TableHead(class: "text-right") { "Role" } + TableHead { "Method" } + TableHead(class: "text-right") { "Amount" } end end TableBody do invoices.each do |invoice| TableRow do - TableCell(class: 'font-medium') { invoice.identifier } - TableCell { render_status_badge(invoice.status) } + TableCell(class: "font-medium") { invoice.identifier } + TableCell { invoice.status } TableCell { invoice.method } TableCell(class: "text-right") { format_amount(invoice.amount) } end @@ -36,8 +35,69 @@ def view_template end TableFooter do TableRow do - TableHead(class: "font-medium", colspan: 3) { "Total" } - TableHead(class: "font-medium text-right") { format_amount(invoices.sum(&:amount)) } + TableCell(colspan: 3) { "Total" } + TableCell(class: "text-right") { format_amount(invoices.sum(&:amount)) } + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With badges", context: self) do + <<~RUBY + Table do + TableHeader do + TableRow do + TableHead { "Invoice" } + TableHead { "Status" } + TableHead(class: "text-right") { "Amount" } + end + end + TableBody do + invoices.each do |invoice| + TableRow do + TableCell(class: "font-medium") { invoice.identifier } + TableCell { render_status_badge(invoice.status) } + TableCell(class: "text-right") { format_amount(invoice.amount) } + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With actions", description: "A row stays highlighted while a menu inside it is open (anything in it with aria-expanded=true).", context: self) do + <<~RUBY + Table do + TableHeader do + TableRow do + TableHead { "Product" } + TableHead { "Price" } + TableHead(class: "text-right") { "Actions" } + end + end + TableBody do + [["Wireless Mouse", "$29.99"], ["Mechanical Keyboard", "$129.99"], ["USB-C Hub", "$49.99"]].each do |product, price| + TableRow do + TableCell(class: "font-medium") { product } + TableCell { price } + TableCell(class: "text-right") do + DropdownMenu do + DropdownMenuTrigger do + Button(variant: :ghost, size: :icon, class: "size-8") do + more_icon + span(class: "sr-only") { "Open menu" } + end + end + DropdownMenuContent(align: :end) do + DropdownMenuItem { "Edit" } + DropdownMenuItem { "Duplicate" } + DropdownMenuSeparator() + DropdownMenuItem(variant: :destructive) { "Delete" } + end + end + end + end end end end @@ -54,49 +114,33 @@ def view_template def invoices [ - Invoice.new(identifier: "INV-0001", status: "Active", method: "Credit Card", amount: 100), - Invoice.new(identifier: "INV-0002", status: "Active", method: "Bank Transfer", amount: 230), - Invoice.new(identifier: "INV-0003", status: "Pending", method: "PayPal", amount: 350), - Invoice.new(identifier: "INV-0004", status: "Inactive", method: "Credit Card", amount: 100) - ] - end - - def users - [ - User.new(avatar_url: "https://avatars.githubusercontent.com/u/246692?v=4", name: "Joel Drapper", username: "joeldrapper", commits: 404), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/33979976?v=4", name: "Alexandre Ruban", username: "alexandreruban", commits: 16), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/77887?v=4", name: "Will Cosgrove", username: "willcosgrove", commits: 12), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/3025661?v=4", name: "Stephann V.", username: "stephannv", commits: 8), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/6411752?v=4", name: "Marco Roth", username: "marcoroth", commits: 8) + Invoice.new(identifier: "INV001", status: "Paid", method: "Credit Card", amount: 250), + Invoice.new(identifier: "INV002", status: "Pending", method: "PayPal", amount: 150), + Invoice.new(identifier: "INV003", status: "Unpaid", method: "Bank Transfer", amount: 350) ] end - def github_link(user) - "https://github.com/#{user.username}" - end - - def github_icon - svg(viewbox: "0 0 438.549 438.549", class: "h-4 w-4") do |s| - s.path( - fill: "currentColor", - d: - "M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z" - ) - end - end - def format_amount(amount) "$#{amount}.00" end def render_status_badge(status) - case status.downcase - when "active" + case status + when "Paid" Badge(size: :sm, class: "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300") { status } - when "inactive" - Badge(variant: :destructive, size: :sm) { status } - when "pending" + when "Pending" Badge(size: :sm, class: "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300") { status } + else + Badge(variant: :destructive, size: :sm) { status } + end + end + + def more_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| + s.circle(cx: "12", cy: "12", r: "1") + s.circle(cx: "19", cy: "12", r: "1") + s.circle(cx: "5", cy: "12", r: "1") end end end diff --git a/gem/lib/ruby_ui/table/table.rb b/gem/lib/ruby_ui/table/table.rb index d3f2c3730..08fd9880d 100644 --- a/gem/lib/ruby_ui/table/table.rb +++ b/gem/lib/ruby_ui/table/table.rb @@ -1,9 +1,14 @@ # frozen_string_literal: true module RubyUI + # A native inside a horizontally scrolling container, like shadcn's + # Table: wide tables scroll instead of wrapping their cells. Attributes go + # to the
; the wrapper is data-slot="table-container". class Table < Base + include TableSlot + def view_template(&block) - div(class: "relative w-full overflow-auto") do + div(data: {slot: "table-container"}, class: "relative w-full overflow-x-auto") do table(**attrs, &block) end end @@ -12,6 +17,7 @@ def view_template(&block) def default_attrs { + data: {slot: default_slot("table")}.compact, class: "w-full caption-bottom text-sm" } end diff --git a/gem/lib/ruby_ui/table/table_body.rb b/gem/lib/ruby_ui/table/table_body.rb index 84891e205..1ebff8dbc 100644 --- a/gem/lib/ruby_ui/table/table_body.rb +++ b/gem/lib/ruby_ui/table/table_body.rb @@ -2,6 +2,8 @@ module RubyUI class TableBody < Base + include TableSlot + def view_template(&) tbody(**attrs, &) end @@ -10,6 +12,7 @@ def view_template(&) def default_attrs { + data: {slot: default_slot("table-body")}.compact, class: "[&_tr:last-child]:border-0" } end diff --git a/gem/lib/ruby_ui/table/table_caption.rb b/gem/lib/ruby_ui/table/table_caption.rb index 21335a8b2..9611a3906 100644 --- a/gem/lib/ruby_ui/table/table_caption.rb +++ b/gem/lib/ruby_ui/table/table_caption.rb @@ -2,6 +2,8 @@ module RubyUI class TableCaption < Base + include TableSlot + def view_template(&) caption(**attrs, &) end @@ -10,6 +12,7 @@ def view_template(&) def default_attrs { + data: {slot: default_slot("table-caption")}.compact, class: "mt-4 text-sm text-muted-foreground" } end diff --git a/gem/lib/ruby_ui/table/table_cell.rb b/gem/lib/ruby_ui/table/table_cell.rb index 6d7e35391..dd7bf6a7f 100644 --- a/gem/lib/ruby_ui/table/table_cell.rb +++ b/gem/lib/ruby_ui/table/table_cell.rb @@ -2,6 +2,8 @@ module RubyUI class TableCell < Base + include TableSlot + def view_template(&) td(**attrs, &) end @@ -10,7 +12,8 @@ def view_template(&) def default_attrs { - class: "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" + data: {slot: default_slot("table-cell")}.compact, + class: "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&:has([data-slot=checkbox])]:pr-0" } end end diff --git a/gem/lib/ruby_ui/table/table_docs.rb b/gem/lib/ruby_ui/table/table_docs.rb index ad0d22c74..405c38130 100644 --- a/gem/lib/ruby_ui/table/table_docs.rb +++ b/gem/lib/ruby_ui/table/table_docs.rb @@ -2,33 +2,32 @@ class Views::Docs::Table < Views::Base Invoice = Struct.new(:identifier, :status, :method, :amount) - User = Struct.new(:avatar_url, :name, :username, :commits, :github_url) def view_template component = "Table" - div(class: "max-w-2xl mx-auto w-full py-10 space-y-8") do + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do render Docs::Header.new(title: "Table", description: "A responsive table component.") Heading(level: 2) { "Usage" } - render Docs::VisualCodeExample.new(title: "Without builder", context: self) do + render Docs::VisualCodeExample.new(title: "With footer", description: "Cells don't wrap: when the table is wider than its container, the container scrolls horizontally.", context: self) do <<~RUBY Table do - TableCaption { "Employees at Acme inc." } + TableCaption { "A list of your recent invoices." } TableHeader do TableRow do - TableHead { "Name" } - TableHead { "Email" } + TableHead(class: "w-[100px]") { "Invoice" } TableHead { "Status" } - TableHead(class: "text-right") { "Role" } + TableHead { "Method" } + TableHead(class: "text-right") { "Amount" } end end TableBody do invoices.each do |invoice| TableRow do - TableCell(class: 'font-medium') { invoice.identifier } - TableCell { render_status_badge(invoice.status) } + TableCell(class: "font-medium") { invoice.identifier } + TableCell { invoice.status } TableCell { invoice.method } TableCell(class: "text-right") { format_amount(invoice.amount) } end @@ -36,8 +35,69 @@ def view_template end TableFooter do TableRow do - TableHead(class: "font-medium", colspan: 3) { "Total" } - TableHead(class: "font-medium text-right") { format_amount(invoices.sum(&:amount)) } + TableCell(colspan: 3) { "Total" } + TableCell(class: "text-right") { format_amount(invoices.sum(&:amount)) } + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With badges", context: self) do + <<~RUBY + Table do + TableHeader do + TableRow do + TableHead { "Invoice" } + TableHead { "Status" } + TableHead(class: "text-right") { "Amount" } + end + end + TableBody do + invoices.each do |invoice| + TableRow do + TableCell(class: "font-medium") { invoice.identifier } + TableCell { render_status_badge(invoice.status) } + TableCell(class: "text-right") { format_amount(invoice.amount) } + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "With actions", description: "A row stays highlighted while a menu inside it is open (anything in it with aria-expanded=true).", context: self) do + <<~RUBY + Table do + TableHeader do + TableRow do + TableHead { "Product" } + TableHead { "Price" } + TableHead(class: "text-right") { "Actions" } + end + end + TableBody do + [["Wireless Mouse", "$29.99"], ["Mechanical Keyboard", "$129.99"], ["USB-C Hub", "$49.99"]].each do |product, price| + TableRow do + TableCell(class: "font-medium") { product } + TableCell { price } + TableCell(class: "text-right") do + DropdownMenu do + DropdownMenuTrigger do + Button(variant: :ghost, size: :icon, class: "size-8") do + more_icon + span(class: "sr-only") { "Open menu" } + end + end + DropdownMenuContent(align: :end) do + DropdownMenuItem { "Edit" } + DropdownMenuItem { "Duplicate" } + DropdownMenuSeparator() + DropdownMenuItem(variant: :destructive) { "Delete" } + end + end + end + end end end end @@ -54,49 +114,33 @@ def view_template def invoices [ - Invoice.new(identifier: "INV-0001", status: "Active", method: "Credit Card", amount: 100), - Invoice.new(identifier: "INV-0002", status: "Active", method: "Bank Transfer", amount: 230), - Invoice.new(identifier: "INV-0003", status: "Pending", method: "PayPal", amount: 350), - Invoice.new(identifier: "INV-0004", status: "Inactive", method: "Credit Card", amount: 100) - ] - end - - def users - [ - User.new(avatar_url: "https://avatars.githubusercontent.com/u/246692?v=4", name: "Joel Drapper", username: "joeldrapper", commits: 404), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/33979976?v=4", name: "Alexandre Ruban", username: "alexandreruban", commits: 16), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/77887?v=4", name: "Will Cosgrove", username: "willcosgrove", commits: 12), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/3025661?v=4", name: "Stephann V.", username: "stephannv", commits: 8), - User.new(avatar_url: "https://avatars.githubusercontent.com/u/6411752?v=4", name: "Marco Roth", username: "marcoroth", commits: 8) + Invoice.new(identifier: "INV001", status: "Paid", method: "Credit Card", amount: 250), + Invoice.new(identifier: "INV002", status: "Pending", method: "PayPal", amount: 150), + Invoice.new(identifier: "INV003", status: "Unpaid", method: "Bank Transfer", amount: 350) ] end - def github_link(user) - "https://github.com/#{user.username}" - end - - def github_icon - svg(viewbox: "0 0 438.549 438.549", class: "h-4 w-4") do |s| - s.path( - fill: "currentColor", - d: - "M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z" - ) - end - end - def format_amount(amount) "$#{amount}.00" end def render_status_badge(status) - case status.downcase - when "active" + case status + when "Paid" Badge(size: :sm, class: "bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300") { status } - when "inactive" - Badge(variant: :destructive, size: :sm) { status } - when "pending" + when "Pending" Badge(size: :sm, class: "bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300") { status } + else + Badge(variant: :destructive, size: :sm) { status } + end + end + + def more_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| + s.circle(cx: "12", cy: "12", r: "1") + s.circle(cx: "19", cy: "12", r: "1") + s.circle(cx: "5", cy: "12", r: "1") end end end diff --git a/gem/lib/ruby_ui/table/table_footer.rb b/gem/lib/ruby_ui/table/table_footer.rb index 24bf71ac1..020bdd1f7 100644 --- a/gem/lib/ruby_ui/table/table_footer.rb +++ b/gem/lib/ruby_ui/table/table_footer.rb @@ -2,6 +2,8 @@ module RubyUI class TableFooter < Base + include TableSlot + def view_template(&) tfoot(**attrs, &) end @@ -10,7 +12,8 @@ def view_template(&) def default_attrs { - class: "border-t bg-muted/50 font-medium[& amp;>tr]:last:border-b-0" + data: {slot: default_slot("table-footer")}.compact, + class: "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0" } end end diff --git a/gem/lib/ruby_ui/table/table_head.rb b/gem/lib/ruby_ui/table/table_head.rb index c0c8d0e48..b17f4e308 100644 --- a/gem/lib/ruby_ui/table/table_head.rb +++ b/gem/lib/ruby_ui/table/table_head.rb @@ -2,6 +2,8 @@ module RubyUI class TableHead < Base + include TableSlot + def view_template(&) th(**attrs, &) end @@ -10,7 +12,8 @@ def view_template(&) def default_attrs { - class: "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" + data: {slot: default_slot("table-head")}.compact, + class: "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&:has([data-slot=checkbox])]:pr-0" } end end diff --git a/gem/lib/ruby_ui/table/table_header.rb b/gem/lib/ruby_ui/table/table_header.rb index 5d632e8c8..0d3e27e65 100644 --- a/gem/lib/ruby_ui/table/table_header.rb +++ b/gem/lib/ruby_ui/table/table_header.rb @@ -2,6 +2,8 @@ module RubyUI class TableHeader < Base + include TableSlot + def view_template(&) thead(**attrs, &) end @@ -10,6 +12,7 @@ def view_template(&) def default_attrs { + data: {slot: default_slot("table-header")}.compact, class: "[&_tr]:border-b" } end diff --git a/gem/lib/ruby_ui/table/table_row.rb b/gem/lib/ruby_ui/table/table_row.rb index d1eeb2b4f..98e88fb29 100644 --- a/gem/lib/ruby_ui/table/table_row.rb +++ b/gem/lib/ruby_ui/table/table_row.rb @@ -2,6 +2,8 @@ module RubyUI class TableRow < Base + include TableSlot + def view_template(&) tr(**attrs, &) end @@ -10,7 +12,8 @@ def view_template(&) def default_attrs { - class: "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted" + data: {slot: default_slot("table-row")}.compact, + class: "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted" } end end diff --git a/gem/lib/ruby_ui/table/table_slot.rb b/gem/lib/ruby_ui/table/table_slot.rb new file mode 100644 index 000000000..1b0a5401e --- /dev/null +++ b/gem/lib/ruby_ui/table/table_slot.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RubyUI + # shadcn spreads props after data-slot, so a caller's slot replaces a Table + # part's default instead of Base#mix joining both values (as in Input). + module TableSlot + SLOT_ATTRS = [:data_slot, :"data-slot", "data-slot", "data_slot"].freeze + SLOT_KEYS = [:slot, :slot!, "slot", "slot!"].freeze + + def initialize(**attrs) + @caller_slot = caller_slot?(attrs) + super + end + + private + + def caller_slot?(attrs) + return true if attrs.key?(:data!) || SLOT_ATTRS.any? { |key| attrs.key?(key) } + + data = attrs[:data] || attrs["data"] + data.is_a?(Hash) && SLOT_KEYS.any? { |key| data.key?(key) } + end + + # The part's own slot, or nil when the caller passed one. + def default_slot(name) + @caller_slot ? nil : name + end + end +end diff --git a/gem/test/ruby_ui/table_test.rb b/gem/test/ruby_ui/table_test.rb index 2ae1e4ba6..3837078f8 100644 --- a/gem/test/ruby_ui/table_test.rb +++ b/gem/test/ruby_ui/table_test.rb @@ -34,8 +34,8 @@ def test_render_with_all_items end RubyUI.TableFooter do RubyUI.TableRow do - RubyUI.TableHead(class: "font-medium", colspan: 3) { "Total" } - RubyUI.TableHead(class: "font-medium text-right") { invoices.sum { |invoice| invoice[:amount] } } + RubyUI.TableCell(colspan: 3) { "Total" } + RubyUI.TableCell(class: "text-right") { invoices.sum { |invoice| invoice[:amount] } } end end end @@ -43,4 +43,75 @@ def test_render_with_all_items assert_match(/Total/, output) end + + def test_every_part_has_its_data_slot + output = phlex do + RubyUI.Table do + RubyUI.TableCaption { "Caption" } + RubyUI.TableHeader { RubyUI.TableRow { RubyUI.TableHead { "Head" } } } + RubyUI.TableBody { RubyUI.TableRow { RubyUI.TableCell { "Cell" } } } + RubyUI.TableFooter { RubyUI.TableRow { RubyUI.TableCell { "Foot" } } } + end + end + + { + "table-container" => "div", "table" => "table", "table-caption" => "caption", + "table-header" => "thead", "table-body" => "tbody", "table-footer" => "tfoot", + "table-row" => "tr", "table-head" => "th", "table-cell" => "td" + }.each do |slot, tag| + assert_match(/<#{tag}\b[^>]*\bdata-slot="#{slot}"/, output, "#{slot} on <#{tag}>") + end + end + + def test_container_scrolls_horizontally_and_attrs_go_to_the_table + output = phlex { RubyUI.Table(id: "invoices", class: "min-w-96") { "" } } + + assert_match(/
/, output) + assert_match(/]*id="invoices"/, output) + assert_match(/]*class="[^"]*min-w-96/, output) + end + + def test_footer_classes_are_valid_tokens + output = phlex { RubyUI.TableFooter { "" } } + + assert_match(%r{class="border-t bg-muted/50 font-medium \[&>tr\]:last:border-b-0"}, output) + end + + def test_head_and_cell_do_not_wrap + head = phlex { RubyUI.TableHead { "Head" } } + cell = phlex { RubyUI.TableCell { "Cell" } } + + assert_match(/whitespace-nowrap/, head) + assert_match(/text-foreground/, head) + refute_match(/text-muted-foreground/, head) + assert_match(/whitespace-nowrap/, cell) + [head, cell].each { |output| refute_match(/translate-y/, output) } + end + + def test_row_is_highlighted_while_a_control_inside_is_expanded + output = phlex { RubyUI.TableRow { "" } } + + assert_match(%r{has-aria-expanded:bg-muted/50}, output) + assert_match(/data-\[state=selected\]:bg-muted/, output) + end + + def test_a_caller_slot_replaces_the_default + { + RubyUI::Table => "table", RubyUI::TableHeader => "thead", RubyUI::TableBody => "tbody", + RubyUI::TableFooter => "tfoot", RubyUI::TableRow => "tr", RubyUI::TableHead => "th", + RubyUI::TableCell => "td", RubyUI::TableCaption => "caption" + }.each do |klass, tag| + output = phlex { RubyUI.public_send(klass.name.split("::").last, data: {slot: "custom", column: "email"}) { "x" } } + + assert_match(/<#{tag}\b[^>]*data-slot="custom"/, output, klass.name) + assert_match(/data-column="email"/, output, klass.name) + end + assert_match(/data-slot="table-container"/, phlex { RubyUI.Table(data: {slot: "custom"}) { "" } }) + end + + def test_checkbox_columns_drop_the_right_padding + [phlex { RubyUI.TableHead { "" } }, phlex { RubyUI.TableCell { "" } }].each do |output| + assert_match(/\[&:has\(\[data-slot=checkbox\]\)\]:pr-0|\[&:has\(\[data-slot=checkbox\]\)\]:pr-0/, output) + end + end end diff --git a/mcp/data/registry/components/table.json b/mcp/data/registry/components/table.json index a3f64c3da..14592e54c 100644 --- a/mcp/data/registry/components/table.json +++ b/mcp/data/registry/components/table.json @@ -4,35 +4,39 @@ "files": [ { "path": "table.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class Table < Base\n def view_template(&block)\n div(class: \"relative w-full overflow-auto\") do\n table(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n class: \"w-full caption-bottom text-sm\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # A native
inside a horizontally scrolling container, like shadcn's\n # Table: wide tables scroll instead of wrapping their cells. Attributes go\n # to the
; the wrapper is data-slot=\"table-container\".\n class Table < Base\n include TableSlot\n\n def view_template(&block)\n div(data: {slot: \"table-container\"}, class: \"relative w-full overflow-x-auto\") do\n table(**attrs, &block)\n end\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table\")}.compact,\n class: \"w-full caption-bottom text-sm\"\n }\n end\n end\nend\n" }, { "path": "table_body.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableBody < Base\n def view_template(&)\n tbody(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"[&_tr:last-child]:border-0\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableBody < Base\n include TableSlot\n\n def view_template(&)\n tbody(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table-body\")}.compact,\n class: \"[&_tr:last-child]:border-0\"\n }\n end\n end\nend\n" }, { "path": "table_caption.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableCaption < Base\n def view_template(&)\n caption(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"mt-4 text-sm text-muted-foreground\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableCaption < Base\n include TableSlot\n\n def view_template(&)\n caption(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table-caption\")}.compact,\n class: \"mt-4 text-sm text-muted-foreground\"\n }\n end\n end\nend\n" }, { "path": "table_cell.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableCell < Base\n def view_template(&)\n td(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableCell < Base\n include TableSlot\n\n def view_template(&)\n td(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table-cell\")}.compact,\n class: \"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&:has([data-slot=checkbox])]:pr-0\"\n }\n end\n end\nend\n" }, { "path": "table_footer.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableFooter < Base\n def view_template(&)\n tfoot(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"border-t bg-muted/50 font-medium[& amp;>tr]:last:border-b-0\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableFooter < Base\n include TableSlot\n\n def view_template(&)\n tfoot(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table-footer\")}.compact,\n class: \"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0\"\n }\n end\n end\nend\n" }, { "path": "table_head.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableHead < Base\n def view_template(&)\n th(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableHead < Base\n include TableSlot\n\n def view_template(&)\n th(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table-head\")}.compact,\n class: \"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&:has([data-slot=checkbox])]:pr-0\"\n }\n end\n end\nend\n" }, { "path": "table_header.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableHeader < Base\n def view_template(&)\n thead(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"[&_tr]:border-b\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableHeader < Base\n include TableSlot\n\n def view_template(&)\n thead(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table-header\")}.compact,\n class: \"[&_tr]:border-b\"\n }\n end\n end\nend\n" }, { "path": "table_row.rb", - "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableRow < Base\n def view_template(&)\n tr(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n class: \"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted\"\n }\n end\n end\nend\n" + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n class TableRow < Base\n include TableSlot\n\n def view_template(&)\n tr(**attrs, &)\n end\n\n private\n\n def default_attrs\n {\n data: {slot: default_slot(\"table-row\")}.compact,\n class: \"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted\"\n }\n end\n end\nend\n" + }, + { + "path": "table_slot.rb", + "content": "# frozen_string_literal: true\n\nmodule RubyUI\n # shadcn spreads props after data-slot, so a caller's slot replaces a Table\n # part's default instead of Base#mix joining both values (as in Input).\n module TableSlot\n SLOT_ATTRS = [:data_slot, :\"data-slot\", \"data-slot\", \"data_slot\"].freeze\n SLOT_KEYS = [:slot, :slot!, \"slot\", \"slot!\"].freeze\n\n def initialize(**attrs)\n @caller_slot = caller_slot?(attrs)\n super\n end\n\n private\n\n def caller_slot?(attrs)\n return true if attrs.key?(:data!) || SLOT_ATTRS.any? { |key| attrs.key?(key) }\n\n data = attrs[:data] || attrs[\"data\"]\n data.is_a?(Hash) && SLOT_KEYS.any? { |key| data.key?(key) }\n end\n\n # The part's own slot, or nil when the caller passed one.\n def default_slot(name)\n @caller_slot ? nil : name\n end\n end\nend\n" } ], "dependencies": { @@ -41,11 +45,21 @@ "gems": [] }, "install_command": "rails g ruby_ui:component Table", - "docs_markdown": "# Table\n\nA responsive table component.\n\n## Usage\n\n### Without builder\n\n```ruby\nTable do\n TableCaption { \"Employees at Acme inc.\" }\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n TableHead { \"Status\" }\n TableHead(class: \"text-right\") { \"Role\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: 'font-medium') { invoice.identifier }\n TableCell { render_status_badge(invoice.status) }\n TableCell { invoice.method }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\n TableFooter do\n TableRow do\n TableHead(class: \"font-medium\", colspan: 3) { \"Total\" }\n TableHead(class: \"font-medium text-right\") { format_amount(invoices.sum(&:amount)) }\n end\n end\nend\n```", + "docs_markdown": "# Table\n\nA responsive table component.\n\n## Usage\n\n### With footer\n\n```ruby\nTable do\n TableCaption { \"A list of your recent invoices.\" }\n TableHeader do\n TableRow do\n TableHead(class: \"w-[100px]\") { \"Invoice\" }\n TableHead { \"Status\" }\n TableHead { \"Method\" }\n TableHead(class: \"text-right\") { \"Amount\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: \"font-medium\") { invoice.identifier }\n TableCell { invoice.status }\n TableCell { invoice.method }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\n TableFooter do\n TableRow do\n TableCell(colspan: 3) { \"Total\" }\n TableCell(class: \"text-right\") { format_amount(invoices.sum(&:amount)) }\n end\n end\nend\n```\n\n### With badges\n\n```ruby\nTable do\n TableHeader do\n TableRow do\n TableHead { \"Invoice\" }\n TableHead { \"Status\" }\n TableHead(class: \"text-right\") { \"Amount\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: \"font-medium\") { invoice.identifier }\n TableCell { render_status_badge(invoice.status) }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\nend\n```\n\n### With actions\n\n```ruby\nTable do\n TableHeader do\n TableRow do\n TableHead { \"Product\" }\n TableHead { \"Price\" }\n TableHead(class: \"text-right\") { \"Actions\" }\n end\n end\n TableBody do\n [[\"Wireless Mouse\", \"$29.99\"], [\"Mechanical Keyboard\", \"$129.99\"], [\"USB-C Hub\", \"$49.99\"]].each do |product, price|\n TableRow do\n TableCell(class: \"font-medium\") { product }\n TableCell { price }\n TableCell(class: \"text-right\") do\n DropdownMenu do\n DropdownMenuTrigger do\n Button(variant: :ghost, size: :icon, class: \"size-8\") do\n more_icon\n span(class: \"sr-only\") { \"Open menu\" }\n end\n end\n DropdownMenuContent(align: :end) do\n DropdownMenuItem { \"Edit\" }\n DropdownMenuItem { \"Duplicate\" }\n DropdownMenuSeparator()\n DropdownMenuItem(variant: :destructive) { \"Delete\" }\n end\n end\n end\n end\n end\n end\nend\n```", "examples": [ { - "title": "Without builder", - "code": "Table do\n TableCaption { \"Employees at Acme inc.\" }\n TableHeader do\n TableRow do\n TableHead { \"Name\" }\n TableHead { \"Email\" }\n TableHead { \"Status\" }\n TableHead(class: \"text-right\") { \"Role\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: 'font-medium') { invoice.identifier }\n TableCell { render_status_badge(invoice.status) }\n TableCell { invoice.method }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\n TableFooter do\n TableRow do\n TableHead(class: \"font-medium\", colspan: 3) { \"Total\" }\n TableHead(class: \"font-medium text-right\") { format_amount(invoices.sum(&:amount)) }\n end\n end\nend\n", + "title": "With footer", + "code": "Table do\n TableCaption { \"A list of your recent invoices.\" }\n TableHeader do\n TableRow do\n TableHead(class: \"w-[100px]\") { \"Invoice\" }\n TableHead { \"Status\" }\n TableHead { \"Method\" }\n TableHead(class: \"text-right\") { \"Amount\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: \"font-medium\") { invoice.identifier }\n TableCell { invoice.status }\n TableCell { invoice.method }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\n TableFooter do\n TableRow do\n TableCell(colspan: 3) { \"Total\" }\n TableCell(class: \"text-right\") { format_amount(invoices.sum(&:amount)) }\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "With badges", + "code": "Table do\n TableHeader do\n TableRow do\n TableHead { \"Invoice\" }\n TableHead { \"Status\" }\n TableHead(class: \"text-right\") { \"Amount\" }\n end\n end\n TableBody do\n invoices.each do |invoice|\n TableRow do\n TableCell(class: \"font-medium\") { invoice.identifier }\n TableCell { render_status_badge(invoice.status) }\n TableCell(class: \"text-right\") { format_amount(invoice.amount) }\n end\n end\n end\nend\n", + "language": "ruby" + }, + { + "title": "With actions", + "code": "Table do\n TableHeader do\n TableRow do\n TableHead { \"Product\" }\n TableHead { \"Price\" }\n TableHead(class: \"text-right\") { \"Actions\" }\n end\n end\n TableBody do\n [[\"Wireless Mouse\", \"$29.99\"], [\"Mechanical Keyboard\", \"$129.99\"], [\"USB-C Hub\", \"$49.99\"]].each do |product, price|\n TableRow do\n TableCell(class: \"font-medium\") { product }\n TableCell { price }\n TableCell(class: \"text-right\") do\n DropdownMenu do\n DropdownMenuTrigger do\n Button(variant: :ghost, size: :icon, class: \"size-8\") do\n more_icon\n span(class: \"sr-only\") { \"Open menu\" }\n end\n end\n DropdownMenuContent(align: :end) do\n DropdownMenuItem { \"Edit\" }\n DropdownMenuItem { \"Duplicate\" }\n DropdownMenuSeparator()\n DropdownMenuItem(variant: :destructive) { \"Delete\" }\n end\n end\n end\n end\n end\n end\nend\n", "language": "ruby" } ]