diff --git a/lib/ruby_ui/data_table/data_table.rb b/lib/ruby_ui/data_table/data_table.rb new file mode 100644 index 00000000..97245a0e --- /dev/null +++ b/lib/ruby_ui/data_table/data_table.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RubyUI + class DataTable < Base + def initialize(src: nil, **attrs) + @src = src + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "w-full space-y-4", + data: { + controller: "ruby-ui--data-table", + ruby_ui__data_table_src_value: @src + } + } + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_content.rb b/lib/ruby_ui/data_table/data_table_content.rb new file mode 100644 index 00000000..ec752a39 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_content.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableContent < Base + def initialize(frame_id: "data_table_content", **attrs) + @frame_id = frame_id + super(**attrs) + end + + def view_template(&) + div(id: @frame_id, **attrs, &) + end + + private + + def default_attrs + { + class: "rounded-md border" + } + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_controller.js b/lib/ruby_ui/data_table/data_table_controller.js new file mode 100644 index 00000000..0017fc9a --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_controller.js @@ -0,0 +1,77 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["search", "perPage"] + static values = { + src: String, + sortColumn: String, + sortDirection: String, + page: { type: Number, default: 1 }, + perPage: { type: Number, default: 10 }, + searchQuery: String, + debounceMs: { type: Number, default: 300 } + } + + connect() { + this.searchTimeout = null + } + + disconnect() { + if (this.searchTimeout) clearTimeout(this.searchTimeout) + } + + sort(event) { + const { column, direction } = event.params + this.sortColumnValue = column + this.sortDirectionValue = direction || "" + this.pageValue = 1 + this._reload() + } + + search() { + if (this.searchTimeout) clearTimeout(this.searchTimeout) + this.searchTimeout = setTimeout(() => { + this.searchQueryValue = this.searchTarget.value + this.pageValue = 1 + this._reload() + }, this.debounceMsValue) + } + + nextPage() { + this.pageValue += 1 + this._reload() + } + + previousPage() { + if (this.pageValue > 1) { + this.pageValue -= 1 + this._reload() + } + } + + changePerPage() { + this.perPageValue = parseInt(this.perPageTarget.value) + this.pageValue = 1 + this._reload() + } + + _reload() { + if (!this.hasSrcValue || !this.srcValue) return + + const url = new URL(this.srcValue, window.location.origin) + if (this.sortColumnValue) url.searchParams.set("sort", this.sortColumnValue) + if (this.sortDirectionValue) url.searchParams.set("direction", this.sortDirectionValue) + if (this.searchQueryValue) url.searchParams.set("search", this.searchQueryValue) + url.searchParams.set("page", this.pageValue) + url.searchParams.set("per_page", this.perPageValue) + + // Use Turbo to fetch and replace the content frame + const frame = this.element.querySelector("turbo-frame") + if (frame) { + frame.src = url.toString() + } else { + // Fallback: dispatch custom event for consumer to handle + this.dispatch("navigate", { detail: { url: url.toString() } }) + } + } +} diff --git a/lib/ruby_ui/data_table/data_table_docs.rb b/lib/ruby_ui/data_table/data_table_docs.rb new file mode 100644 index 00000000..f0bef8d9 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_docs.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableDocs < Phlex::HTML + def view_template + # Documentation placeholder for RubyUI website + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_pagination.rb b/lib/ruby_ui/data_table/data_table_pagination.rb new file mode 100644 index 00000000..66179e79 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_pagination.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePagination < Base + def initialize(current_page:, total_pages:, **attrs) + @current_page = current_page + @total_pages = total_pages + super(**attrs) + end + + def view_template + div(**attrs) do + div(class: "flex items-center justify-between px-2") do + div(class: "flex-1 text-sm text-muted-foreground") do + plain "Page #{@current_page} of #{@total_pages}" + end + div(class: "flex items-center space-x-2") do + nav_button( + direction: "previous", + disabled: @current_page <= 1, + action: "click->ruby-ui--data-table#previousPage", + icon_path: "m15 18-6-6 6-6" + ) + nav_button( + direction: "next", + disabled: @current_page >= @total_pages, + action: "click->ruby-ui--data-table#nextPage", + icon_path: "m9 18 6-6-6-6" + ) + end + end + end + end + + private + + def nav_button(direction:, disabled:, action:, icon_path:) + button( + type: "button", + class: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground h-8 w-8 p-0 #{disabled ? "opacity-50 pointer-events-none" : ""}", + disabled: disabled, + aria_label: direction, + data: {action: action} + ) do + 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" + ) { |s| s.path(d: icon_path) } + end + end + + def default_attrs + { + class: "flex items-center justify-end py-4" + } + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_per_page.rb b/lib/ruby_ui/data_table/data_table_per_page.rb new file mode 100644 index 00000000..1e045c55 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_per_page.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePerPage < Base + def initialize(options: [10, 20, 50, 100], current: 10, **attrs) + @options = options + @current = current + super(**attrs) + end + + def view_template + div(**attrs) do + span(class: "text-sm text-muted-foreground") { "Rows per page" } + render RubyUI::NativeSelect.new( + size: :sm, + class: "w-16", + data: { + action: "change->ruby-ui--data-table#changePerPage", + ruby_ui__data_table_target: "perPage" + } + ) do + @options.each do |opt| + render RubyUI::NativeSelectOption.new(value: opt, selected: opt == @current) { opt.to_s } + end + end + end + end + + private + + def default_attrs + { + class: "flex items-center gap-2" + } + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_search.rb b/lib/ruby_ui/data_table/data_table_search.rb new file mode 100644 index 00000000..1f433f24 --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_search.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSearch < Base + def initialize(placeholder: "Search...", name: "search", **attrs) + @placeholder = placeholder + @name = name + super(**attrs) + end + + def view_template + render RubyUI::Input.new( + type: :search, + name: @name, + placeholder: @placeholder, + **attrs + ) + end + + private + + def default_attrs + { + class: "max-w-sm", + data: { + ruby_ui__data_table_target: "search", + action: "input->ruby-ui--data-table#search" + } + } + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_sortable_header.rb b/lib/ruby_ui/data_table/data_table_sortable_header.rb new file mode 100644 index 00000000..e6823c7d --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_sortable_header.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSortableHeader < Base + def initialize(column:, label: nil, direction: nil, **attrs) + @column = column + @label = label || column.to_s.tr("_", " ").capitalize + @direction = direction # nil, "asc", or "desc" + super(**attrs) + end + + def view_template(&block) + th(**attrs) do + button( + type: "button", + class: "inline-flex items-center gap-1 hover:text-foreground", + data: { + action: "click->ruby-ui--data-table#sort", + ruby_ui__data_table_column_param: @column, + ruby_ui__data_table_direction_param: next_direction + } + ) do + if block + yield + else + plain @label + end + render_sort_icon + end + end + end + + private + + def next_direction + case @direction + when "asc" then "desc" + when "desc" then "" + else "asc" + end + end + + def render_sort_icon + svg( + xmlns: "http://www.w3.org/2000/svg", + width: "14", height: "14", + viewBox: "0 0 24 24", + fill: "none", stroke: "currentColor", + stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", + class: "ml-1 #{@direction ? "" : "text-muted-foreground"}" + ) do |s| + if @direction == "asc" + s.path(d: "m18 15-6-6-6 6") + elsif @direction == "desc" + s.path(d: "m6 9 6 6 6-6") + else + s.path(d: "m7 15 5 5 5-5") + s.path(d: "m7 9 5-5 5 5") + end + end + end + + def default_attrs + { + class: "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0" + } + end + end +end diff --git a/lib/ruby_ui/data_table/data_table_toolbar.rb b/lib/ruby_ui/data_table/data_table_toolbar.rb new file mode 100644 index 00000000..6c3c02df --- /dev/null +++ b/lib/ruby_ui/data_table/data_table_toolbar.rb @@ -0,0 +1,17 @@ +# 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/test/ruby_ui/data_table_test.rb b/test/ruby_ui/data_table_test.rb new file mode 100644 index 00000000..ef8e94c6 --- /dev/null +++ b/test/ruby_ui/data_table_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" + +module RubyUI + class DataTableTest < ComponentTest + def test_render_data_table_wrapper + output = phlex do + RubyUI.DataTable { "content" } + end + + assert_match(/data-controller="ruby-ui--data-table"/, output) + assert_match(/content/, output) + end + + def test_render_data_table_with_src + output = phlex do + RubyUI.DataTable(src: "/users") { "content" } + end + + assert_match(/data-ruby-ui--data-table-src-value="\/users"/, output) + end + + def test_render_toolbar + output = phlex do + RubyUI.DataTableToolbar { "toolbar content" } + end + + assert_match(/toolbar content/, output) + assert_match(/flex items-center/, output) + end + + def test_render_search + output = phlex do + RubyUI.DataTableSearch(placeholder: "Filter emails...") + end + + assert_match(/type="search"/, output) + assert_match(/placeholder="Filter emails..."/, output) + assert_match(/input->ruby-ui--data-table#search/, output) + assert_match(/data-ruby-ui--data-table-target="search"/, output) + end + + def test_render_sortable_header_without_direction + output = phlex do + RubyUI.DataTableSortableHeader(column: "name") + end + + assert_match(/Name/, output) + assert_match(/data-action="click->ruby-ui--data-table#sort"/, output) + assert_match(/data-ruby-ui--data-table-column-param="name"/, output) + assert_match(/data-ruby-ui--data-table-direction-param="asc"/, output) + end + + def test_render_sortable_header_asc + output = phlex do + RubyUI.DataTableSortableHeader(column: "name", direction: "asc") + end + + assert_match(/data-ruby-ui--data-table-direction-param="desc"/, output) + assert_match(/svg/, output) + end + + def test_render_sortable_header_desc + output = phlex do + RubyUI.DataTableSortableHeader(column: "name", direction: "desc") + end + + assert_match(/data-ruby-ui--data-table-direction-param/, output) + end + + def test_render_sortable_header_custom_label + output = phlex do + RubyUI.DataTableSortableHeader(column: "email", label: "Email Address") + end + + assert_match(/Email Address/, output) + end + + def test_render_pagination + output = phlex do + RubyUI.DataTablePagination(current_page: 2, total_pages: 5) + end + + assert_match(/Page 2 of 5/, output) + assert_match(/data-action="click->ruby-ui--data-table#previousPage"/, output) + assert_match(/data-action="click->ruby-ui--data-table#nextPage"/, output) + end + + def test_render_pagination_first_page_disables_prev + output = phlex do + RubyUI.DataTablePagination(current_page: 1, total_pages: 5) + end + + assert_match(/disabled/, output) + end + + def test_render_pagination_last_page_disables_next + output = phlex do + RubyUI.DataTablePagination(current_page: 5, total_pages: 5) + end + + assert_match(/disabled/, output) + end + + def test_render_per_page_selector + output = phlex do + RubyUI.DataTablePerPage(current: 20) + end + + assert_match(/Rows per page/, output) + assert_match(/change->ruby-ui--data-table#changePerPage/, output) + assert_match(/data-ruby-ui--data-table-target="perPage"/, output) + assert_match(/