Skip to content

Commit 9b18785

Browse files
committed
feat(data_table): add DataTable component with Hotwire + Tailwind
Composable DataTable wrapping existing Table with Stimulus-driven sorting, filtering, search and pagination (server-driven pattern). Components: DataTable, DataTableToolbar, DataTableSearch, DataTableSortableHeader, DataTableContent, DataTablePagination, DataTablePerPage. Includes 15 unit tests for all sub-components.
1 parent 755b288 commit 9b18785

10 files changed

Lines changed: 510 additions & 0 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTable < Base
5+
def initialize(src: nil, **attrs)
6+
@src = src
7+
super(**attrs)
8+
end
9+
10+
def view_template(&)
11+
div(**attrs, &)
12+
end
13+
14+
private
15+
16+
def default_attrs
17+
{
18+
class: "w-full space-y-4",
19+
data: {
20+
controller: "ruby-ui--data-table",
21+
ruby_ui__data_table_src_value: @src
22+
}
23+
}
24+
end
25+
end
26+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTableContent < Base
5+
def initialize(frame_id: "data_table_content", **attrs)
6+
@frame_id = frame_id
7+
super(**attrs)
8+
end
9+
10+
def view_template(&)
11+
div(id: @frame_id, **attrs, &)
12+
end
13+
14+
private
15+
16+
def default_attrs
17+
{
18+
class: "rounded-md border"
19+
}
20+
end
21+
end
22+
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["search", "perPage"]
5+
static values = {
6+
src: String,
7+
sortColumn: String,
8+
sortDirection: String,
9+
page: { type: Number, default: 1 },
10+
perPage: { type: Number, default: 10 },
11+
searchQuery: String,
12+
debounceMs: { type: Number, default: 300 }
13+
}
14+
15+
connect() {
16+
this.searchTimeout = null
17+
}
18+
19+
disconnect() {
20+
if (this.searchTimeout) clearTimeout(this.searchTimeout)
21+
}
22+
23+
sort(event) {
24+
const { column, direction } = event.params
25+
this.sortColumnValue = column
26+
this.sortDirectionValue = direction || ""
27+
this.pageValue = 1
28+
this._reload()
29+
}
30+
31+
search() {
32+
if (this.searchTimeout) clearTimeout(this.searchTimeout)
33+
this.searchTimeout = setTimeout(() => {
34+
this.searchQueryValue = this.searchTarget.value
35+
this.pageValue = 1
36+
this._reload()
37+
}, this.debounceMsValue)
38+
}
39+
40+
nextPage() {
41+
this.pageValue += 1
42+
this._reload()
43+
}
44+
45+
previousPage() {
46+
if (this.pageValue > 1) {
47+
this.pageValue -= 1
48+
this._reload()
49+
}
50+
}
51+
52+
changePerPage() {
53+
this.perPageValue = parseInt(this.perPageTarget.value)
54+
this.pageValue = 1
55+
this._reload()
56+
}
57+
58+
_reload() {
59+
if (!this.hasSrcValue || !this.srcValue) return
60+
61+
const url = new URL(this.srcValue, window.location.origin)
62+
if (this.sortColumnValue) url.searchParams.set("sort", this.sortColumnValue)
63+
if (this.sortDirectionValue) url.searchParams.set("direction", this.sortDirectionValue)
64+
if (this.searchQueryValue) url.searchParams.set("search", this.searchQueryValue)
65+
url.searchParams.set("page", this.pageValue)
66+
url.searchParams.set("per_page", this.perPageValue)
67+
68+
// Use Turbo to fetch and replace the content frame
69+
const frame = this.element.querySelector("turbo-frame")
70+
if (frame) {
71+
frame.src = url.toString()
72+
} else {
73+
// Fallback: dispatch custom event for consumer to handle
74+
this.dispatch("navigate", { detail: { url: url.toString() } })
75+
}
76+
}
77+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTableDocs < Phlex::HTML
5+
def view_template
6+
# Documentation placeholder for RubyUI website
7+
end
8+
end
9+
end
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTablePagination < Base
5+
def initialize(current_page:, total_pages:, **attrs)
6+
@current_page = current_page
7+
@total_pages = total_pages
8+
super(**attrs)
9+
end
10+
11+
def view_template
12+
div(**attrs) do
13+
div(class: "flex items-center justify-between px-2") do
14+
div(class: "flex-1 text-sm text-muted-foreground") do
15+
plain "Page #{@current_page} of #{@total_pages}"
16+
end
17+
div(class: "flex items-center space-x-2") do
18+
nav_button(
19+
direction: "previous",
20+
disabled: @current_page <= 1,
21+
action: "click->ruby-ui--data-table#previousPage",
22+
icon_path: "m15 18-6-6 6-6"
23+
)
24+
nav_button(
25+
direction: "next",
26+
disabled: @current_page >= @total_pages,
27+
action: "click->ruby-ui--data-table#nextPage",
28+
icon_path: "m9 18 6-6-6-6"
29+
)
30+
end
31+
end
32+
end
33+
end
34+
35+
private
36+
37+
def nav_button(direction:, disabled:, action:, icon_path:)
38+
button(
39+
type: "button",
40+
class: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground h-8 w-8 p-0 #{disabled ? "opacity-50 pointer-events-none" : ""}",
41+
disabled: disabled,
42+
aria_label: direction,
43+
data: {action: action}
44+
) do
45+
svg(
46+
xmlns: "http://www.w3.org/2000/svg",
47+
width: "16", height: "16", viewBox: "0 0 24 24",
48+
fill: "none", stroke: "currentColor",
49+
stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round"
50+
) { |s| s.path(d: icon_path) }
51+
end
52+
end
53+
54+
def default_attrs
55+
{
56+
class: "flex items-center justify-end py-4"
57+
}
58+
end
59+
end
60+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTablePerPage < Base
5+
def initialize(options: [10, 20, 50, 100], current: 10, **attrs)
6+
@options = options
7+
@current = current
8+
super(**attrs)
9+
end
10+
11+
def view_template
12+
div(**attrs) do
13+
span(class: "text-sm text-muted-foreground") { "Rows per page" }
14+
select(
15+
class: "h-8 w-16 rounded-md border bg-background px-2 text-sm",
16+
data: {
17+
action: "change->ruby-ui--data-table#changePerPage",
18+
ruby_ui__data_table_target: "perPage"
19+
}
20+
) do
21+
@options.each do |opt|
22+
option(value: opt, selected: opt == @current) { opt.to_s }
23+
end
24+
end
25+
end
26+
end
27+
28+
private
29+
30+
def default_attrs
31+
{
32+
class: "flex items-center gap-2"
33+
}
34+
end
35+
end
36+
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTableSearch < Base
5+
def initialize(placeholder: "Search...", name: "search", **attrs)
6+
@placeholder = placeholder
7+
@name = name
8+
super(**attrs)
9+
end
10+
11+
def view_template
12+
input(**attrs)
13+
end
14+
15+
private
16+
17+
def default_attrs
18+
{
19+
type: "search",
20+
name: @name,
21+
placeholder: @placeholder,
22+
class: "flex h-9 w-full max-w-sm rounded-md border bg-background px-3 py-1 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
23+
data: {
24+
ruby_ui__data_table_target: "search",
25+
action: "input->ruby-ui--data-table#search"
26+
}
27+
}
28+
end
29+
end
30+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTableSortableHeader < Base
5+
def initialize(column:, label: nil, direction: nil, **attrs)
6+
@column = column
7+
@label = label || column.to_s.tr("_", " ").capitalize
8+
@direction = direction # nil, "asc", or "desc"
9+
super(**attrs)
10+
end
11+
12+
def view_template(&block)
13+
th(**attrs) do
14+
button(
15+
type: "button",
16+
class: "inline-flex items-center gap-1 hover:text-foreground",
17+
data: {
18+
action: "click->ruby-ui--data-table#sort",
19+
ruby_ui__data_table_column_param: @column,
20+
ruby_ui__data_table_direction_param: next_direction
21+
}
22+
) do
23+
if block
24+
yield
25+
else
26+
plain @label
27+
end
28+
render_sort_icon
29+
end
30+
end
31+
end
32+
33+
private
34+
35+
def next_direction
36+
case @direction
37+
when "asc" then "desc"
38+
when "desc" then ""
39+
else "asc"
40+
end
41+
end
42+
43+
def render_sort_icon
44+
svg(
45+
xmlns: "http://www.w3.org/2000/svg",
46+
width: "14", height: "14",
47+
viewBox: "0 0 24 24",
48+
fill: "none", stroke: "currentColor",
49+
stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round",
50+
class: "ml-1 #{@direction ? "" : "text-muted-foreground"}"
51+
) do |s|
52+
if @direction == "asc"
53+
s.path(d: "m18 15-6-6-6 6")
54+
elsif @direction == "desc"
55+
s.path(d: "m6 9 6 6 6-6")
56+
else
57+
s.path(d: "m7 15 5 5 5-5")
58+
s.path(d: "m7 9 5-5 5 5")
59+
end
60+
end
61+
end
62+
63+
def default_attrs
64+
{
65+
class: "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0"
66+
}
67+
end
68+
end
69+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
module RubyUI
4+
class DataTableToolbar < Base
5+
def view_template(&)
6+
div(**attrs, &)
7+
end
8+
9+
private
10+
11+
def default_attrs
12+
{
13+
class: "flex items-center justify-between gap-2"
14+
}
15+
end
16+
end
17+
end

0 commit comments

Comments
 (0)