ruby_ui 1.0.2 → 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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.txt +1 -1
  3. data/README.md +4 -0
  4. data/lib/generators/ruby_ui/component_generator.rb +5 -1
  5. data/lib/generators/ruby_ui/dependencies.yml +10 -0
  6. data/lib/generators/ruby_ui/install/docs_generator.rb +33 -0
  7. data/lib/generators/ruby_ui/install/install_generator.rb +1 -1
  8. data/lib/generators/ruby_ui/javascript_utils.rb +4 -0
  9. data/lib/ruby_ui/accordion/accordion_docs.rb +53 -0
  10. data/lib/ruby_ui/alert/alert_docs.rb +135 -0
  11. data/lib/ruby_ui/alert_dialog/alert_dialog_docs.rb +35 -0
  12. data/lib/ruby_ui/aspect_ratio/aspect_ratio_docs.rb +64 -0
  13. data/lib/ruby_ui/avatar/avatar_docs.rb +92 -0
  14. data/lib/ruby_ui/badge/badge_docs.rb +80 -0
  15. data/lib/ruby_ui/breadcrumb/breadcrumb_docs.rb +116 -0
  16. data/lib/ruby_ui/button/button_docs.rb +143 -0
  17. data/lib/ruby_ui/calendar/calendar_docs.rb +34 -0
  18. data/lib/ruby_ui/card/card_docs.rb +114 -0
  19. data/lib/ruby_ui/carousel/carousel_docs.rb +104 -0
  20. data/lib/ruby_ui/chart/chart_docs.rb +115 -0
  21. data/lib/ruby_ui/checkbox/checkbox.rb +2 -2
  22. data/lib/ruby_ui/checkbox/checkbox_docs.rb +41 -0
  23. data/lib/ruby_ui/clipboard/clipboard_docs.rb +30 -0
  24. data/lib/ruby_ui/codeblock/codeblock_docs.rb +55 -0
  25. data/lib/ruby_ui/collapsible/collapsible_docs.rb +96 -0
  26. data/lib/ruby_ui/combobox/combobox.rb +7 -1
  27. data/lib/ruby_ui/combobox/combobox_badge.rb +17 -0
  28. data/lib/ruby_ui/combobox/combobox_badge_trigger.rb +47 -0
  29. data/lib/ruby_ui/combobox/combobox_checkbox.rb +1 -7
  30. data/lib/ruby_ui/combobox/combobox_clear_button.rb +40 -0
  31. data/lib/ruby_ui/combobox/combobox_controller.js +252 -47
  32. data/lib/ruby_ui/combobox/combobox_docs.rb +286 -0
  33. data/lib/ruby_ui/combobox/combobox_input_trigger.rb +64 -0
  34. data/lib/ruby_ui/combobox/combobox_item.rb +5 -7
  35. data/lib/ruby_ui/combobox/combobox_item_indicator.rb +30 -0
  36. data/lib/ruby_ui/combobox/combobox_list_group.rb +1 -1
  37. data/lib/ruby_ui/combobox/combobox_popover.rb +1 -5
  38. data/lib/ruby_ui/combobox/combobox_radio.rb +1 -8
  39. data/lib/ruby_ui/combobox/combobox_toggle_all_checkbox.rb +1 -6
  40. data/lib/ruby_ui/combobox/combobox_trigger.rb +19 -19
  41. data/lib/ruby_ui/command/command_docs.rb +154 -0
  42. data/lib/ruby_ui/context_menu/context_menu.rb +1 -1
  43. data/lib/ruby_ui/context_menu/context_menu_docs.rb +85 -0
  44. data/lib/ruby_ui/data_table/data_table.rb +29 -0
  45. data/lib/ruby_ui/data_table/data_table_bulk_actions.rb +18 -0
  46. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +62 -0
  47. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +14 -0
  48. data/lib/ruby_ui/data_table/data_table_controller.js +57 -0
  49. data/lib/ruby_ui/data_table/data_table_docs.rb +180 -0
  50. data/lib/ruby_ui/data_table/data_table_expand_toggle.rb +53 -0
  51. data/lib/ruby_ui/data_table/data_table_form.rb +39 -0
  52. data/lib/ruby_ui/data_table/data_table_kaminari_adapter.rb +17 -0
  53. data/lib/ruby_ui/data_table/data_table_manual_adapter.rb +17 -0
  54. data/lib/ruby_ui/data_table/data_table_pagination.rb +100 -0
  55. data/lib/ruby_ui/data_table/data_table_pagination_bar.rb +15 -0
  56. data/lib/ruby_ui/data_table/data_table_pagy_adapter.rb +17 -0
  57. data/lib/ruby_ui/data_table/data_table_per_page_select.rb +35 -0
  58. data/lib/ruby_ui/data_table/data_table_row_checkbox.rb +30 -0
  59. data/lib/ruby_ui/data_table/data_table_search.rb +57 -0
  60. data/lib/ruby_ui/data_table/data_table_search_controller.js +62 -0
  61. data/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb +21 -0
  62. data/lib/ruby_ui/data_table/data_table_selection_summary.rb +25 -0
  63. data/lib/ruby_ui/data_table/data_table_sort_head.rb +112 -0
  64. data/lib/ruby_ui/data_table/data_table_toolbar.rb +15 -0
  65. data/lib/ruby_ui/dialog/dialog_docs.rb +102 -0
  66. data/lib/ruby_ui/docs/base.rb +90 -0
  67. data/lib/ruby_ui/docs/component_setup_tabs.rb +15 -0
  68. data/lib/ruby_ui/docs/components_table.rb +13 -0
  69. data/lib/ruby_ui/docs/header.rb +17 -0
  70. data/lib/ruby_ui/docs/sidebar_examples.rb +22 -0
  71. data/lib/ruby_ui/docs/visual_code_example.rb +22 -0
  72. data/lib/ruby_ui/dropdown_menu/dropdown_menu_docs.rb +212 -0
  73. data/lib/ruby_ui/form/form_docs.rb +178 -0
  74. data/lib/ruby_ui/form/form_field.rb +1 -1
  75. data/lib/ruby_ui/form/form_field_error.rb +1 -1
  76. data/lib/ruby_ui/form/form_field_hint.rb +1 -1
  77. data/lib/ruby_ui/form/form_field_label.rb +1 -1
  78. data/lib/ruby_ui/hover_card/hover_card_docs.rb +71 -0
  79. data/lib/ruby_ui/input/input.rb +4 -3
  80. data/lib/ruby_ui/input/input_docs.rb +68 -0
  81. data/lib/ruby_ui/link/link_docs.rb +106 -0
  82. data/lib/ruby_ui/masked_input/masked_input.rb +11 -1
  83. data/lib/ruby_ui/masked_input/masked_input_controller.js +13 -0
  84. data/lib/ruby_ui/masked_input/masked_input_docs.rb +47 -0
  85. data/lib/ruby_ui/native_select/native_select.rb +39 -0
  86. data/lib/ruby_ui/native_select/native_select_docs.rb +83 -0
  87. data/lib/ruby_ui/native_select/native_select_group.rb +15 -0
  88. data/lib/ruby_ui/native_select/native_select_icon.rb +39 -0
  89. data/lib/ruby_ui/native_select/native_select_option.rb +15 -0
  90. data/lib/ruby_ui/pagination/pagination_docs.rb +127 -0
  91. data/lib/ruby_ui/popover/popover_docs.rb +971 -0
  92. data/lib/ruby_ui/progress/progress_docs.rb +27 -0
  93. data/lib/ruby_ui/radio_button/radio_button.rb +1 -1
  94. data/lib/ruby_ui/radio_button/radio_button_docs.rb +53 -0
  95. data/lib/ruby_ui/select/select_docs.rb +129 -0
  96. data/lib/ruby_ui/separator/separator_docs.rb +36 -0
  97. data/lib/ruby_ui/sheet/sheet_content.rb +1 -1
  98. data/lib/ruby_ui/sheet/sheet_docs.rb +76 -0
  99. data/lib/ruby_ui/shortcut_key/shortcut_key_docs.rb +29 -0
  100. data/lib/ruby_ui/sidebar/sidebar_docs.rb +176 -0
  101. data/lib/ruby_ui/skeleton/skeleton_docs.rb +29 -0
  102. data/lib/ruby_ui/switch/switch_docs.rb +46 -0
  103. data/lib/ruby_ui/table/table_docs.rb +102 -0
  104. data/lib/ruby_ui/tabs/tabs_docs.rb +211 -0
  105. data/lib/ruby_ui/textarea/textarea_docs.rb +54 -0
  106. data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +71 -0
  107. data/lib/ruby_ui/tooltip/tooltip_docs.rb +52 -0
  108. data/lib/ruby_ui/typography/typography_docs.rb +107 -0
  109. data/lib/ruby_ui.rb +1 -1
  110. metadata +90 -3
@@ -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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class DataTablePaginationBar < Base
5
+ def view_template(&)
6
+ div(**attrs, &)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {class: "flex items-center justify-between gap-4 py-2"}
13
+ end
14
+ end
15
+ 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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class DataTableSearch < Base
5
+ def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, **attrs)
6
+ @path = path
7
+ @name = name
8
+ @value = value
9
+ @frame_id = frame_id
10
+ @placeholder = placeholder
11
+ @debounce = debounce
12
+ @preserved_params = preserved_params
13
+ super(**attrs)
14
+ end
15
+
16
+ def view_template
17
+ form_attrs = {method: "get", action: @path}
18
+ form_attrs[:data] = form_data
19
+
20
+ form(**attrs.merge(form_attrs)) do
21
+ render RubyUI::Input.new(
22
+ type: :search,
23
+ name: @name,
24
+ value: @value,
25
+ placeholder: @placeholder,
26
+ autocomplete: "off"
27
+ )
28
+ @preserved_params.each do |k, v|
29
+ next if v.nil? || (v.respond_to?(:empty?) && v.empty?)
30
+ next if k.to_s == @name
31
+ input(type: "hidden", name: k.to_s, value: v.to_s)
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def debounce_enabled?
39
+ @debounce && @debounce.to_i > 0
40
+ end
41
+
42
+ def form_data
43
+ base = {}
44
+ base[:turbo_frame] = @frame_id if @frame_id
45
+ if debounce_enabled?
46
+ base[:controller] = "ruby-ui--data-table-search"
47
+ base[:"ruby-ui--data-table-search-delay-value"] = @debounce.to_i
48
+ base[:action] = "input->ruby-ui--data-table-search#submit"
49
+ end
50
+ base
51
+ end
52
+
53
+ def default_attrs
54
+ {class: "max-w-sm flex-1"}
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,62 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ // Module-level map survives controller disconnect/connect across Turbo Frame swaps.
4
+ // Keyed by the search form's action URL.
5
+ const PENDING_FOCUS = new Map();
6
+
7
+ export default class extends Controller {
8
+ static values = { delay: { type: Number, default: 300 } };
9
+
10
+ connect() {
11
+ this.timer = null;
12
+ this.beforeFrameRender = this.captureBeforeRender.bind(this);
13
+ document.addEventListener("turbo:before-frame-render", this.beforeFrameRender);
14
+ // New instance after a Turbo Frame swap — check for captured state.
15
+ this.restoreIfPending();
16
+ }
17
+
18
+ disconnect() {
19
+ clearTimeout(this.timer);
20
+ document.removeEventListener("turbo:before-frame-render", this.beforeFrameRender);
21
+ }
22
+
23
+ submit(event) {
24
+ if (event && event.type !== "input") return;
25
+ clearTimeout(this.timer);
26
+ if (this.delayValue <= 0) return;
27
+ this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue);
28
+ }
29
+
30
+ captureBeforeRender() {
31
+ const input = this.input();
32
+ if (!input || document.activeElement !== input) return;
33
+ PENDING_FOCUS.set(this.key(), {
34
+ selectionStart: input.selectionStart,
35
+ selectionEnd: input.selectionEnd
36
+ });
37
+ }
38
+
39
+ restoreIfPending() {
40
+ const state = PENDING_FOCUS.get(this.key());
41
+ if (!state) return;
42
+ PENDING_FOCUS.delete(this.key());
43
+ const input = this.input();
44
+ if (!input) return;
45
+ input.focus();
46
+ const len = input.value.length;
47
+ try {
48
+ input.setSelectionRange(
49
+ Math.min(state.selectionStart ?? len, len),
50
+ Math.min(state.selectionEnd ?? len, len)
51
+ );
52
+ } catch (e) {}
53
+ }
54
+
55
+ input() {
56
+ return this.element.querySelector('input[type="search"]');
57
+ }
58
+
59
+ key() {
60
+ return this.element.action || "_";
61
+ }
62
+ }
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class DataTableSelectAllCheckbox < Base
5
+ def view_template
6
+ render RubyUI::Checkbox.new(**attrs)
7
+ end
8
+
9
+ private
10
+
11
+ def default_attrs
12
+ {
13
+ aria_label: "Select all",
14
+ data: {
15
+ "ruby-ui--data-table-target": "selectAll",
16
+ action: "change->ruby-ui--data-table#toggleAll"
17
+ }
18
+ }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class DataTableSelectionSummary < Base
5
+ def initialize(total_on_page: 0, **attrs)
6
+ @total_on_page = total_on_page
7
+ super(**attrs)
8
+ end
9
+
10
+ def view_template
11
+ div(**attrs) do
12
+ plain "0 of #{@total_on_page} row(s) selected."
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def default_attrs
19
+ {
20
+ class: "text-sm text-muted-foreground",
21
+ data: {"ruby-ui--data-table-target": "selectionSummary"}
22
+ }
23
+ end
24
+ end
25
+ end