ruby_ui 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/generators/ruby_ui/component_generator.rb +5 -1
- data/lib/generators/ruby_ui/dependencies.yml +10 -0
- data/lib/generators/ruby_ui/install/install_generator.rb +1 -1
- data/lib/generators/ruby_ui/javascript_utils.rb +4 -0
- data/lib/ruby_ui/combobox/combobox.rb +7 -1
- data/lib/ruby_ui/combobox/combobox_badge.rb +17 -0
- data/lib/ruby_ui/combobox/combobox_badge_trigger.rb +47 -0
- data/lib/ruby_ui/combobox/combobox_checkbox.rb +1 -7
- data/lib/ruby_ui/combobox/combobox_clear_button.rb +40 -0
- data/lib/ruby_ui/combobox/combobox_controller.js +243 -53
- data/lib/ruby_ui/combobox/combobox_docs.rb +199 -64
- data/lib/ruby_ui/combobox/combobox_input_trigger.rb +64 -0
- data/lib/ruby_ui/combobox/combobox_item.rb +5 -7
- data/lib/ruby_ui/combobox/combobox_item_indicator.rb +30 -0
- data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
- data/lib/ruby_ui/combobox/combobox_popover.rb +0 -5
- data/lib/ruby_ui/combobox/combobox_radio.rb +1 -8
- data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +1 -7
- data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
- data/lib/ruby_ui/data_table/data_table.rb +29 -0
- data/lib/ruby_ui/data_table/data_table_bulk_actions.rb +18 -0
- data/lib/ruby_ui/data_table/data_table_column_toggle.rb +62 -0
- data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +14 -0
- data/lib/ruby_ui/data_table/data_table_controller.js +57 -0
- data/lib/ruby_ui/data_table/data_table_docs.rb +180 -0
- data/lib/ruby_ui/data_table/data_table_expand_toggle.rb +53 -0
- data/lib/ruby_ui/data_table/data_table_form.rb +39 -0
- data/lib/ruby_ui/data_table/data_table_kaminari_adapter.rb +17 -0
- data/lib/ruby_ui/data_table/data_table_manual_adapter.rb +17 -0
- data/lib/ruby_ui/data_table/data_table_pagination.rb +100 -0
- data/lib/ruby_ui/data_table/data_table_pagination_bar.rb +15 -0
- data/lib/ruby_ui/data_table/data_table_pagy_adapter.rb +17 -0
- data/lib/ruby_ui/data_table/data_table_per_page_select.rb +35 -0
- data/lib/ruby_ui/data_table/data_table_row_checkbox.rb +30 -0
- data/lib/ruby_ui/data_table/data_table_search.rb +57 -0
- data/lib/ruby_ui/data_table/data_table_search_controller.js +62 -0
- data/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb +21 -0
- data/lib/ruby_ui/data_table/data_table_selection_summary.rb +25 -0
- data/lib/ruby_ui/data_table/data_table_sort_head.rb +112 -0
- data/lib/ruby_ui/data_table/data_table_toolbar.rb +15 -0
- data/lib/ruby_ui/masked_input/masked_input.rb +11 -1
- data/lib/ruby_ui/masked_input/masked_input_controller.js +13 -0
- data/lib/ruby_ui/native_select/native_select.rb +39 -0
- data/lib/ruby_ui/native_select/native_select_docs.rb +83 -0
- data/lib/ruby_ui/native_select/native_select_group.rb +15 -0
- data/lib/ruby_ui/native_select/native_select_icon.rb +39 -0
- data/lib/ruby_ui/native_select/native_select_option.rb +15 -0
- data/lib/ruby_ui.rb +1 -1
- metadata +43 -3
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTableColumnToggle < Base
|
|
5
|
+
def initialize(columns:, **attrs)
|
|
6
|
+
@columns = columns
|
|
7
|
+
super(**attrs)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def view_template
|
|
11
|
+
div(**attrs) do
|
|
12
|
+
render RubyUI::DropdownMenu.new do
|
|
13
|
+
render RubyUI::DropdownMenuTrigger.new do
|
|
14
|
+
render RubyUI::Button.new(variant: :outline, size: :sm) do
|
|
15
|
+
plain "Columns"
|
|
16
|
+
# inline chevron-down SVG (lucide 24px, 1px stroke)
|
|
17
|
+
svg(
|
|
18
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
19
|
+
width: "16",
|
|
20
|
+
height: "16",
|
|
21
|
+
viewBox: "0 0 24 24",
|
|
22
|
+
fill: "none",
|
|
23
|
+
stroke: "currentColor",
|
|
24
|
+
stroke_width: "2",
|
|
25
|
+
stroke_linecap: "round",
|
|
26
|
+
stroke_linejoin: "round",
|
|
27
|
+
class: "w-4 h-4 ml-1"
|
|
28
|
+
) do |s|
|
|
29
|
+
s.polyline(points: "6 9 12 15 18 9")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
render RubyUI::DropdownMenuContent.new do
|
|
34
|
+
@columns.each do |col|
|
|
35
|
+
label(class: "flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer hover:bg-accent") do
|
|
36
|
+
input(
|
|
37
|
+
type: "checkbox",
|
|
38
|
+
checked: true,
|
|
39
|
+
class: "h-4 w-4 rounded border border-input accent-primary cursor-pointer",
|
|
40
|
+
data: {
|
|
41
|
+
column_key: col[:key].to_s,
|
|
42
|
+
action: "change->ruby-ui--data-table-column-visibility#toggle"
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
span { plain col[:label] }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def default_attrs
|
|
56
|
+
{
|
|
57
|
+
class: "relative",
|
|
58
|
+
data: {controller: "ruby-ui--data-table-column-visibility"}
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js
|
|
2
|
+
import { Controller } from "@hotwired/stimulus";
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
toggle(event) {
|
|
6
|
+
const key = event.target.dataset.columnKey;
|
|
7
|
+
const visible = event.target.checked;
|
|
8
|
+
const root = this.element.closest('[data-controller~="ruby-ui--data-table"]');
|
|
9
|
+
if (!root) return;
|
|
10
|
+
root
|
|
11
|
+
.querySelectorAll(`[data-column="${key}"]`)
|
|
12
|
+
.forEach((el) => el.classList.toggle("hidden", !visible));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// app/javascript/controllers/ruby_ui/data_table_controller.js
|
|
2
|
+
import { Controller } from "@hotwired/stimulus";
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = [
|
|
6
|
+
"selectAll",
|
|
7
|
+
"rowCheckbox",
|
|
8
|
+
"selectionSummary",
|
|
9
|
+
"selectionBar",
|
|
10
|
+
"bulkActions",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
this.updateState();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toggleAll(event) {
|
|
18
|
+
const checked = event.target.checked;
|
|
19
|
+
this.rowCheckboxTargets.forEach((cb) => {
|
|
20
|
+
cb.checked = checked;
|
|
21
|
+
});
|
|
22
|
+
this.updateState();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
toggleRow() {
|
|
26
|
+
this.updateState();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
toggleRowDetail(event) {
|
|
30
|
+
const button = event.currentTarget;
|
|
31
|
+
const id = button.getAttribute("aria-controls");
|
|
32
|
+
if (!id) return;
|
|
33
|
+
const target = document.getElementById(id);
|
|
34
|
+
if (!target) return;
|
|
35
|
+
const expanded = button.getAttribute("aria-expanded") === "true";
|
|
36
|
+
button.setAttribute("aria-expanded", String(!expanded));
|
|
37
|
+
target.classList.toggle("hidden", expanded);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
updateState() {
|
|
41
|
+
const total = this.rowCheckboxTargets.length;
|
|
42
|
+
const selected = this.rowCheckboxTargets.filter((cb) => cb.checked).length;
|
|
43
|
+
|
|
44
|
+
if (this.hasSelectAllTarget) {
|
|
45
|
+
this.selectAllTarget.checked = total > 0 && selected === total;
|
|
46
|
+
this.selectAllTarget.indeterminate = selected > 0 && selected < total;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (this.hasSelectionSummaryTarget) {
|
|
50
|
+
this.selectionSummaryTarget.textContent = `${selected} of ${total} row(s) selected.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (this.hasBulkActionsTarget) {
|
|
54
|
+
this.bulkActionsTarget.classList.toggle("hidden", selected === 0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Views::Docs::DataTable < Views::Base
|
|
4
|
+
Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
SAMPLE_ROWS = [
|
|
7
|
+
Row.new(id: 1, name: "Alice", email: "alice@example.com", salary: 90_000, status: "Active"),
|
|
8
|
+
Row.new(id: 2, name: "Bob", email: "bob@example.com", salary: 75_000, status: "Inactive"),
|
|
9
|
+
Row.new(id: 3, name: "Carol", email: "carol@example.com", salary: 85_000, status: "Active")
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def view_template
|
|
13
|
+
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
|
|
14
|
+
component = "DataTable"
|
|
15
|
+
render Docs::Header.new(
|
|
16
|
+
title: component,
|
|
17
|
+
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."
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
Heading(level: 2) { "Usage" }
|
|
21
|
+
|
|
22
|
+
render Docs::VisualCodeExample.new(title: "Server-driven table", context: self) do
|
|
23
|
+
@@code = <<~RUBY
|
|
24
|
+
DataTable(id: "employees") do
|
|
25
|
+
DataTableToolbar do
|
|
26
|
+
DataTableSearch(path: employees_path, value: @search)
|
|
27
|
+
DataTablePerPageSelect(path: employees_path, value: @per_page)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
div(class: "rounded-md border") do
|
|
31
|
+
Table do
|
|
32
|
+
TableHeader do
|
|
33
|
+
TableRow do
|
|
34
|
+
TableHead { "Name" }
|
|
35
|
+
DataTableSortHead(column_key: :email, label: "Email",
|
|
36
|
+
sort: @sort, direction: @direction,
|
|
37
|
+
path: employees_path)
|
|
38
|
+
TableHead(class: "text-right") { "Salary" }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
TableBody do
|
|
42
|
+
@rows.each do |r|
|
|
43
|
+
TableRow do
|
|
44
|
+
TableCell { r.name }
|
|
45
|
+
TableCell { r.email }
|
|
46
|
+
TableCell(class: "text-right") { r.salary }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
DataTablePaginationBar do
|
|
54
|
+
DataTableSelectionSummary(total_on_page: @rows.size)
|
|
55
|
+
DataTablePagination(page: @page, per_page: @per_page,
|
|
56
|
+
total_count: @total_count, path: employees_path)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
RUBY
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do
|
|
63
|
+
@@code = <<~RUBY
|
|
64
|
+
FORM_ID = "employees_form"
|
|
65
|
+
|
|
66
|
+
DataTable(id: "employees_select") do
|
|
67
|
+
DataTableToolbar do
|
|
68
|
+
DataTableSearch(path: employees_path, value: @search)
|
|
69
|
+
DataTableBulkActions do
|
|
70
|
+
Button(type: "submit", form: FORM_ID,
|
|
71
|
+
formaction: bulk_delete_employees_path,
|
|
72
|
+
formmethod: "post",
|
|
73
|
+
variant: :destructive, size: :sm) { "Delete" }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
DataTableForm(id: FORM_ID, action: "") do
|
|
78
|
+
div(class: "rounded-md border") do
|
|
79
|
+
Table do
|
|
80
|
+
TableHeader do
|
|
81
|
+
TableRow do
|
|
82
|
+
TableHead(class: "w-10") { DataTableSelectAllCheckbox() }
|
|
83
|
+
TableHead { "Name" }
|
|
84
|
+
TableHead { "Email" }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
TableBody do
|
|
88
|
+
@rows.each do |r|
|
|
89
|
+
TableRow do
|
|
90
|
+
TableCell { DataTableRowCheckbox(value: r.id) }
|
|
91
|
+
TableCell { r.name }
|
|
92
|
+
TableCell { r.email }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
DataTablePaginationBar do
|
|
101
|
+
DataTableSelectionSummary(total_on_page: @rows.size)
|
|
102
|
+
DataTablePagination(page: @page, per_page: @per_page,
|
|
103
|
+
total_count: @total_count, path: employees_path)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
RUBY
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do
|
|
110
|
+
@@code = <<~RUBY
|
|
111
|
+
DataTable(id: "employees_cols") do
|
|
112
|
+
DataTableToolbar do
|
|
113
|
+
DataTableColumnToggle(columns: [
|
|
114
|
+
{key: :email, label: "Email"},
|
|
115
|
+
{key: :salary, label: "Salary"}
|
|
116
|
+
])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Table do
|
|
120
|
+
TableHeader do
|
|
121
|
+
TableRow do
|
|
122
|
+
TableHead { "Name" }
|
|
123
|
+
TableHead(data: {column: "email"}) { "Email" }
|
|
124
|
+
TableHead(data: {column: "salary"}) { "Salary" }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
TableBody do
|
|
128
|
+
@rows.each do |r|
|
|
129
|
+
TableRow do
|
|
130
|
+
TableCell { r.name }
|
|
131
|
+
TableCell(data: {column: "email"}) { r.email }
|
|
132
|
+
TableCell(data: {column: "salary"}) { r.salary }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
RUBY
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
render Docs::VisualCodeExample.new(title: "Expandable rows", context: self) do
|
|
142
|
+
@@code = <<~RUBY
|
|
143
|
+
DataTable(id: "employees_expand") do
|
|
144
|
+
Table do
|
|
145
|
+
TableHeader do
|
|
146
|
+
TableRow do
|
|
147
|
+
TableHead(class: "w-10") { }
|
|
148
|
+
TableHead { "Name" }
|
|
149
|
+
TableHead { "Email" }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
TableBody do
|
|
153
|
+
@rows.each do |r|
|
|
154
|
+
detail_id = "row-\#{r.id}-detail"
|
|
155
|
+
TableRow do
|
|
156
|
+
TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle \#{r.name}") }
|
|
157
|
+
TableCell { r.name }
|
|
158
|
+
TableCell { r.email }
|
|
159
|
+
end
|
|
160
|
+
TableRow(id: detail_id, class: "hidden", role: "region") do
|
|
161
|
+
TableCell(colspan: 3, class: "bg-muted/40") do
|
|
162
|
+
div(class: "p-4") do
|
|
163
|
+
p { "Salary: $\#{r.salary}" }
|
|
164
|
+
p { "Status: \#{r.status}" }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
RUBY
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
render Components::ComponentSetup::Tabs.new(component_name: component)
|
|
176
|
+
|
|
177
|
+
render Docs::ComponentsTable.new(component_files(component))
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTableExpandToggle < Base
|
|
5
|
+
def initialize(controls:, expanded: false, label: "Toggle row details", **attrs)
|
|
6
|
+
@controls = controls
|
|
7
|
+
@expanded = expanded
|
|
8
|
+
@label = label
|
|
9
|
+
super(**attrs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def view_template
|
|
13
|
+
button(
|
|
14
|
+
type: "button",
|
|
15
|
+
aria_expanded: @expanded.to_s,
|
|
16
|
+
aria_controls: @controls,
|
|
17
|
+
aria_label: @label,
|
|
18
|
+
data: {
|
|
19
|
+
action: "click->ruby-ui--data-table#toggleRowDetail"
|
|
20
|
+
},
|
|
21
|
+
**attrs
|
|
22
|
+
) do
|
|
23
|
+
render_icon
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def render_icon
|
|
30
|
+
# inline chevron-right SVG (lucide)
|
|
31
|
+
svg(
|
|
32
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
33
|
+
width: "16",
|
|
34
|
+
height: "16",
|
|
35
|
+
viewBox: "0 0 24 24",
|
|
36
|
+
fill: "none",
|
|
37
|
+
stroke: "currentColor",
|
|
38
|
+
stroke_width: "2",
|
|
39
|
+
stroke_linecap: "round",
|
|
40
|
+
stroke_linejoin: "round",
|
|
41
|
+
class: "h-4 w-4 transition-transform duration-150 group-aria-expanded:rotate-90"
|
|
42
|
+
) do |s|
|
|
43
|
+
s.polyline(points: "9 18 15 12 9 6")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def default_attrs
|
|
48
|
+
{
|
|
49
|
+
class: "group inline-flex items-center justify-center h-8 w-8 rounded-md hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTableForm < Base
|
|
5
|
+
def initialize(action: "", method: "post", id: nil, **attrs)
|
|
6
|
+
@action = action
|
|
7
|
+
@method = method
|
|
8
|
+
@id = id
|
|
9
|
+
super(**attrs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def view_template(&block)
|
|
13
|
+
form_attrs = {action: @action, method: @method}
|
|
14
|
+
form_attrs[:id] = @id if @id
|
|
15
|
+
form(**form_attrs, **attrs) do
|
|
16
|
+
input(type: "hidden", name: "authenticity_token", value: csrf_token)
|
|
17
|
+
yield if block
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def csrf_token
|
|
24
|
+
# In a Rails app, view_context provides a real CSRF token.
|
|
25
|
+
# Outside Rails (gem tests), fall back to a placeholder.
|
|
26
|
+
if respond_to?(:helpers, true) && helpers.respond_to?(:form_authenticity_token)
|
|
27
|
+
helpers.form_authenticity_token
|
|
28
|
+
elsif respond_to?(:view_context, true) && view_context.respond_to?(:form_authenticity_token)
|
|
29
|
+
view_context.form_authenticity_token
|
|
30
|
+
else
|
|
31
|
+
"csrf-token-placeholder"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def default_attrs
|
|
36
|
+
{}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTableKaminariAdapter
|
|
5
|
+
def initialize(collection)
|
|
6
|
+
@collection = collection
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def current_page = @collection.current_page
|
|
10
|
+
|
|
11
|
+
def total_pages = @collection.total_pages
|
|
12
|
+
|
|
13
|
+
def total_count = @collection.total_count
|
|
14
|
+
|
|
15
|
+
def per_page = @collection.limit_value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTableManualAdapter
|
|
5
|
+
attr_reader :current_page, :per_page, :total_count
|
|
6
|
+
|
|
7
|
+
def initialize(page:, per_page:, total_count:)
|
|
8
|
+
@current_page = page.to_i
|
|
9
|
+
@per_page = [per_page.to_i, 1].max
|
|
10
|
+
@total_count = total_count.to_i
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def total_pages
|
|
14
|
+
[(@total_count.to_f / @per_page).ceil, 1].max
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require_relative "data_table_manual_adapter"
|
|
5
|
+
require_relative "data_table_pagy_adapter"
|
|
6
|
+
require_relative "data_table_kaminari_adapter"
|
|
7
|
+
|
|
8
|
+
module RubyUI
|
|
9
|
+
class DataTablePagination < Base
|
|
10
|
+
def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, window: 1, prev_label: "<", next_label: ">", **attrs)
|
|
11
|
+
@adapter = resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:)
|
|
12
|
+
@page_param = page_param
|
|
13
|
+
@path = path
|
|
14
|
+
@query = query.to_h.transform_keys(&:to_s)
|
|
15
|
+
@window = window
|
|
16
|
+
@prev_label = prev_label
|
|
17
|
+
@next_label = next_label
|
|
18
|
+
super(**attrs)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def view_template
|
|
22
|
+
return if total <= 1
|
|
23
|
+
|
|
24
|
+
render RubyUI::Pagination.new(class: "mx-0 w-auto justify-end", **attrs) do
|
|
25
|
+
render RubyUI::PaginationContent.new do
|
|
26
|
+
prev_item
|
|
27
|
+
number_items
|
|
28
|
+
next_item
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:)
|
|
36
|
+
return with if with
|
|
37
|
+
return RubyUI::DataTablePagyAdapter.new(pagy) if pagy
|
|
38
|
+
return RubyUI::DataTableKaminariAdapter.new(kaminari) if kaminari
|
|
39
|
+
if page && per_page && total_count
|
|
40
|
+
return RubyUI::DataTableManualAdapter.new(page:, per_page:, total_count:)
|
|
41
|
+
end
|
|
42
|
+
raise ArgumentError, "DataTablePagination requires one of: with:, pagy:, kaminari:, or page:+per_page:+total_count:"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def current = @adapter.current_page
|
|
46
|
+
|
|
47
|
+
def total = @adapter.total_pages
|
|
48
|
+
|
|
49
|
+
def page_href(p)
|
|
50
|
+
qs = build_query(@query.merge(@page_param => p.to_s))
|
|
51
|
+
qs.empty? ? @path : "#{@path}?#{qs}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_query(hash)
|
|
55
|
+
hash.flat_map { |k, v|
|
|
56
|
+
Array(v).map { |val| "#{CGI.escape(k.to_s)}=#{CGI.escape(val.to_s)}" }
|
|
57
|
+
}.join("&")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def prev_item
|
|
61
|
+
if current <= 1
|
|
62
|
+
li do
|
|
63
|
+
span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { @prev_label }
|
|
64
|
+
end
|
|
65
|
+
else
|
|
66
|
+
render RubyUI::PaginationItem.new(href: page_href(current - 1)) { @prev_label }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def next_item
|
|
71
|
+
if current >= total
|
|
72
|
+
li do
|
|
73
|
+
span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { @next_label }
|
|
74
|
+
end
|
|
75
|
+
else
|
|
76
|
+
render RubyUI::PaginationItem.new(href: page_href(current + 1)) { @next_label }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def number_items
|
|
81
|
+
windowed_pages.each do |p|
|
|
82
|
+
if p == :gap
|
|
83
|
+
render RubyUI::PaginationEllipsis.new
|
|
84
|
+
else
|
|
85
|
+
render RubyUI::PaginationItem.new(href: page_href(p), active: p == current) { plain p.to_s }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def windowed_pages
|
|
91
|
+
return (1..total).to_a if total <= 7
|
|
92
|
+
pages = [1]
|
|
93
|
+
pages << :gap if current - @window > 2
|
|
94
|
+
((current - @window)..(current + @window)).each { |p| pages << p if p > 1 && p < total }
|
|
95
|
+
pages << :gap if current + @window < total - 1
|
|
96
|
+
pages << total
|
|
97
|
+
pages
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTablePagyAdapter
|
|
5
|
+
def initialize(pagy)
|
|
6
|
+
@pagy = pagy
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def current_page = @pagy.page
|
|
10
|
+
|
|
11
|
+
def total_pages = @pagy.pages
|
|
12
|
+
|
|
13
|
+
def total_count = @pagy.count
|
|
14
|
+
|
|
15
|
+
def per_page = @pagy.items
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTablePerPageSelect < Base
|
|
5
|
+
def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [5, 10, 25, 50], **attrs)
|
|
6
|
+
@path = path
|
|
7
|
+
@name = name
|
|
8
|
+
@value = value
|
|
9
|
+
@frame_id = frame_id
|
|
10
|
+
@options = options
|
|
11
|
+
super(**attrs)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def view_template
|
|
15
|
+
form_attrs = {action: @path, method: "get"}
|
|
16
|
+
form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id
|
|
17
|
+
|
|
18
|
+
form(**attrs.merge(form_attrs)) do
|
|
19
|
+
render RubyUI::NativeSelect.new(name: @name, onchange: safe("this.form.requestSubmit()")) do
|
|
20
|
+
@options.each do |opt|
|
|
21
|
+
option_attrs = {value: opt.to_s}
|
|
22
|
+
option_attrs[:selected] = true if opt.to_s == @value.to_s
|
|
23
|
+
option(**option_attrs) { plain opt.to_s }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def default_attrs
|
|
32
|
+
{}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUI
|
|
4
|
+
class DataTableRowCheckbox < Base
|
|
5
|
+
def initialize(value:, name: "ids[]", label: nil, **attrs)
|
|
6
|
+
@value = value
|
|
7
|
+
@name = name
|
|
8
|
+
@label = label
|
|
9
|
+
super(**attrs)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def view_template
|
|
13
|
+
render RubyUI::Checkbox.new(**attrs)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def default_attrs
|
|
19
|
+
{
|
|
20
|
+
name: @name,
|
|
21
|
+
value: @value,
|
|
22
|
+
aria_label: @label || "Select row #{@value}",
|
|
23
|
+
data: {
|
|
24
|
+
"ruby-ui--data-table-target": "rowCheckbox",
|
|
25
|
+
action: "change->ruby-ui--data-table#toggleRow"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|