Skip to content
Open
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
10 changes: 10 additions & 0 deletions lib/generators/ruby_ui/dependencies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ accordion:
js_packages:
- "motion"

data_table:
components:
- "Table"
- "Checkbox"
- "NativeSelect"
- "Pagination"
- "DropdownMenu"
- "Input"
- "Button"

alert_dialog:
components:
- "Button"
Expand Down
29 changes: 29 additions & 0 deletions lib/ruby_ui/data_table/data_table.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions lib/ruby_ui/data_table/data_table_bulk_actions.rb
Original file line number Diff line number Diff line change
@@ -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
62 changes: 62 additions & 0 deletions lib/ruby_ui/data_table/data_table_column_toggle.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# 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"
# inline chevron-down SVG (lucide 24px, 1px stroke)
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "16",
height: "16",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
class: "w-4 h-4 ml-1"
) do |s|
s.polyline(points: "6 9 12 15 18 9")
end
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
Original file line number Diff line number Diff line change
@@ -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));
}
}
57 changes: 57 additions & 0 deletions lib/ruby_ui/data_table/data_table_controller.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
180 changes: 180 additions & 0 deletions lib/ruby_ui/data_table/data_table_docs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# frozen_string_literal: true

class Views::Docs::DataTable < Views::Base
Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true)

SAMPLE_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"),
Row.new(id: 3, name: "Carol", email: "carol@example.com", salary: 85_000, status: "Active")
].freeze

def view_template
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
component = "DataTable"
render Docs::Header.new(
title: component,
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."
)

Heading(level: 2) { "Usage" }

render Docs::VisualCodeExample.new(title: "Server-driven table", context: self) do
@@code = <<~RUBY
DataTable(id: "employees") do
DataTableToolbar do
DataTableSearch(path: employees_path, value: @search)
DataTablePerPageSelect(path: employees_path, value: @per_page)
end

div(class: "rounded-md border") do
Table do
TableHeader do
TableRow do
TableHead { "Name" }
DataTableSortHead(column_key: :email, label: "Email",
sort: @sort, direction: @direction,
path: employees_path)
TableHead(class: "text-right") { "Salary" }
end
end
TableBody do
@rows.each do |r|
TableRow do
TableCell { r.name }
TableCell { r.email }
TableCell(class: "text-right") { r.salary }
end
end
end
end
end

DataTablePaginationBar do
DataTableSelectionSummary(total_on_page: @rows.size)
DataTablePagination(page: @page, per_page: @per_page,
total_count: @total_count, path: employees_path)
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do
@@code = <<~RUBY
FORM_ID = "employees_form"

DataTable(id: "employees_select") do
DataTableToolbar do
DataTableSearch(path: employees_path, value: @search)
DataTableBulkActions do
Button(type: "submit", form: FORM_ID,
formaction: bulk_delete_employees_path,
formmethod: "post",
variant: :destructive, size: :sm) { "Delete" }
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() }
TableHead { "Name" }
TableHead { "Email" }
end
end
TableBody do
@rows.each do |r|
TableRow do
TableCell { DataTableRowCheckbox(value: r.id) }
TableCell { r.name }
TableCell { r.email }
end
end
end
end
end
end

DataTablePaginationBar do
DataTableSelectionSummary(total_on_page: @rows.size)
DataTablePagination(page: @page, per_page: @per_page,
total_count: @total_count, path: employees_path)
end
end
RUBY
end

render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do
@@code = <<~RUBY
DataTable(id: "employees_cols") 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

render Docs::VisualCodeExample.new(title: "Expandable rows", context: self) do
@@code = <<~RUBY
DataTable(id: "employees_expand") do
Table do
TableHeader do
TableRow do
TableHead(class: "w-10") { }
TableHead { "Name" }
TableHead { "Email" }
end
end
TableBody do
@rows.each do |r|
detail_id = "row-\#{r.id}-detail"
TableRow do
TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle \#{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") do
p { "Salary: $\#{r.salary}" }
p { "Status: \#{r.status}" }
end
end
end
end
end
end
end
RUBY
end

render Components::ComponentSetup::Tabs.new(component_name: component)

render Docs::ComponentsTable.new(component_files(component))
end
end
end
Loading
Loading