From 0100625cb1caec33e356ef400b4f2836cf9ab060 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 29 Apr 2026 18:30:07 -0300 Subject: [PATCH 1/3] [Feature] Add DataTable and NativeSelect component docs Add documentation pages for the new DataTable component family (v1.2.0) with interactive demo featuring search, sort, pagination, column visibility, row selection, and bulk actions. Also copies NativeSelect component (DataTable dependency) and updates layout/preview for better table rendering. --- Gemfile.lock | 248 ++++++------ app/components/docs/visual_code_example.rb | 6 +- .../ruby_ui/data_table/data_table.rb | 29 ++ .../data_table/data_table_bulk_actions.rb | 18 + .../data_table/data_table_column_toggle.rb | 62 +++ .../data_table/data_table_expand_toggle.rb | 53 +++ .../ruby_ui/data_table/data_table_form.rb | 39 ++ .../data_table/data_table_kaminari_adapter.rb | 17 + .../data_table/data_table_manual_adapter.rb | 17 + .../data_table/data_table_pagination.rb | 100 +++++ .../data_table/data_table_pagination_bar.rb | 15 + .../data_table/data_table_pagy_adapter.rb | 17 + .../data_table/data_table_per_page_select.rb | 35 ++ .../data_table/data_table_row_checkbox.rb | 30 ++ .../ruby_ui/data_table/data_table_search.rb | 57 +++ .../data_table_select_all_checkbox.rb | 21 + .../data_table_selection_summary.rb | 25 ++ .../data_table/data_table_sort_head.rb | 112 ++++++ .../ruby_ui/data_table/data_table_toolbar.rb | 15 + app/components/shared/components_list.rb | 1 + .../docs/data_table_demo_controller.rb | 57 +++ app/controllers/docs/data_table_demo_data.rb | 108 ++++++ app/controllers/docs_controller.rb | 4 + app/javascript/controllers/index.js | 9 + ...data_table_column_visibility_controller.js | 14 + .../ruby_ui/data_table_controller.js | 57 +++ .../ruby_ui/data_table_search_controller.js | 62 +++ app/views/docs/data_table.rb | 362 ++++++++++++++++++ app/views/docs/data_table_demo/index.rb | 138 +++++++ app/views/layouts/docs_layout.rb | 2 +- config/initializers/ruby_ui.rb | 5 +- config/routes.rb | 6 + 32 files changed, 1609 insertions(+), 132 deletions(-) create mode 100644 app/components/ruby_ui/data_table/data_table.rb create mode 100644 app/components/ruby_ui/data_table/data_table_bulk_actions.rb create mode 100644 app/components/ruby_ui/data_table/data_table_column_toggle.rb create mode 100644 app/components/ruby_ui/data_table/data_table_expand_toggle.rb create mode 100644 app/components/ruby_ui/data_table/data_table_form.rb create mode 100644 app/components/ruby_ui/data_table/data_table_kaminari_adapter.rb create mode 100644 app/components/ruby_ui/data_table/data_table_manual_adapter.rb create mode 100644 app/components/ruby_ui/data_table/data_table_pagination.rb create mode 100644 app/components/ruby_ui/data_table/data_table_pagination_bar.rb create mode 100644 app/components/ruby_ui/data_table/data_table_pagy_adapter.rb create mode 100644 app/components/ruby_ui/data_table/data_table_per_page_select.rb create mode 100644 app/components/ruby_ui/data_table/data_table_row_checkbox.rb create mode 100644 app/components/ruby_ui/data_table/data_table_search.rb create mode 100644 app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb create mode 100644 app/components/ruby_ui/data_table/data_table_selection_summary.rb create mode 100644 app/components/ruby_ui/data_table/data_table_sort_head.rb create mode 100644 app/components/ruby_ui/data_table/data_table_toolbar.rb create mode 100644 app/controllers/docs/data_table_demo_controller.rb create mode 100644 app/controllers/docs/data_table_demo_data.rb create mode 100644 app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js create mode 100644 app/javascript/controllers/ruby_ui/data_table_controller.js create mode 100644 app/javascript/controllers/ruby_ui/data_table_search_controller.js create mode 100644 app/views/docs/data_table.rb create mode 100644 app/views/docs/data_table_demo/index.rb diff --git a/Gemfile.lock b/Gemfile.lock index ae1fb223..2ede0452 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,39 +14,39 @@ GIT GIT remote: https://github.com/ruby-ui/ruby_ui.git - revision: 856136f40bc4d5be942e39506e56fb08348afc93 + revision: a3e5c8488b11a4c15caf2f2391181f8f057a589f branch: main specs: - ruby_ui (1.1.0) + ruby_ui (1.2.0) GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.18) + action_text-trix (2.1.15) railties - actioncable (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + actioncable (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionmailbox (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) - actionmailer (8.1.3) - actionpack (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activesupport (= 8.1.3) + actionmailer (8.1.1) + actionpack (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activesupport (= 8.1.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.3) - actionview (= 8.1.3) - activesupport (= 8.1.3) + actionpack (8.1.1) + actionview (= 8.1.1) + activesupport (= 8.1.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -54,36 +54,36 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.3) + actiontext (8.1.1) action_text-trix (~> 2.1.15) - actionpack (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + actionpack (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.3) - activesupport (= 8.1.3) + actionview (8.1.1) + activesupport (= 8.1.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.3) - activesupport (= 8.1.3) + activejob (8.1.1) + activesupport (= 8.1.1) globalid (>= 0.3.6) - activemodel (8.1.3) - activesupport (= 8.1.3) - activerecord (8.1.3) - activemodel (= 8.1.3) - activesupport (= 8.1.3) + activemodel (8.1.1) + activesupport (= 8.1.1) + activerecord (8.1.1) + activemodel (= 8.1.1) + activesupport (= 8.1.1) timeout (>= 0.4.0) - activestorage (8.1.3) - actionpack (= 8.1.3) - activejob (= 8.1.3) - activerecord (= 8.1.3) - activesupport (= 8.1.3) + activestorage (8.1.1) + actionpack (= 8.1.1) + activejob (= 8.1.1) + activerecord (= 8.1.1) + activesupport (= 8.1.1) marcel (~> 1.0) - activesupport (8.1.3) + activesupport (8.1.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -100,9 +100,9 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) base64 (0.3.0) - bigdecimal (4.1.2) + bigdecimal (3.3.1) bindex (0.8.1) - bootsnap (1.23.0) + bootsnap (1.19.0) msgpack (~> 1.2) builder (3.3.0) capybara (3.40.0) @@ -115,38 +115,37 @@ GEM regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) coderay (1.1.3) - concurrent-ruby (1.3.6) - connection_pool (3.0.2) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) crass (1.0.6) cssbundling-rails (1.4.3) railties (>= 6.0.0) - date (3.5.1) - debug (1.11.1) + date (3.5.0) + debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) drb (2.2.3) - erb (6.0.2) + erb (6.0.0) erubi (1.13.1) globalid (1.3.0) activesupport (>= 6.1) - i18n (1.14.8) + i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.8.2) - irb (1.17.0) + io-console (0.8.1) + irb (1.15.3) pp (>= 0.6.0) - prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.19.4) + json (2.15.1) language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.25.1) + loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - lucide-rails (0.7.4) + lucide-rails (0.7.1) railties (>= 4.1.0) mail (2.9.0) logger @@ -159,11 +158,9 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.4) - drb (~> 2.0) - prism (~> 1.5) + minitest (5.26.2) msgpack (1.8.0) - net-imap (0.6.3) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -173,66 +170,65 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) - nokogiri (1.19.2) + nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.19.2-x86_64-linux-gnu) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) parallel (1.27.0) - parser (3.3.10.1) + parser (3.3.9.0) ast (~> 2.4.1) racc pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.9.0) - propshaft (1.3.2) + prism (1.6.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - pry (0.16.0) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - reline (>= 0.6.0) - psych (5.3.1) + psych (5.2.6) date stringio public_suffix (6.0.1) - puma (7.2.0) + puma (7.1.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.2.6) - rack-session (2.1.2) + rack (3.2.4) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.3.1) + rackup (2.2.1) rack (>= 3) - rails (8.1.3) - actioncable (= 8.1.3) - actionmailbox (= 8.1.3) - actionmailer (= 8.1.3) - actionpack (= 8.1.3) - actiontext (= 8.1.3) - actionview (= 8.1.3) - activejob (= 8.1.3) - activemodel (= 8.1.3) - activerecord (= 8.1.3) - activestorage (= 8.1.3) - activesupport (= 8.1.3) + rails (8.1.1) + actioncable (= 8.1.1) + actionmailbox (= 8.1.1) + actionmailer (= 8.1.1) + actionpack (= 8.1.1) + actiontext (= 8.1.1) + actionview (= 8.1.1) + activejob (= 8.1.1) + activemodel (= 8.1.1) + activerecord (= 8.1.1) + activestorage (= 8.1.1) + activesupport (= 8.1.1) bundler (>= 1.15.0) - railties (= 8.1.3) + railties (= 8.1.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.0) - loofah (~> 2.25) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.1.3) - actionpack (= 8.1.3) - activesupport (= 8.1.3) + railties (8.1.1) + actionpack (= 8.1.1) + activesupport (= 8.1.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -241,18 +237,16 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) - rdoc (7.2.0) + rdoc (6.15.1) erb psych (>= 4.0.0) tsort - regexp_parser (2.11.3) + regexp_parser (2.11.2) reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) - rouge (4.7.0) - rss (0.3.2) - rexml - rubocop (1.84.2) + rouge (4.6.1) + rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -260,63 +254,64 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.49.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.48.0) parser (>= 3.3.7.2) - prism (~> 1.7) - rubocop-performance (1.26.1) + prism (~> 1.4) + rubocop-performance (1.25.0) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.47.1, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) ruby-progressbar (1.13.0) rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.43.0) + selenium-webdriver (4.38.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) sin_lru_redux (2.5.2) - sqlite3 (2.9.3) + sqlite3 (2.8.0) mini_portile2 (~> 2.8.0) - sqlite3 (2.9.3-x86_64-linux-gnu) - standard (1.54.0) + sqlite3 (2.8.0-x86_64-linux-gnu) + standard (1.52.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.84.0) + rubocop (~> 1.81.7) standard-custom (~> 1.0.0) standard-performance (~> 1.8) standard-custom (1.0.2) lint_roller (~> 1.0) rubocop (~> 1.50) - standard-performance (1.9.0) + standard-performance (1.8.0) lint_roller (~> 1.1) - rubocop-performance (~> 1.26.0) + rubocop-performance (~> 1.25.0) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.2.0) - tailwind_merge (1.4.0) + stringio (3.1.8) + tailwind_merge (1.3.1) sin_lru_redux (~> 2.5) - thor (1.5.0) - timeout (0.6.1) + thor (1.4.0) + timeout (0.4.4) tsort (0.2.0) - turbo-rails (2.0.23) + turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) - unicode-emoji (4.2.0) + unicode-emoji (4.1.0) uri (1.1.1) useragent (0.16.11) - web-console (4.3.0) - actionview (>= 8.0.0) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) bindex (>= 0.4.0) - railties (>= 8.0.0) + railties (>= 6.0.0) websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -324,7 +319,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.5) + zeitwerk (2.7.3) PLATFORMS ruby @@ -336,22 +331,21 @@ DEPENDENCIES cssbundling-rails (= 1.4.3) debug jsbundling-rails (= 1.3.1) - lucide-rails (= 0.7.4) + lucide-rails (= 0.7.1) phlex! phlex-rails! - propshaft (= 1.3.2) - pry (= 0.16.0) - puma (= 7.2.0) - rails (= 8.1.3) - rouge (~> 4.7) - rss (= 0.3.2) + propshaft (= 1.3.1) + pry (= 0.15.2) + puma (= 7.1.0) + rails (= 8.1.1) + rouge (~> 4.6) ruby_ui! selenium-webdriver - sqlite3 (= 2.9.3) + sqlite3 (= 2.8.0) standard stimulus-rails (= 1.3.4) - tailwind_merge (~> 1.4.0) - turbo-rails (= 2.0.23) + tailwind_merge (~> 1.3.1) + turbo-rails (= 2.0.20) tzinfo-data web-console @@ -359,4 +353,4 @@ RUBY VERSION ruby 3.4.7p58 BUNDLED WITH - 2.6.4 + 2.6.9 diff --git a/app/components/docs/visual_code_example.rb b/app/components/docs/visual_code_example.rb index 9f8f2757..84189039 100644 --- a/app/components/docs/visual_code_example.rb +++ b/app/components/docs/visual_code_example.rb @@ -78,8 +78,8 @@ def render_preview_tab(&block) end def iframe_preview - div(class: "relative aspect-[4/2.5] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do - div(class: "absolute inset-0 hidden w-[1600px] bg-background md:block") do + div(class: "relative min-h-[500px] w-full overflow-hidden rounded-md border", data: {controller: "iframe-theme"}) do + div(class: "absolute inset-0 hidden w-full bg-background md:block") do iframe(src: @src, class: "size-full", data: {iframe_theme_target: "iframe"}) end end @@ -87,7 +87,7 @@ def iframe_preview def raw_preview div(class: "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 relative rounded-md border") do - div(class: "preview flex min-h-[350px] w-full justify-center p-10 items-center") do + div(class: "preview min-h-[350px] w-full p-6") do decoded_code = CGI.unescapeHTML(@display_code) @context.instance_eval(decoded_code) end diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb new file mode 100644 index 00000000..8a64aed2 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -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 diff --git a/app/components/ruby_ui/data_table/data_table_bulk_actions.rb b/app/components/ruby_ui/data_table/data_table_bulk_actions.rb new file mode 100644 index 00000000..d5ccb50b --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_bulk_actions.rb @@ -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 diff --git a/app/components/ruby_ui/data_table/data_table_column_toggle.rb b/app/components/ruby_ui/data_table/data_table_column_toggle.rb new file mode 100644 index 00000000..ad20b217 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_column_toggle.rb @@ -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 diff --git a/app/components/ruby_ui/data_table/data_table_expand_toggle.rb b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb new file mode 100644 index 00000000..55f43672 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableExpandToggle < Base + def initialize(controls:, expanded: false, label: "Toggle row details", **attrs) + @controls = controls + @expanded = expanded + @label = label + super(**attrs) + end + + def view_template + button( + type: "button", + aria_expanded: @expanded.to_s, + aria_controls: @controls, + aria_label: @label, + data: { + action: "click->ruby-ui--data-table#toggleRowDetail" + }, + **attrs + ) do + render_icon + end + end + + private + + def render_icon + # inline chevron-right SVG (lucide) + 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: "h-4 w-4 transition-transform duration-150 group-aria-expanded:rotate-90" + ) do |s| + s.polyline(points: "9 18 15 12 9 6") + end + end + + def default_attrs + { + 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" + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_form.rb b/app/components/ruby_ui/data_table/data_table_form.rb new file mode 100644 index 00000000..4708116b --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_form.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableForm < Base + def initialize(action: "", method: "post", id: nil, **attrs) + @action = action + @method = method + @id = id + super(**attrs) + end + + def view_template(&block) + form_attrs = {action: @action, method: @method} + form_attrs[:id] = @id if @id + form(**form_attrs, **attrs) do + input(type: "hidden", name: "authenticity_token", value: csrf_token) + yield if block + end + end + + private + + def csrf_token + # In a Rails app, view_context provides a real CSRF token. + # Outside Rails (gem tests), fall back to a placeholder. + if respond_to?(:helpers, true) && helpers.respond_to?(:form_authenticity_token) + helpers.form_authenticity_token + elsif respond_to?(:view_context, true) && view_context.respond_to?(:form_authenticity_token) + view_context.form_authenticity_token + else + "csrf-token-placeholder" + end + end + + def default_attrs + {} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_kaminari_adapter.rb b/app/components/ruby_ui/data_table/data_table_kaminari_adapter.rb new file mode 100644 index 00000000..6dd83402 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_kaminari_adapter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableKaminariAdapter + def initialize(collection) + @collection = collection + end + + def current_page = @collection.current_page + + def total_pages = @collection.total_pages + + def total_count = @collection.total_count + + def per_page = @collection.limit_value + end +end diff --git a/app/components/ruby_ui/data_table/data_table_manual_adapter.rb b/app/components/ruby_ui/data_table/data_table_manual_adapter.rb new file mode 100644 index 00000000..46b859e6 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_manual_adapter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableManualAdapter + attr_reader :current_page, :per_page, :total_count + + def initialize(page:, per_page:, total_count:) + @current_page = page.to_i + @per_page = [per_page.to_i, 1].max + @total_count = total_count.to_i + end + + def total_pages + [(@total_count.to_f / @per_page).ceil, 1].max + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_pagination.rb b/app/components/ruby_ui/data_table/data_table_pagination.rb new file mode 100644 index 00000000..af12d3f5 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "cgi" +require_relative "data_table_manual_adapter" +require_relative "data_table_pagy_adapter" +require_relative "data_table_kaminari_adapter" + +module RubyUI + class DataTablePagination < Base + 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) + @adapter = resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:) + @page_param = page_param + @path = path + @query = query.to_h.transform_keys(&:to_s) + @window = window + @prev_label = prev_label + @next_label = next_label + super(**attrs) + end + + def view_template + return if total <= 1 + + render RubyUI::Pagination.new(class: "mx-0 w-auto justify-end", **attrs) do + render RubyUI::PaginationContent.new do + prev_item + number_items + next_item + end + end + end + + private + + def resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:) + return with if with + return RubyUI::DataTablePagyAdapter.new(pagy) if pagy + return RubyUI::DataTableKaminariAdapter.new(kaminari) if kaminari + if page && per_page && total_count + return RubyUI::DataTableManualAdapter.new(page:, per_page:, total_count:) + end + raise ArgumentError, "DataTablePagination requires one of: with:, pagy:, kaminari:, or page:+per_page:+total_count:" + end + + def current = @adapter.current_page + + def total = @adapter.total_pages + + def page_href(p) + qs = build_query(@query.merge(@page_param => p.to_s)) + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def build_query(hash) + hash.flat_map { |k, v| + Array(v).map { |val| "#{CGI.escape(k.to_s)}=#{CGI.escape(val.to_s)}" } + }.join("&") + end + + def prev_item + if current <= 1 + li do + span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { @prev_label } + end + else + render RubyUI::PaginationItem.new(href: page_href(current - 1)) { @prev_label } + end + end + + def next_item + if current >= total + li do + span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { @next_label } + end + else + render RubyUI::PaginationItem.new(href: page_href(current + 1)) { @next_label } + end + end + + def number_items + windowed_pages.each do |p| + if p == :gap + render RubyUI::PaginationEllipsis.new + else + render RubyUI::PaginationItem.new(href: page_href(p), active: p == current) { plain p.to_s } + end + end + end + + def windowed_pages + return (1..total).to_a if total <= 7 + pages = [1] + pages << :gap if current - @window > 2 + ((current - @window)..(current + @window)).each { |p| pages << p if p > 1 && p < total } + pages << :gap if current + @window < total - 1 + pages << total + pages + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_pagination_bar.rb b/app/components/ruby_ui/data_table/data_table_pagination_bar.rb new file mode 100644 index 00000000..c980890e --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_pagination_bar.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePaginationBar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + {class: "flex items-center justify-between gap-4 py-2"} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_pagy_adapter.rb b/app/components/ruby_ui/data_table/data_table_pagy_adapter.rb new file mode 100644 index 00000000..fad905d3 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_pagy_adapter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePagyAdapter + def initialize(pagy) + @pagy = pagy + end + + def current_page = @pagy.page + + def total_pages = @pagy.pages + + def total_count = @pagy.count + + def per_page = @pagy.items + end +end diff --git a/app/components/ruby_ui/data_table/data_table_per_page_select.rb b/app/components/ruby_ui/data_table/data_table_per_page_select.rb new file mode 100644 index 00000000..d3c88f8b --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_per_page_select.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePerPageSelect < Base + def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [5, 10, 25, 50], **attrs) + @path = path + @name = name + @value = value + @frame_id = frame_id + @options = options + super(**attrs) + end + + def view_template + form_attrs = {action: @path, method: "get"} + form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id + + form(**attrs.merge(form_attrs)) do + render RubyUI::NativeSelect.new(name: @name, onchange: safe("this.form.requestSubmit()")) do + @options.each do |opt| + option_attrs = {value: opt.to_s} + option_attrs[:selected] = true if opt.to_s == @value.to_s + option(**option_attrs) { plain opt.to_s } + end + end + end + end + + private + + def default_attrs + {} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_row_checkbox.rb b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb new file mode 100644 index 00000000..0eba666a --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableRowCheckbox < Base + def initialize(value:, name: "ids[]", label: nil, **attrs) + @value = value + @name = name + @label = label + super(**attrs) + end + + def view_template + render RubyUI::Checkbox.new(**attrs) + end + + private + + def default_attrs + { + name: @name, + value: @value, + aria_label: @label || "Select row #{@value}", + data: { + "ruby-ui--data-table-target": "rowCheckbox", + action: "change->ruby-ui--data-table#toggleRow" + } + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_search.rb b/app/components/ruby_ui/data_table/data_table_search.rb new file mode 100644 index 00000000..8e9b01d9 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_search.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSearch < Base + def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, **attrs) + @path = path + @name = name + @value = value + @frame_id = frame_id + @placeholder = placeholder + @debounce = debounce + @preserved_params = preserved_params + super(**attrs) + end + + def view_template + form_attrs = {method: "get", action: @path} + form_attrs[:data] = form_data + + form(**attrs.merge(form_attrs)) do + render RubyUI::Input.new( + type: :search, + name: @name, + value: @value, + placeholder: @placeholder, + autocomplete: "off" + ) + @preserved_params.each do |k, v| + next if v.nil? || (v.respond_to?(:empty?) && v.empty?) + next if k.to_s == @name + input(type: "hidden", name: k.to_s, value: v.to_s) + end + end + end + + private + + def debounce_enabled? + @debounce && @debounce.to_i > 0 + end + + def form_data + base = {} + base[:turbo_frame] = @frame_id if @frame_id + if debounce_enabled? + base[:controller] = "ruby-ui--data-table-search" + base[:"ruby-ui--data-table-search-delay-value"] = @debounce.to_i + base[:action] = "input->ruby-ui--data-table-search#submit" + end + base + end + + def default_attrs + {class: "max-w-sm flex-1"} + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb b/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb new file mode 100644 index 00000000..d1478f9e --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSelectAllCheckbox < Base + def view_template + render RubyUI::Checkbox.new(**attrs) + end + + private + + def default_attrs + { + aria_label: "Select all", + data: { + "ruby-ui--data-table-target": "selectAll", + action: "change->ruby-ui--data-table#toggleAll" + } + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_selection_summary.rb b/app/components/ruby_ui/data_table/data_table_selection_summary.rb new file mode 100644 index 00000000..455e5f1c --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_selection_summary.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSelectionSummary < Base + def initialize(total_on_page: 0, **attrs) + @total_on_page = total_on_page + super(**attrs) + end + + def view_template + div(**attrs) do + plain "0 of #{@total_on_page} row(s) selected." + end + end + + private + + def default_attrs + { + class: "text-sm text-muted-foreground", + data: {"ruby-ui--data-table-target": "selectionSummary"} + } + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_sort_head.rb b/app/components/ruby_ui/data_table/data_table_sort_head.rb new file mode 100644 index 00000000..7cd174c7 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_sort_head.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "cgi" + +module RubyUI + class DataTableSortHead < Base + def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort", direction_param: "direction", page_param: "page", path: "", query: {}, **attrs) + @column_key = column_key + @label = label + @sort = sort + @direction = direction + @sort_param = sort_param + @direction_param = direction_param + @page_param = page_param + @path = path + @query = query.to_h.transform_keys(&:to_s) + super(**attrs) + end + + def view_template + render RubyUI::TableHead.new(class: "text-foreground whitespace-nowrap", **attrs) do + a(href: sort_href, class: "inline-flex items-center gap-1 text-inherit no-underline hover:text-foreground transition-colors") do + plain @label + sort_icon + end + end + end + + private + + def current_direction + (@sort.to_s == @column_key.to_s) ? @direction : nil + end + + def next_params + next_dir = {nil => "asc", "asc" => "desc", "desc" => nil}[current_direction] + base = @query.except(@sort_param, @direction_param, @page_param) + next_dir ? base.merge(@sort_param => @column_key.to_s, @direction_param => next_dir) : base + end + + def sort_href + qs = build_query(next_params) + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def build_query(hash) + hash.flat_map { |k, v| + Array(v).map { |val| "#{CGI.escape(k.to_s)}=#{CGI.escape(val.to_s)}" } + }.join("&") + end + + def sort_icon + icon_name = case current_direction + when "asc" then :chevron_up + when "desc" then :chevron_down + else :chevrons_up_down + end + icon_class = current_direction ? "inline-block w-3 h-3" : "inline-block w-3 h-3 opacity-30" + render_sort_svg(icon_name, icon_class) + end + + def render_sort_svg(icon_name, icon_class) + case icon_name + when :chevron_up + # chevron-up: polyline pointing up + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "12", + height: "12", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: icon_class + ) { |s| s.polyline(points: "18 15 12 9 6 15") } + when :chevron_down + # chevron-down: polyline pointing down + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "12", + height: "12", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: icon_class + ) { |s| s.polyline(points: "6 9 12 15 18 9") } + else + # chevrons-up-down + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "12", + height: "12", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + stroke_width: "2", + stroke_linecap: "round", + stroke_linejoin: "round", + class: icon_class + ) do |s| + s.polyline(points: "8 15 12 19 16 15") + s.polyline(points: "8 9 12 5 16 9") + end + end + end + end +end diff --git a/app/components/ruby_ui/data_table/data_table_toolbar.rb b/app/components/ruby_ui/data_table/data_table_toolbar.rb new file mode 100644 index 00000000..e94867a2 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_toolbar.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableToolbar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + {class: "flex items-center justify-between gap-2"} + end + end +end diff --git a/app/components/shared/components_list.rb b/app/components/shared/components_list.rb index 49c82490..9f2775c1 100644 --- a/app/components/shared/components_list.rb +++ b/app/components/shared/components_list.rb @@ -25,6 +25,7 @@ def components {name: "Combobox", path: docs_combobox_path}, {name: "Command", path: docs_command_path}, {name: "Context Menu", path: docs_context_menu_path}, + {name: "Data Table", path: docs_data_table_path}, {name: "Date Picker", path: docs_date_picker_path}, {name: "Dialog / Modal", path: docs_dialog_path}, {name: "Dropdown Menu", path: docs_dropdown_menu_path}, diff --git a/app/controllers/docs/data_table_demo_controller.rb b/app/controllers/docs/data_table_demo_controller.rb new file mode 100644 index 00000000..92035286 --- /dev/null +++ b/app/controllers/docs/data_table_demo_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Docs + class DataTableDemoController < ApplicationController + layout -> { Views::Layouts::ExamplesLayout } + + def index + employees = DataTableDemoData::EMPLOYEES.dup + + if params[:search].present? + q = params[:search].downcase + employees = employees.select { |e| e.name.downcase.include?(q) || e.email.downcase.include?(q) } + end + + if params[:sort].present? + col = params[:sort].to_sym + if employees.first&.respond_to?(col) + employees = employees.sort_by do |e| + v = e.send(col) + v.is_a?(Numeric) ? v : v.to_s.downcase + end + employees = employees.reverse if params[:direction] == "desc" + end + end + + @total_count = employees.size + @per_page = (params[:per_page] || 5).to_i.clamp(1, 100) + @total_pages = [(@total_count.to_f / @per_page).ceil, 1].max + @page = (params[:page] || 1).to_i.clamp(1, @total_pages) + + offset = (@page - 1) * @per_page + @employees = employees.slice(offset, @per_page) || [] + + render Views::Docs::DataTableDemo::Index.new( + employees: @employees, + total_count: @total_count, + page: @page, + per_page: @per_page, + sort: params[:sort], + direction: params[:direction], + search: params[:search] + ) + end + + def bulk_delete + ids = Array(params[:ids]).map(&:to_s) + flash[:notice] = "Would delete: #{ids.join(", ")}" + redirect_to docs_data_table_demo_path + end + + def bulk_export + ids = Array(params[:ids]).map(&:to_s) + flash[:notice] = "Would export: #{ids.join(", ")}" + redirect_to docs_data_table_demo_path + end + end +end diff --git a/app/controllers/docs/data_table_demo_data.rb b/app/controllers/docs/data_table_demo_data.rb new file mode 100644 index 00000000..a826b3e1 --- /dev/null +++ b/app/controllers/docs/data_table_demo_data.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Docs + module DataTableDemoData + EMPLOYEES = [ + {id: 1, name: "Alice Johnson", email: "alice.johnson@example.com", department: "Engineering", status: "Active", salary: 95_000}, + {id: 2, name: "Bob Smith", email: "bob.smith@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 3, name: "Carol White", email: "carol.white@example.com", department: "Product", status: "On Leave", salary: 88_000}, + {id: 4, name: "David Brown", email: "david.brown@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 5, name: "Eve Davis", email: "eve.davis@example.com", department: "Marketing", status: "Inactive", salary: 74_000}, + {id: 6, name: "Frank Miller", email: "frank.miller@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 7, name: "Grace Lee", email: "grace.lee@example.com", department: "HR", status: "Active", salary: 60_000}, + {id: 8, name: "Henry Wilson", email: "henry.wilson@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 9, name: "Iris Martinez", email: "iris.martinez@example.com", department: "Design", status: "Inactive", salary: 79_000}, + {id: 10, name: "Jack Taylor", email: "jack.taylor@example.com", department: "Engineering", status: "Active", salary: 110_000}, + {id: 11, name: "Karen Anderson", email: "karen.anderson@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 12, name: "Liam Thomas", email: "liam.thomas@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 13, name: "Mia Jackson", email: "mia.jackson@example.com", department: "Engineering", status: "On Leave", salary: 96_000}, + {id: 14, name: "Noah Harris", email: "noah.harris@example.com", department: "Finance", status: "Active", salary: 89_000}, + {id: 15, name: "Olivia Clark", email: "olivia.clark@example.com", department: "HR", status: "Active", salary: 68_000}, + {id: 16, name: "Paul Lewis", email: "paul.lewis@example.com", department: "Design", status: "Active", salary: 84_000}, + {id: 17, name: "Quinn Robinson", email: "quinn.robinson@example.com", department: "Engineering", status: "Active", salary: 105_000}, + {id: 18, name: "Rachel Walker", email: "rachel.walker@example.com", department: "Product", status: "Inactive", salary: 87_000}, + {id: 19, name: "Sam Young", email: "sam.young@example.com", department: "Marketing", status: "Active", salary: 72_000}, + {id: 20, name: "Tina Hall", email: "tina.hall@example.com", department: "Finance", status: "Active", salary: 91_000}, + {id: 21, name: "Uma Allen", email: "uma.allen@example.com", department: "Engineering", status: "Active", salary: 99_000}, + {id: 22, name: "Victor Scott", email: "victor.scott@example.com", department: "Design", status: "On Leave", salary: 81_000}, + {id: 23, name: "Wendy Green", email: "wendy.green@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 24, name: "Xander Baker", email: "xander.baker@example.com", department: "Engineering", status: "Active", salary: 108_000}, + {id: 25, name: "Yara Adams", email: "yara.adams@example.com", department: "Product", status: "Active", salary: 93_000}, + {id: 26, name: "Zoe Nelson", email: "zoe.nelson@example.com", department: "Marketing", status: "Inactive", salary: 73_000}, + {id: 27, name: "Aaron Carter", email: "aaron.carter@example.com", department: "Finance", status: "Active", salary: 86_000}, + {id: 28, name: "Bella Mitchell", email: "bella.mitchell@example.com", department: "Engineering", status: "Active", salary: 101_000}, + {id: 29, name: "Carlos Perez", email: "carlos.perez@example.com", department: "Design", status: "Active", salary: 83_000}, + {id: 30, name: "Diana Roberts", email: "diana.roberts@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 31, name: "Ethan Turner", email: "ethan.turner@example.com", department: "Engineering", status: "Active", salary: 97_000}, + {id: 32, name: "Fiona Phillips", email: "fiona.phillips@example.com", department: "HR", status: "Inactive", salary: 69_000}, + {id: 33, name: "George Campbell", email: "george.campbell@example.com", department: "Finance", status: "Active", salary: 94_000}, + {id: 34, name: "Hannah Parker", email: "hannah.parker@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 35, name: "Ivan Evans", email: "ivan.evans@example.com", department: "Engineering", status: "On Leave", salary: 103_000}, + {id: 36, name: "Julia Edwards", email: "julia.edwards@example.com", department: "Design", status: "Active", salary: 80_000}, + {id: 37, name: "Kevin Collins", email: "kevin.collins@example.com", department: "Product", status: "Active", salary: 91_000}, + {id: 38, name: "Laura Stewart", email: "laura.stewart@example.com", department: "Engineering", status: "Active", salary: 106_000}, + {id: 39, name: "Marcus Sanchez", email: "marcus.sanchez@example.com", department: "Finance", status: "Active", salary: 88_000}, + {id: 40, name: "Nina Morris", email: "nina.morris@example.com", department: "HR", status: "Active", salary: 72_000}, + {id: 41, name: "Oscar Rogers", email: "oscar.rogers@example.com", department: "Marketing", status: "Inactive", salary: 75_000}, + {id: 42, name: "Penny Reed", email: "penny.reed@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 43, name: "Quincy Cook", email: "quincy.cook@example.com", department: "Engineering", status: "Active", salary: 100_000}, + {id: 44, name: "Rose Morgan", email: "rose.morgan@example.com", department: "Product", status: "Active", salary: 89_000}, + {id: 45, name: "Steve Bell", email: "steve.bell@example.com", department: "Finance", status: "On Leave", salary: 87_000}, + {id: 46, name: "Tara Murphy", email: "tara.murphy@example.com", department: "Engineering", status: "Active", salary: 104_000}, + {id: 47, name: "Umar Bailey", email: "umar.bailey@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 48, name: "Vera Rivera", email: "vera.rivera@example.com", department: "Marketing", status: "Active", salary: 78_000}, + {id: 49, name: "William Cooper", email: "william.cooper@example.com", department: "Design", status: "Inactive", salary: 81_000}, + {id: 50, name: "Xena Richardson", email: "xena.richardson@example.com", department: "Engineering", status: "Active", salary: 107_000}, + {id: 51, name: "Yasmine Cox", email: "yasmine.cox@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 52, name: "Zachary Howard", email: "zachary.howard@example.com", department: "Finance", status: "Active", salary: 85_000}, + {id: 53, name: "Amber Ward", email: "amber.ward@example.com", department: "Engineering", status: "Active", salary: 96_000}, + {id: 54, name: "Blake Torres", email: "blake.torres@example.com", department: "HR", status: "On Leave", salary: 71_000}, + {id: 55, name: "Chloe Peterson", email: "chloe.peterson@example.com", department: "Marketing", status: "Active", salary: 74_000}, + {id: 56, name: "Derek Gray", email: "derek.gray@example.com", department: "Design", status: "Active", salary: 83_000}, + {id: 57, name: "Elena Ramirez", email: "elena.ramirez@example.com", department: "Engineering", status: "Active", salary: 101_000}, + {id: 58, name: "Felix James", email: "felix.james@example.com", department: "Finance", status: "Inactive", salary: 88_000}, + {id: 59, name: "Gina Watson", email: "gina.watson@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 60, name: "Hugo Brooks", email: "hugo.brooks@example.com", department: "Engineering", status: "Active", salary: 109_000}, + {id: 61, name: "Irene Kelly", email: "irene.kelly@example.com", department: "HR", status: "Active", salary: 68_000}, + {id: 62, name: "Jonas Sanders", email: "jonas.sanders@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 63, name: "Kira Price", email: "kira.price@example.com", department: "Design", status: "On Leave", salary: 80_000}, + {id: 64, name: "Leo Bennett", email: "leo.bennett@example.com", department: "Engineering", status: "Active", salary: 98_000}, + {id: 65, name: "Maya Wood", email: "maya.wood@example.com", department: "Finance", status: "Active", salary: 91_000}, + {id: 66, name: "Nate Barnes", email: "nate.barnes@example.com", department: "Product", status: "Active", salary: 93_000}, + {id: 67, name: "Odessa Ross", email: "odessa.ross@example.com", department: "Engineering", status: "Inactive", salary: 97_000}, + {id: 68, name: "Pierce Henderson", email: "pierce.henderson@example.com", department: "HR", status: "Active", salary: 73_000}, + {id: 69, name: "Quinn Coleman", email: "quinn.coleman@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 70, name: "Ruby Jenkins", email: "ruby.jenkins@example.com", department: "Design", status: "Active", salary: 84_000}, + {id: 71, name: "Seth Perry", email: "seth.perry@example.com", department: "Engineering", status: "Active", salary: 103_000}, + {id: 72, name: "Tatum Powell", email: "tatum.powell@example.com", department: "Finance", status: "On Leave", salary: 86_000}, + {id: 73, name: "Uma Long", email: "uma.long@example.com", department: "Product", status: "Active", salary: 89_000}, + {id: 74, name: "Vince Patterson", email: "vince.patterson@example.com", department: "Engineering", status: "Active", salary: 105_000}, + {id: 75, name: "Willa Hughes", email: "willa.hughes@example.com", department: "HR", status: "Active", salary: 69_000}, + {id: 76, name: "Xander Flores", email: "xander.flores@example.com", department: "Marketing", status: "Inactive", salary: 75_000}, + {id: 77, name: "Yolanda Washington", email: "yolanda.washington@example.com", department: "Design", status: "Active", salary: 82_000}, + {id: 78, name: "Zack Butler", email: "zack.butler@example.com", department: "Engineering", status: "Active", salary: 100_000}, + {id: 79, name: "Alicia Simmons", email: "alicia.simmons@example.com", department: "Finance", status: "Active", salary: 87_000}, + {id: 80, name: "Brett Foster", email: "brett.foster@example.com", department: "Product", status: "Active", salary: 92_000}, + {id: 81, name: "Cassie Gonzales", email: "cassie.gonzales@example.com", department: "Engineering", status: "On Leave", salary: 99_000}, + {id: 82, name: "Drew Bryant", email: "drew.bryant@example.com", department: "HR", status: "Active", salary: 71_000}, + {id: 83, name: "Elsa Alexander", email: "elsa.alexander@example.com", department: "Marketing", status: "Active", salary: 78_000}, + {id: 84, name: "Floyd Russell", email: "floyd.russell@example.com", department: "Design", status: "Active", salary: 81_000}, + {id: 85, name: "Greta Griffin", email: "greta.griffin@example.com", department: "Engineering", status: "Active", salary: 107_000}, + {id: 86, name: "Hector Diaz", email: "hector.diaz@example.com", department: "Finance", status: "Inactive", salary: 85_000}, + {id: 87, name: "Isla Hayes", email: "isla.hayes@example.com", department: "Product", status: "Active", salary: 91_000}, + {id: 88, name: "Jared Myers", email: "jared.myers@example.com", department: "Engineering", status: "Active", salary: 102_000}, + {id: 89, name: "Kara Ford", email: "kara.ford@example.com", department: "HR", status: "Active", salary: 70_000}, + {id: 90, name: "Lionel Hamilton", email: "lionel.hamilton@example.com", department: "Marketing", status: "Active", salary: 76_000}, + {id: 91, name: "Mabel Graham", email: "mabel.graham@example.com", department: "Design", status: "On Leave", salary: 83_000}, + {id: 92, name: "Nolan Sullivan", email: "nolan.sullivan@example.com", department: "Engineering", status: "Active", salary: 106_000}, + {id: 93, name: "Opal Wallace", email: "opal.wallace@example.com", department: "Finance", status: "Active", salary: 88_000}, + {id: 94, name: "Preston Woods", email: "preston.woods@example.com", department: "Product", status: "Active", salary: 90_000}, + {id: 95, name: "Queenie Cole", email: "queenie.cole@example.com", department: "Engineering", status: "Inactive", salary: 95_000}, + {id: 96, name: "Regan West", email: "regan.west@example.com", department: "HR", status: "Active", salary: 72_000}, + {id: 97, name: "Spencer Jordan", email: "spencer.jordan@example.com", department: "Marketing", status: "Active", salary: 77_000}, + {id: 98, name: "Tess Owens", email: "tess.owens@example.com", department: "Design", status: "Active", salary: 80_000}, + {id: 99, name: "Uriah Reynolds", email: "uriah.reynolds@example.com", department: "Engineering", status: "Active", salary: 104_000}, + {id: 100, name: "Violet Fisher", email: "violet.fisher@example.com", department: "Finance", status: "Active", salary: 86_000} + ].map { |e| Data.define(*e.keys).new(**e) }.freeze + end +end diff --git a/app/controllers/docs_controller.rb b/app/controllers/docs_controller.rb index 071964b0..f60ca123 100644 --- a/app/controllers/docs_controller.rb +++ b/app/controllers/docs_controller.rb @@ -122,6 +122,10 @@ def context_menu render Views::Docs::ContextMenu.new end + def data_table + render Views::Docs::DataTable.new + end + def date_picker render Views::Docs::DatePicker.new end diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index e92a12d6..e68815bd 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -43,6 +43,15 @@ application.register("ruby-ui--command", RubyUi__CommandController) import RubyUi__ContextMenuController from "./ruby_ui/context_menu_controller" application.register("ruby-ui--context-menu", RubyUi__ContextMenuController) +import RubyUi__DataTableController from "./ruby_ui/data_table_controller" +application.register("ruby-ui--data-table", RubyUi__DataTableController) + +import RubyUi__DataTableColumnVisibilityController from "./ruby_ui/data_table_column_visibility_controller" +application.register("ruby-ui--data-table-column-visibility", RubyUi__DataTableColumnVisibilityController) + +import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller" +application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController) + import RubyUi__DialogController from "./ruby_ui/dialog_controller" application.register("ruby-ui--dialog", RubyUi__DialogController) diff --git a/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js b/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js new file mode 100644 index 00000000..d3cb0584 --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js @@ -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)); + } +} diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js new file mode 100644 index 00000000..1ffb8fb2 --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -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); + } + } +} diff --git a/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/app/javascript/controllers/ruby_ui/data_table_search_controller.js new file mode 100644 index 00000000..0dc4101c --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_search_controller.js @@ -0,0 +1,62 @@ +import { Controller } from "@hotwired/stimulus"; + +// Module-level map survives controller disconnect/connect across Turbo Frame swaps. +// Keyed by the search form's action URL. +const PENDING_FOCUS = new Map(); + +export default class extends Controller { + static values = { delay: { type: Number, default: 300 } }; + + connect() { + this.timer = null; + this.beforeFrameRender = this.captureBeforeRender.bind(this); + document.addEventListener("turbo:before-frame-render", this.beforeFrameRender); + // New instance after a Turbo Frame swap — check for captured state. + this.restoreIfPending(); + } + + disconnect() { + clearTimeout(this.timer); + document.removeEventListener("turbo:before-frame-render", this.beforeFrameRender); + } + + submit(event) { + if (event && event.type !== "input") return; + clearTimeout(this.timer); + if (this.delayValue <= 0) return; + this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue); + } + + captureBeforeRender() { + const input = this.input(); + if (!input || document.activeElement !== input) return; + PENDING_FOCUS.set(this.key(), { + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd + }); + } + + restoreIfPending() { + const state = PENDING_FOCUS.get(this.key()); + if (!state) return; + PENDING_FOCUS.delete(this.key()); + const input = this.input(); + if (!input) return; + input.focus(); + const len = input.value.length; + try { + input.setSelectionRange( + Math.min(state.selectionStart ?? len, len), + Math.min(state.selectionEnd ?? len, len) + ); + } catch (e) {} + } + + input() { + return this.element.querySelector('input[type="search"]'); + } + + key() { + return this.element.action || "_"; + } +} diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb new file mode 100644 index 00000000..f77dcb09 --- /dev/null +++ b/app/views/docs/data_table.rb @@ -0,0 +1,362 @@ +# frozen_string_literal: true + +class Views::Docs::DataTable < Views::Base + Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true) + + def view_template + @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") + ] + @page = 1 + @per_page = 10 + @total = 2 + + div(class: "mx-auto w-full py-10 space-y-10") do + render Docs::Header.new( + title: "Data Table", + 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) { "Complete demo" } + p(class: "-mt-6") { "Full feature set — search, sort, numbered pagination, per-page, select-all, row checkboxes, bulk actions, row actions dropdown, column visibility, badge cells." } + + render Docs::VisualCodeExample.new(title: "Complete demo", src: "/docs/data_table_demo", context: self) do + <<~RUBY + FORM_ID = "employees_form" + + DataTable(id: "employees_list") do + DataTableToolbar do + DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search) + div(class: "flex items-center gap-2") do + DataTableColumnToggle(columns: [ + {key: :email, label: "Email"}, + {key: :department, label: "Department"} + ]) + DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page) + DataTableBulkActions do + Button(type: "submit", form: FORM_ID, formaction: bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", form: FORM_ID, formaction: bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + end + 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() } + DataTableSortHead(column_key: :name, label: "Name", sort: @sort, direction: @direction, path: docs_data_table_demo_path) + DataTableSortHead(column_key: :salary, label: "Salary", sort: @sort, direction: @direction, path: docs_data_table_demo_path) + end + end + TableBody do + @employees.each do |e| + TableRow do + TableCell { DataTableRowCheckbox(value: e.id) } + TableCell { e.name } + TableCell { e.salary } + end + end + end + end + end + end + + DataTablePaginationBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path) + end + end + RUBY + end + + Heading(level: 2) { "Server-driven" } + p(class: "-mt-6") { "Turbo Frame GET on each sort/search/page. No client-only state." } + + render Docs::VisualCodeExample.new(title: "Server-driven", context: self) do + <<~RUBY + DataTable(id: "server") do + DataTableToolbar do + DataTableSearch(path: my_path, preserved_params: {"sort" => @sort, "direction" => @direction}.compact_blank) + end + + Table do + TableHeader do + TableRow do + DataTableSortHead(column_key: :name, label: "Name", path: my_path) + end + end + TableBody do + @rows.each { |r| TableRow { TableCell { r.name } } } + end + end + + DataTablePagination(page: @page, per_page: @per_page, total_count: @total, path: my_path) + end + RUBY + end + + Heading(level: 2) { "Selection + bulk actions" } + p(class: "-mt-6") { "DataTableBulkActions is a plain slot — put any Phlex content inside. Row checkboxes are elements inside DataTableForm. Bulk action buttons submit that form with the selected IDs via HTML5 form-association attributes." } + + render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do + <<~RUBY + DataTable(id: "selection") do + DataTableToolbar do + div + DataTableBulkActions do + Button(type: "submit", form: "selection_form", + formaction: bulk_delete_path, formmethod: "post", + data: {turbo_confirm: "Delete selected?"}, + variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", form: "selection_form", + formaction: bulk_export_path, formmethod: "post", + variant: :outline, size: :sm) { "Export" } + end + end + + DataTableForm(id: "selection_form", action: "") do + Table do + TableHeader do + TableRow do + TableHead { DataTableSelectAllCheckbox() } + TableHead { "Name" } + end + end + TableBody do + @rows.each do |r| + TableRow do + TableCell { DataTableRowCheckbox(value: r.id) } + TableCell { r.name } + end + end + end + end + end + + DataTableSelectionSummary(total_on_page: @rows.size) + end + RUBY + end + + Heading(level: 3) { "Bulk action button attributes" } + p { "Because the submit buttons live inside DataTableToolbar (outside DataTableForm), you must use HTML5 form-association attributes to wire them up. Server receives params[:ids] as an array." } + + Table do + TableHeader do + TableRow do + TableHead { "Attribute" } + TableHead { "Required" } + TableHead { "Purpose" } + end + end + TableBody do + [ + ['type: "submit"', "yes", "native submit button"], + ["form: FORM_ID", "yes (button is outside DataTableForm)", "HTML5 form-association — lets the button submit a form located elsewhere in the DOM"], + ["formaction: \"/path\"", "yes", "target URL, overrides the form's action"], + ["formmethod: \"post\"", "yes", "HTTP verb, overrides the form's method"], + ["formnovalidate: true", "optional", "skip HTML5 validation"], + ["data: {turbo_confirm: \"Are you sure?\"}", "optional", "Rails/Turbo confirmation dialog before submit"] + ].each do |attr, required, purpose| + TableRow do + TableCell { code(class: "font-mono text-xs") { plain attr } } + TableCell { plain required } + TableCell { plain purpose } + end + end + end + end + + p { "For simpler bulk actions that include CSRF and Turbo confirms out of the box, you can use Rails' #{code(class: "font-mono text-xs") { "button_to" }} helper — e.g. #{code(class: "font-mono text-xs") { 'button_to "Delete", path, method: :delete, form: {data: {turbo_confirm: "..."}}' }} — the button will carry a nested form that submits to the given path." } + + Heading(level: 3) { "Rails controller example" } + p { "Your endpoint receives the selected IDs as params[:ids] (an array of strings):" } + + Codeblock(<<~RUBY, syntax: :ruby) + class EmployeesController < ApplicationController + def bulk_delete + ids = Array(params[:ids]).map(&:to_i) + Employee.where(id: ids).destroy_all + redirect_to employees_path, notice: "Deleted \#{ids.size} employees" + end + + def bulk_export + ids = Array(params[:ids]).map(&:to_i) + employees = Employee.where(id: ids) + send_data employees.to_csv, filename: "employees.csv" + end + end + RUBY + + Heading(level: 2) { "Column visibility" } + p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." } + p { "Column visibility is client-side and resets on every Turbo Frame swap (sort/search/page re-renders). If you need it to persist, encode it in a URL param (e.g. `?columns=name,status`) or store in localStorage." } + + render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do + <<~RUBY + DataTable(id: "columns") 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 + + Heading(level: 2) { "Custom cell renderers" } + p(class: "-mt-6") { "Plain Ruby helpers for badge/date/currency — the gem does not ship renderers." } + + render Docs::VisualCodeExample.new(title: "Custom cell renderers", context: self) do + <<~RUBY + def status_badge(status) + variant = {"Active" => :success, "Inactive" => :destructive}.fetch(status, :outline) + Badge(variant: variant, size: :sm) { plain status } + end + + DataTable(id: "renderers") do + Table do + TableHeader do + TableRow do + TableHead { "Name" } + TableHead { "Status" } + TableHead(class: "text-right") { "Salary" } + end + end + TableBody do + @rows.each do |r| + TableRow do + TableCell { r.name } + TableCell { status_badge(r.status) } + TableCell(class: "text-right") { plain view_context.number_to_currency(r.salary, precision: 0) } + end + end + end + end + end + RUBY + end + + Heading(level: 2) { "Expandable rows" } + p(class: "-mt-6") { "Toggle a detail region below each row. Accessible: aria-expanded, aria-controls, keyboard-focusable button, region role on the expanded content." } + + render Docs::VisualCodeExample.new(title: "Expandable rows", context: self) do + <<~RUBY + DataTable(id: "expand_demo") do + Table do + TableHeader do + TableRow do + TableHead(class: "w-10") { } + TableHead { "Name" } + TableHead { "Role" } + end + end + TableBody do + @rows.each do |r| + detail_id = "row-\#{r.id}-detail" + TableRow do + TableCell { DataTableExpandToggle(controls: detail_id, label: "Toggle details for \#{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 space-y-1") do + p { "Salary: $\#{r.salary}" } + p { "Status: \#{r.status}" } + end + end + end + end + end + end + end + RUBY + end + + Heading(level: 2) { "Pagination adapters" } + p { "DataTablePagination accepts a pagination source via one of four keyword forms. Each resolves to an internal adapter exposing current_page, total_pages, total_count, and per_page." } + + Heading(level: 3) { "Manual" } + p { "No gem required. Pass page/per_page/total_count directly." } + Codeblock(<<~RUBY, syntax: :ruby) + DataTablePagination( + page: @page, + per_page: @per_page, + total_count: @total_count, + path: employees_path + ) + RUBY + + Heading(level: 3) { "Pagy" } + p { "If you use Pagy, pass the pagy object directly." } + Codeblock(<<~RUBY, syntax: :ruby) + @pagy, @employees = pagy(Employee.all) + + DataTablePagination(pagy: @pagy, path: employees_path) + RUBY + + Heading(level: 3) { "Kaminari" } + p { "If you use Kaminari, pass the paginated collection." } + Codeblock(<<~RUBY, syntax: :ruby) + @employees = Employee.page(params[:page]).per(25) + + DataTablePagination(kaminari: @employees, path: employees_path) + RUBY + + Heading(level: 3) { "Custom adapter" } + p { "Any object responding to current_page, total_pages, total_count and per_page works via the with: keyword. Useful when wrapping a different gem or custom pagination logic." } + Codeblock(<<~RUBY, syntax: :ruby) + class MyAdapter + def initialize(result) + @result = result + end + + def current_page = @result.page + def total_pages = @result.total_pages + def total_count = @result.count + def per_page = @result.limit + end + + DataTablePagination(with: MyAdapter.new(@result), path: employees_path) + RUBY + end + end + + private + + def my_path + "#" + end + + def bulk_delete_path + "#" + end + + def bulk_export_path + "#" + end +end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb new file mode 100644 index 00000000..19c8ee40 --- /dev/null +++ b/app/views/docs/data_table_demo/index.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +class Views::Docs::DataTableDemo::Index < Views::Base + FRAME_ID = "employees_list" + FORM_ID = "employees_form" + + TOGGLABLE_COLUMNS = [ + {key: :email, label: "Email"}, + {key: :department, label: "Department"}, + {key: :status, label: "Status"}, + {key: :salary, label: "Salary"} + ].freeze + + BADGE_VARIANTS = { + "Active" => :success, + "Inactive" => :destructive, + "On Leave" => :warning + }.freeze + + def initialize(employees:, total_count:, page:, per_page:, sort:, direction:, search:) + @employees = employees + @total_count = total_count + @page = page + @per_page = per_page + @sort = sort + @direction = direction + @search = search + end + + def view_template + div(class: "p-6") { render_table } + end + + private + + def render_table + DataTable(id: FRAME_ID) do + DataTableToolbar do + DataTableSearch( + path: docs_data_table_demo_path, + frame_id: FRAME_ID, + value: @search, + placeholder: "Filter emails...", + preserved_params: preserved_query.except("search") + ) + div(class: "flex items-center gap-2") do + DataTableColumnToggle(columns: TOGGLABLE_COLUMNS) + DataTablePerPageSelect( + path: docs_data_table_demo_path, + frame_id: FRAME_ID, + value: @per_page + ) + DataTableBulkActions do + Button(type: "submit", form: FORM_ID, formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", form: FORM_ID, formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + end + 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() } + DataTableSortHead(column_key: :name, label: "Name", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query) + DataTableSortHead(column_key: :email, label: "Email", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, data: {column: "email"}) + DataTableSortHead(column_key: :department, label: "Department", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, data: {column: "department"}) + TableHead(data: {column: "status"}) { plain "Status" } + DataTableSortHead(column_key: :salary, label: "Salary", sort: @sort, direction: @direction, path: docs_data_table_demo_path, query: preserved_query, class: "text-right [&>a]:justify-end", data: {column: "salary"}) + TableHead(class: "w-12") + end + end + + TableBody do + if @employees.empty? + TableRow do + TableCell(colspan: 7, class: "h-24 text-center text-muted-foreground") { plain "No results." } + end + else + @employees.each do |e| + TableRow do + TableCell(class: "w-10") { DataTableRowCheckbox(value: e.id, label: "Select row for #{e.name}") } + TableCell(class: "font-medium") { plain e.name } + TableCell(data: {column: "email"}) { plain e.email } + TableCell(data: {column: "department"}) { plain e.department } + TableCell(data: {column: "status"}) do + Badge(variant: BADGE_VARIANTS.fetch(e.status, :outline), size: :sm) { plain e.status } + end + TableCell(class: "text-right", data: {column: "salary"}) { plain view_context.number_to_currency(e.salary, precision: 0, unit: "$") } + TableCell(class: "w-12 text-right") { row_actions(e) } + end + end + end + end + end + end + end + + DataTablePaginationBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTablePagination( + page: @page, + per_page: @per_page, + total_count: @total_count, + path: docs_data_table_demo_path, + query: preserved_query + ) + end + end + end + + def row_actions(employee) + DropdownMenu do + DropdownMenuTrigger do + Button(type: "button", variant: :ghost, size: :icon, aria_label: "Open menu") do + raw view_context.lucide_icon("ellipsis-vertical", class: "h-4 w-4") + end + end + DropdownMenuContent do + DropdownMenuLabel { plain "Actions" } + DropdownMenuItem(href: "#") { plain "Copy employee ID" } + DropdownMenuSeparator() + DropdownMenuItem(href: "#") { plain "View details" } + DropdownMenuItem(href: "#") { plain "View payments" } + end + end + end + + def preserved_query + { + "search" => @search, + "sort" => @sort, + "direction" => @direction, + "per_page" => @per_page.to_s + }.compact_blank + end +end diff --git a/app/views/layouts/docs_layout.rb b/app/views/layouts/docs_layout.rb index 85fe4adc..70c0ffb0 100644 --- a/app/views/layouts/docs_layout.rb +++ b/app/views/layouts/docs_layout.rb @@ -17,7 +17,7 @@ def view_template(&block) div(class: "border-b") do div(class: "container px-4 flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10") do render Shared::Sidebar.new - main(class: "relative py-6 lg:gap-10 lg:py-8 xl:grid xl:grid-cols-[1fr_300px]", &block) + main(class: "relative py-6 lg:py-8 w-full min-w-0", &block) end end end diff --git a/config/initializers/ruby_ui.rb b/config/initializers/ruby_ui.rb index a5b0a4d0..9a5c35e8 100644 --- a/config/initializers/ruby_ui.rb +++ b/config/initializers/ruby_ui.rb @@ -15,4 +15,7 @@ module RubyUI ) # Allow using RubyUI::ComponentName instead RubyUI::ComponentName::ComponentName -Rails.autoloaders.main.collapse(Rails.root.join("app/components/ruby_ui/*")) +collapse_dirs = Dir.glob(Rails.root.join("app/components/ruby_ui/*")).reject do |path| + path.end_with?("data_table_pagination_adapters") +end +Rails.autoloaders.main.collapse(collapse_dirs) unless collapse_dirs.empty? diff --git a/config/routes.rb b/config/routes.rb index bee40040..32060507 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,6 +64,12 @@ get "theme_toggle", to: "docs#theme_toggle", as: :docs_theme_toggle get "tooltip", to: "docs#tooltip", as: :docs_tooltip get "typography", to: "docs#typography", as: :docs_typography + + # DATA TABLE + get "data_table", to: "docs#data_table", as: :docs_data_table + get "data_table_demo", to: "docs/data_table_demo#index", as: :docs_data_table_demo + post "data_table_demo/bulk_delete", to: "docs/data_table_demo#bulk_delete", as: :docs_data_table_demo_bulk_delete + post "data_table_demo/bulk_export", to: "docs/data_table_demo#bulk_export", as: :docs_data_table_demo_bulk_export end match "/404", to: "errors#not_found", via: :all From 8b5bbba6fe85529527987ddb5f81f740459b5d4f Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 29 Apr 2026 18:36:14 -0300 Subject: [PATCH 2/3] Update Gemfile.lock to match current Gemfile --- Gemfile.lock | 146 ++++++++++++++++++++++++++------------------------- 1 file changed, 75 insertions(+), 71 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2ede0452..1865e1c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -24,29 +24,29 @@ GEM specs: action_text-trix (2.1.15) railties - actioncable (8.1.1) - actionpack (= 8.1.1) - activesupport (= 8.1.1) + actioncable (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.1) - actionpack (= 8.1.1) - activejob (= 8.1.1) - activerecord (= 8.1.1) - activestorage (= 8.1.1) - activesupport (= 8.1.1) + actionmailbox (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) - actionmailer (8.1.1) - actionpack (= 8.1.1) - actionview (= 8.1.1) - activejob (= 8.1.1) - activesupport (= 8.1.1) + actionmailer (8.1.3) + actionpack (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activesupport (= 8.1.3) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.1) - actionview (= 8.1.1) - activesupport (= 8.1.1) + actionpack (8.1.3) + actionview (= 8.1.3) + activesupport (= 8.1.3) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -54,36 +54,36 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.1) + actiontext (8.1.3) action_text-trix (~> 2.1.15) - actionpack (= 8.1.1) - activerecord (= 8.1.1) - activestorage (= 8.1.1) - activesupport (= 8.1.1) + actionpack (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.1) - activesupport (= 8.1.1) + actionview (8.1.3) + activesupport (= 8.1.3) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.1) - activesupport (= 8.1.1) + activejob (8.1.3) + activesupport (= 8.1.3) globalid (>= 0.3.6) - activemodel (8.1.1) - activesupport (= 8.1.1) - activerecord (8.1.1) - activemodel (= 8.1.1) - activesupport (= 8.1.1) + activemodel (8.1.3) + activesupport (= 8.1.3) + activerecord (8.1.3) + activemodel (= 8.1.3) + activesupport (= 8.1.3) timeout (>= 0.4.0) - activestorage (8.1.1) - actionpack (= 8.1.1) - activejob (= 8.1.1) - activerecord (= 8.1.1) - activesupport (= 8.1.1) + activestorage (8.1.3) + actionpack (= 8.1.3) + activejob (= 8.1.3) + activerecord (= 8.1.3) + activesupport (= 8.1.3) marcel (~> 1.0) - activesupport (8.1.1) + activesupport (8.1.3) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -145,7 +145,7 @@ GEM loofah (2.24.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - lucide-rails (0.7.1) + lucide-rails (0.7.4) railties (>= 4.1.0) mail (2.9.0) logger @@ -183,18 +183,19 @@ GEM prettyprint prettyprint (0.2.0) prism (1.6.0) - propshaft (1.3.1) + propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - pry (0.15.2) + pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) + reline (>= 0.6.0) psych (5.2.6) date stringio public_suffix (6.0.1) - puma (7.1.0) + puma (7.2.0) nio4r (~> 2.0) racc (1.8.1) rack (3.2.4) @@ -205,20 +206,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.1.1) - actioncable (= 8.1.1) - actionmailbox (= 8.1.1) - actionmailer (= 8.1.1) - actionpack (= 8.1.1) - actiontext (= 8.1.1) - actionview (= 8.1.1) - activejob (= 8.1.1) - activemodel (= 8.1.1) - activerecord (= 8.1.1) - activestorage (= 8.1.1) - activesupport (= 8.1.1) + rails (8.1.3) + actioncable (= 8.1.3) + actionmailbox (= 8.1.3) + actionmailer (= 8.1.3) + actionpack (= 8.1.3) + actiontext (= 8.1.3) + actionview (= 8.1.3) + activejob (= 8.1.3) + activemodel (= 8.1.3) + activerecord (= 8.1.3) + activestorage (= 8.1.3) + activesupport (= 8.1.3) bundler (>= 1.15.0) - railties (= 8.1.1) + railties (= 8.1.3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -226,9 +227,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (8.1.1) - actionpack (= 8.1.1) - activesupport (= 8.1.1) + railties (8.1.3) + actionpack (= 8.1.3) + activesupport (= 8.1.3) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -245,7 +246,9 @@ GEM reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) - rouge (4.6.1) + rouge (4.7.0) + rss (0.3.2) + rexml rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -274,9 +277,9 @@ GEM rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) sin_lru_redux (2.5.2) - sqlite3 (2.8.0) + sqlite3 (2.9.3) mini_portile2 (~> 2.8.0) - sqlite3 (2.8.0-x86_64-linux-gnu) + sqlite3 (2.9.3-x86_64-linux-gnu) standard (1.52.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) @@ -292,12 +295,12 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.8) - tailwind_merge (1.3.1) + tailwind_merge (1.4.0) sin_lru_redux (~> 2.5) thor (1.4.0) timeout (0.4.4) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.23) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) @@ -331,21 +334,22 @@ DEPENDENCIES cssbundling-rails (= 1.4.3) debug jsbundling-rails (= 1.3.1) - lucide-rails (= 0.7.1) + lucide-rails (= 0.7.4) phlex! phlex-rails! - propshaft (= 1.3.1) - pry (= 0.15.2) - puma (= 7.1.0) - rails (= 8.1.1) - rouge (~> 4.6) + propshaft (= 1.3.2) + pry (= 0.16.0) + puma (= 7.2.0) + rails (= 8.1.3) + rouge (~> 4.7) + rss (= 0.3.2) ruby_ui! selenium-webdriver - sqlite3 (= 2.8.0) + sqlite3 (= 2.9.3) standard stimulus-rails (= 1.3.4) - tailwind_merge (~> 1.3.1) - turbo-rails (= 2.0.20) + tailwind_merge (~> 1.4.0) + turbo-rails (= 2.0.23) tzinfo-data web-console From f4b2a3e9af4d40c0574f02a4297a2697f1d97391 Mon Sep 17 00:00:00 2001 From: Djalma Date: Wed, 29 Apr 2026 18:40:02 -0300 Subject: [PATCH 3/3] Fix test to only GET docs routes, skip POST bulk actions --- test/controllers/components_controller_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/controllers/components_controller_test.rb b/test/controllers/components_controller_test.rb index 148c5811..7d40cf16 100644 --- a/test/controllers/components_controller_test.rb +++ b/test/controllers/components_controller_test.rb @@ -5,7 +5,7 @@ def self.all_docs_routes scope_prefix = "/docs" Rails.application.routes.routes.select do |route| - route.path.spec.to_s.start_with?(scope_prefix) + route.path.spec.to_s.start_with?(scope_prefix) && route.verb == "GET" end.map do |route| { method: route.verb,