From 30202f6136978a1f0979e719f5f1ffb3558e1525 Mon Sep 17 00:00:00 2001 From: CulmoneY Date: Thu, 12 Mar 2026 23:21:05 -0400 Subject: [PATCH] [Ux] Mobile Table Component --- .../shared/index_table_component.html.erb | 55 +++----- .../shared/index_table_component.rb | 12 ++ .../desktop_component.html.erb | 37 ++++++ .../desktop_component.rb | 16 +++ .../mobile_component.html.erb | 47 +++++++ .../index_table_component/mobile_component.rb | 29 +++++ .../index_table_component_controller.js | 121 ++++++++++-------- 7 files changed, 226 insertions(+), 91 deletions(-) create mode 100644 app/components/shared/index_table_component/desktop_component.html.erb create mode 100644 app/components/shared/index_table_component/desktop_component.rb create mode 100644 app/components/shared/index_table_component/mobile_component.html.erb create mode 100644 app/components/shared/index_table_component/mobile_component.rb diff --git a/app/components/shared/index_table_component.html.erb b/app/components/shared/index_table_component.html.erb index 8450f923..cb1a0cee 100644 --- a/app/components/shared/index_table_component.html.erb +++ b/app/components/shared/index_table_component.html.erb @@ -1,4 +1,7 @@ -
+
<% if searchable %>
<% end %> - - <% column_sizes = columns.map { |c| c.col_size || "minmax(0, 1fr)" }.join(" ") %> + <%= render Shared::IndexTableComponent::DesktopComponent.new( + records: records, + columns: columns, + column_sizes: column_sizes, + empty_state_text: empty_state_text, + cell_content_renderer: cell_content_renderer + ) %> - -
-
-
- <% columns.each do |column| %> -
<%= column.header %>
- <% end %> -
-
-
- -
- <% records.each_with_index do |record, index| %> -
-
- <% columns.each do |column| %> -
<%= render_cell_content(record, column) %>
- <% end %> -
-
- <% end %> -
"> -
-
<%= t("shared.index_table_component.no_entries") %>
-
-
-
- -
- -
+ <%= render Shared::IndexTableComponent::MobileComponent.new( + records: records, + columns: columns, + empty_state_text: empty_state_text, + cell_content_renderer: cell_content_renderer + ) %>
diff --git a/app/components/shared/index_table_component.rb b/app/components/shared/index_table_component.rb index 6238b508..cb25b9c9 100644 --- a/app/components/shared/index_table_component.rb +++ b/app/components/shared/index_table_component.rb @@ -17,6 +17,18 @@ def column(attribute, header: nil, col_size: nil, &cell_renderer) @columns << Column.new(attribute, label, cell_renderer, col_size) end + def column_sizes + @column_sizes ||= columns.map { |column| column.col_size || "minmax(0, 1fr)" }.join(" ") + end + + def empty_state_text + t("shared.index_table_component.no_entries") + end + + def cell_content_renderer + method(:render_cell_content) + end + private def render_cell_content(record, column) diff --git a/app/components/shared/index_table_component/desktop_component.html.erb b/app/components/shared/index_table_component/desktop_component.html.erb new file mode 100644 index 00000000..1a33fd81 --- /dev/null +++ b/app/components/shared/index_table_component/desktop_component.html.erb @@ -0,0 +1,37 @@ + diff --git a/app/components/shared/index_table_component/desktop_component.rb b/app/components/shared/index_table_component/desktop_component.rb new file mode 100644 index 00000000..dfb77fa0 --- /dev/null +++ b/app/components/shared/index_table_component/desktop_component.rb @@ -0,0 +1,16 @@ +module Shared + class IndexTableComponent + class DesktopComponent < ViewComponent::Base + attr_reader :records, :columns, :column_sizes, :empty_state_text, :cell_content_renderer + + def initialize(records:, columns:, column_sizes:, empty_state_text:, cell_content_renderer:) + super() + @records = records + @columns = columns + @column_sizes = column_sizes + @empty_state_text = empty_state_text + @cell_content_renderer = cell_content_renderer + end + end + end +end diff --git a/app/components/shared/index_table_component/mobile_component.html.erb b/app/components/shared/index_table_component/mobile_component.html.erb new file mode 100644 index 00000000..3c150ae4 --- /dev/null +++ b/app/components/shared/index_table_component/mobile_component.html.erb @@ -0,0 +1,47 @@ +
+ <% records.each do |record| %> +
+
+
+ Actions +
+ <% if actions_column %> +
+ <%= cell_content_renderer.call(record, actions_column) %> +
+ <% end %> +
+
+ <% content_columns.each do |column| %> +
+
<%= column.header %>
+
<%= cell_content_renderer.call(record, column) %>
+
+ <% end %> +
+
+ <% end %> + +
+ +
+
diff --git a/app/components/shared/index_table_component/mobile_component.rb b/app/components/shared/index_table_component/mobile_component.rb new file mode 100644 index 00000000..af1ad1d3 --- /dev/null +++ b/app/components/shared/index_table_component/mobile_component.rb @@ -0,0 +1,29 @@ +module Shared + class IndexTableComponent + class MobileComponent < ViewComponent::Base + attr_reader :records, :columns, :empty_state_text, :cell_content_renderer + + def initialize(records:, columns:, empty_state_text:, cell_content_renderer:) + super() + @records = records + @columns = columns + @empty_state_text = empty_state_text + @cell_content_renderer = cell_content_renderer + end + + def content_columns + columns.reject { |column| action_column?(column) } + end + + def actions_column + columns.find { |column| action_column?(column) } + end + + private + + def action_column?(column) + column.attribute.to_sym == :actions + end + end + end +end diff --git a/app/javascript/controllers/index_table_component_controller.js b/app/javascript/controllers/index_table_component_controller.js index 76e24f03..f6c9207f 100644 --- a/app/javascript/controllers/index_table_component_controller.js +++ b/app/javascript/controllers/index_table_component_controller.js @@ -5,26 +5,15 @@ export default class extends Controller { static values = { perPage: { type: Number, default: 10 } }; connect() { - this._rows = this.rowTargets || []; - this._filteredRows = [...this._rows]; + this._query = ""; this._page = 0; - this._paginate(); + this.refreshLayout(); } filter() { - const query = (this.searchTarget?.value || "").trim().toLowerCase(); - - if (!query) { - this._filteredRows = [...this._rows]; - } else { - this._filteredRows = this._rows.filter((row) => { - const text = row.textContent.replace(/\s+/g, " ").toLowerCase(); - return text.includes(query); - }); - } - + this._query = (this.searchTarget?.value || "").trim().toLowerCase(); this._page = 0; - this._paginate(); + this._refreshRows(); } previousPage() { @@ -41,8 +30,35 @@ export default class extends Controller { } } + refreshLayout() { + this._refreshRows(); + } + // --- Private --- + _refreshRows() { + this._rows = this._activeRows(); + this._filteredRows = this._filterRows(this._rows); + this._page = Math.min(this._page, this._lastPage()); + this._paginate(); + } + + _activeRows() { + return this.rowTargets.filter((row) => { + const layout = row.closest("[data-index-table-component-layout]"); + return layout && window.getComputedStyle(layout).display !== "none"; + }); + } + + _filterRows(rows) { + if (!this._query) return [...rows]; + + return rows.filter((row) => { + const text = row.textContent.replace(/\s+/g, " ").toLowerCase(); + return text.includes(this._query); + }); + } + _lastPage() { return Math.max( 0, @@ -55,49 +71,48 @@ export default class extends Controller { const start = this._page * perPage; const end = (this._page + 1) * perPage; - this._rows.forEach((row) => (row.style.display = "none")); - this._filteredRows - .slice(start, end) - .forEach((row) => (row.style.display = "")); + this.rowTargets.forEach((row) => (row.style.display = "none")); + this._filteredRows.slice(start, end).forEach((row) => (row.style.display = "")); - // Toggle empty state - this.emptyRowTarget.classList.toggle( - "hidden", - this._filteredRows.length > 0, - ); + this.emptyRowTargets.forEach((emptyRow) => { + const layout = emptyRow.closest("[data-index-table-component-layout]"); + const isActive = layout && window.getComputedStyle(layout).display !== "none"; + emptyRow.classList.toggle("hidden", !isActive || this._filteredRows.length > 0); + }); - // Update pagination if (this.hasPaginationTarget) { - this._updatePagination(); + this._updatePagination(this._lastPage()); } } - _updatePagination() { - const lastPage = this._lastPage(); - const container = this.paginationTarget; - - if (lastPage === 0) { - container.classList.add("hidden"); - return; - } - - container.classList.remove("hidden"); - - const prevBtn = container.querySelector("[data-prev]"); - const nextBtn = container.querySelector("[data-next]"); - - if (prevBtn) { - prevBtn.disabled = this._page === 0; - prevBtn.classList.toggle("btn-disabled", this._page === 0); - } - - if (nextBtn) { - nextBtn.disabled = this._page === lastPage; - nextBtn.classList.toggle("btn-disabled", this._page === lastPage); - } - - if (this.hasPageInfoTarget) { - this.pageInfoTarget.textContent = `${this._page + 1} of ${lastPage + 1}`; - } + _updatePagination(lastPage) { + this.paginationTargets.forEach((container, index) => { + const layout = container.closest("[data-index-table-component-layout]"); + const isActive = layout && window.getComputedStyle(layout).display !== "none"; + + if (!isActive || lastPage === 0) { + container.classList.add("hidden"); + } else { + container.classList.remove("hidden"); + } + + const prevBtn = container.querySelector("[data-prev]"); + const nextBtn = container.querySelector("[data-next]"); + + if (prevBtn) { + prevBtn.disabled = this._page === 0; + prevBtn.classList.toggle("btn-disabled", this._page === 0); + } + + if (nextBtn) { + nextBtn.disabled = this._page === lastPage; + nextBtn.classList.toggle("btn-disabled", this._page === lastPage); + } + + const pageInfo = this.pageInfoTargets[index]; + if (pageInfo) { + pageInfo.textContent = `${this._page + 1} of ${lastPage + 1}`; + } + }); } }