diff --git a/Gemfile.lock b/Gemfile.lock index ae1fb223..1865e1c3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -14,15 +14,15 @@ 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) @@ -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,35 +115,34 @@ 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) @@ -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,19 +170,19 @@ 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) + prism (1.6.0) propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -194,20 +191,20 @@ GEM 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) 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) @@ -227,8 +224,8 @@ GEM 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) @@ -241,18 +238,18 @@ 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) + rubocop (1.81.7) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -260,20 +257,20 @@ 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) @@ -283,25 +280,25 @@ GEM sqlite3 (2.9.3) mini_portile2 (~> 2.8.0) sqlite3 (2.9.3-x86_64-linux-gnu) - standard (1.54.0) + 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) + stringio (3.1.8) tailwind_merge (1.4.0) 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) actionpack (>= 7.1.0) @@ -310,13 +307,14 @@ GEM 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 +322,7 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.5) + zeitwerk (2.7.3) PLATFORMS ruby @@ -359,4 +357,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 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,