From 5f94eabaff1270565a94787a1f71051989763529 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:10:03 -0300 Subject: [PATCH 01/81] docs(spec): DataTable Hotwire-first design Adds the brainstorm-approved design document for the DataTable component family. Covers components, Stimulus controllers, Turbo wiring, pagination adapter pattern, configurable query params, icon strategy, test scope, and six documentation examples. --- .../2026-04-24-datatable-hotwire-design.md | 460 ++++++++++++++++++ 1 file changed, 460 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md diff --git a/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md b/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md new file mode 100644 index 00000000..4f2268cd --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md @@ -0,0 +1,460 @@ +# DataTable — Hotwire-first, Avo-inspired Spec + +**Date:** 2026-04-24 +**Branch:** `da/datatable-hotwire` +**Author:** Djalma Araujo +**Status:** Design (awaiting user sign-off) + +## Purpose + +Add a `DataTable` component family to the Ruby UI website. Every interaction +(sort, search, pagination, per-page, filter) is a plain Rails request, answered +with HTML, swapped via ``. No external JS library. Client-only +JavaScript is Stimulus, kept minimal and scoped to ephemeral UI state +(selection, column visibility, dropdown open). Row selection uses a +**form-first** pattern: row checkboxes are `` inside a real +`
`, so bulk actions submit natively without custom fetch logic. + +Architecture is inspired by Avo's `ResourceTableComponent`. Composition mirrors +shadcn's data-table demo, but each primitive maps to an existing Ruby UI +`Table*` component rather than a namespaced duplicate. + +## Scope + +In scope: + +- 12 new components under `app/components/ruby_ui/data_table/` +- 2 Stimulus controllers +- 3 pagination adapters (manual, pagy, kaminari) +- 6 documentation examples (first = complete demo) +- Component render tests + pagination adapter tests + demo controller + integration test +- Docs demo controller with in-memory data filtering/sorting/pagination +- Stub bulk-action endpoints (flash + redirect, no persistence) + +Out of scope: + +- Multi-column sort +- Column-level filters (only global search) +- Server-persisted selection (selection is per-page, client-only by design) +- Keyboard navigation beyond native ``/`` behavior +- CSV/export implementation (demo stub only) +- Sticky header +- Density toggle +- System tests (Selenium is incompatible with the current devcontainer) + +## Design decisions + +### Reuse over duplication + +Cirdes's branch duplicated 8 Table primitives as `DataTableAvo*`. This spec +reuses the existing `Table`, `TableHeader`, `TableBody`, `TableRow`, +`TableHead`, `TableCell`, `TableFooter`, `TableCaption` components directly. +Users compose the table with primitives they already know from the rest of the +docs site. + +### Form-first selection + +The `` contents sit inside a `` element. Row checkboxes are +plain ``. Bulk action +buttons submit the form with `type="submit" formaction="/foo" +formmethod="post"` so each action routes independently. Rails receives +`params[:ids]` natively. No custom JavaScript in the submit path. The server +owns the truth. + +### Selection ephemerality + +Row selection and column visibility are DOM-local state. A Turbo Frame swap +(sort/search/page) destroys and re-renders the frame, which naturally clears +both. This matches Avo's philosophy and is documented as intentional. + +### Query param flexibility + +Every component that emits or reads a query param accepts a prop for the +param name, with a sensible default: + +| Component | Prop | Default | +|---|---|---| +| `DataTableSearch` | `name` | `"search"` | +| `DataTableSortHead` | `sort_param`, `direction_param` | `"sort"`, `"direction"` | +| `DataTablePerPageSelect` | `name` | `"per_page"` | +| `DataTablePagination` | `page_param` | `"page"` | + +Users can map to existing conventions (`q`, `sort_by`, `sort_dir`, `p`, +`size`) without touching internals. There is no global config. Each +component declares its own param name. + +### Pagination adapters + +`DataTablePagination` accepts a `with:` argument pointing to any object that +implements: + +```ruby +current_page -> Integer # 1-based +total_pages -> Integer +total_count -> Integer | nil +``` + +Three built-in adapters under `RubyUI::DataTable::Pagination::*`: + +- `Manual.new(page:, per_page:, total_count:)` — arithmetic, no gem. +- `Pagy.new(pagy)` — reads `.page`, `.pages`, `.count`. +- `Kaminari.new(collection)` — reads `.current_page`, `.total_pages`, + `.total_count`. + +Keyword shortcuts auto-wrap: + +- `DataTablePagination(pagy: @pagy, ...)` +- `DataTablePagination(kaminari: @records, ...)` +- `DataTablePagination(page:, per_page:, total_count:, ...)` (manual) + +Custom adapters: user writes a class with three methods, passes via `with:`. +No monkey-patching. + +### Defaults over required props + +Only `id:` on the root `DataTable`, `column_key:`/`label:` on `SortHead`, +`value:` on `RowCheckbox`, `columns:` on `ColumnToggle`, and a pagination +source are strictly required. All other props (`path:`, `frame_id:`, `query:`, +`value:`, `sort:`, `direction:`, `name:`, `param:`, `placeholder:`, +`options:`) default sensibly: + +- `path:` — `helpers.url_for(only_path: true)` (current path) +- `frame_id:` — omitted; Turbo auto-scopes form submissions to the enclosing + `` +- `query:` — `request.query_parameters` (preserves other params) +- `value:` / `sort:` / `direction:` — read from `params[name_or_param]` + +Minimum usage emerges: + +```ruby +DataTable(id: "employees") do + DataTableToolbar do + DataTableSearch + DataTableColumnToggle(columns: TOGGLABLE) + DataTablePerPageSelect + end + Table do + TableHeader do + TableRow do + TableHead { DataTableSelectAllCheckbox } + DataTableSortHead(column_key: :name, label: "Name") + TableHead { "Status" } + end + end + TableBody do + @employees.each do |e| + TableRow do + TableCell { DataTableRowCheckbox(value: e.id) } + TableCell { e.name } + TableCell { Badge { e.status } } + end + end + end + end + DataTableSelectionBar do + DataTableSelectionSummary + DataTableBulkActions do + Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } + end + end + DataTablePagination(pagy: @pagy) +end +``` + +### Icons as files, never inline + +All SVG icons come from the `lucide-rails` helper (`lucide_icon`) which +renders asset-pipeline SVG files. Mapping: + +| Use | Icon | +|---|---| +| Sort asc | `:chevron_up` | +| Sort desc | `:chevron_down` | +| Sort unsorted | `:chevrons_up_down` | +| Columns button caret | `:chevron_down` | +| Row actions trigger | `:ellipsis_vertical` | +| Search decoration | `:search` | + +If an icon is not in lucide, it is committed as a file under +`app/assets/images/ruby_ui/data_table/.svg` and rendered via the Rails +asset helper — never inlined in Ruby. + +## Components + +Path: `app/components/ruby_ui/data_table/` + +``` +data_table.rb root — + + controller +data_table_toolbar.rb flex layout slot +data_table_search.rb with Input +data_table_per_page_select.rb with NativeSelect (auto-submit) +data_table_column_toggle.rb DropdownMenu + Checkbox list +data_table_sort_head.rb wraps TableHead, renders +data_table_row_checkbox.rb wraps Checkbox, +data_table_select_all_checkbox.rb wraps Checkbox, Stimulus select-all target +data_table_selection_bar.rb container for summary + bulk actions +data_table_selection_summary.rb "X of N selected" text +data_table_bulk_actions.rb hidden-by-default slot for submit buttons +data_table_pagination.rb numbered pagination, adapter-backed + +pagination/manual.rb arithmetic adapter +pagination/pagy.rb Pagy duck-type adapter +pagination/kaminari.rb Kaminari duck-type adapter +``` + +### API surface (condensed) + +```ruby +DataTable(id:, path: nil, **attrs) +DataTableToolbar(**attrs) +DataTableSearch(name: "search", path: nil, frame_id: nil, value: nil, placeholder: "Search...", **attrs) +DataTablePerPageSelect(name: "per_page", path: nil, frame_id: nil, value: nil, options: [5, 10, 25, 50], **attrs) +DataTableColumnToggle(columns:, **attrs) +DataTableSortHead(column_key:, label:, sort_param: "sort", direction_param: "direction", + sort: nil, direction: nil, path: nil, query: nil, **attrs) +DataTableRowCheckbox(value:, name: "ids[]", **attrs) +DataTableSelectAllCheckbox(**attrs) +DataTableSelectionBar(**attrs) +DataTableSelectionSummary(total_on_page: nil, **attrs) +DataTableBulkActions(**attrs) +DataTablePagination(with: nil, pagy: nil, kaminari: nil, + page: nil, per_page: nil, total_count: nil, + page_param: "page", path: nil, query: nil, **attrs) +``` + +## Stimulus controllers + +Path: `app/javascript/controllers/ruby_ui/` + +### `data_table_controller.js` + +Attached to the root `DataTable` element. Targets: + +- `selectAll` — the select-all checkbox +- `rowCheckbox` — each row checkbox (many) +- `selectionSummary` — the "X of N selected" text node +- `selectionBar` — outer container (holds summary + bulk actions) +- `bulkActions` — the bulk actions slot + +Actions: + +- `toggleAll` — check/uncheck every `rowCheckbox`, then `updateState()` +- `toggleRow` — `updateState()` + +`updateState()`: + +- `count = selected row checkboxes` +- `total = all row checkboxes` +- Set summary text to `"count of total row(s) selected"` +- `selectAll.checked = count === total && total > 0` +- `selectAll.indeterminate = count > 0 && count < total` +- Toggle `hidden` on `summary` vs `bulkActions`: summary visible when + `count === 0`, bulk actions visible when `count > 0` + +`connect()` calls `updateState()` once — matches server-rendered page load. + +### `data_table_column_visibility_controller.js` + +Attached to the `DataTableColumnToggle` root. Targets: none required (we read +`event.target.dataset.columnKey`). + +Action: + +- `toggle(event)` — compute `key`/`visible`, then find the nearest ancestor + with `[data-controller~="ruby-ui--data-table"]` and + `querySelectorAll('[data-column="KEY"]')`, adding/removing `hidden`. + +Dropdown open/close is delegated to the existing `ruby-ui--dropdown-menu` +controller. No re-implementation. + +### Why only two controllers + +Explored and rejected: + +- `data_table_search_controller` — debounced auto-submit. Trivial, nice to + have, not necessary. Form submits on explicit action. +- `data_table_per_page_controller` — auto-submit select on change. Doable + with `onchange="this.form.requestSubmit()"` or a generic + `form-submit-on-change` utility controller. Single-purpose per-table + controller is over-engineering. +- `data_table_sort_controller` — not needed; sort heads are plain ``. +- `data_table_selection_controller` split from root — both pieces read the + same counter. Splitting spreads the same computation. +- `data_table_bulk_actions_controller` for confirm — Rails provides + `data-turbo-confirm=`. +- `data_table_column_toggle_menu_controller` — already delegated to + `ruby-ui--dropdown-menu`. + +## Server / Turbo wiring + +### Request flow + +| Action | Request | Response | +|---|---|---| +| Type + submit search | `GET /demo?search=foo` (form `data-turbo-frame`) | Frame swap | +| Click sort header | `GET /demo?sort=name&direction=asc` (link in frame) | Frame swap | +| Change per-page | `GET /demo?per_page=25` (auto-submit select form) | Frame swap | +| Click page N | `GET /demo?page=3` (link in frame) | Frame swap | +| Toggle row checkbox | none | client-only `updateState()` | +| Click "Delete" | `POST /bulk_delete` (form with `ids[]`) | Redirect or Turbo Stream | + +### Frame anatomy + +```html + +
+ +
+ +
+
+
+ + +
+ + …
+
+
+
0 of N selected
+ +
+
+
+ + +
+
+``` + +Nested `
` is invalid HTML. Search/per-page forms are **siblings** of the +main bulk form, not nested. Visually grouped in the toolbar. + +### Demo controller + +`Docs::DataTableDemoController`: + +- `index` — reads `search`, `sort`, `direction`, `page`, `per_page`; filters + in-memory `EMPLOYEES`; paginates; renders + `Views::Docs::DataTableDemo::Index`. Clamps `per_page` to `1..100`, clamps + `page` to valid range. +- `bulk_delete` — flashes `"Would delete: #{params[:ids].join(', ')}"`, + redirects to `docs_data_table_demo_path`. +- `bulk_export` — same pattern, flashes export intent. + +## File layout + +New: + +``` +app/components/ruby_ui/data_table/ (12 components + 3 adapters) +app/javascript/controllers/ruby_ui/ + data_table_controller.js + data_table_column_visibility_controller.js +app/controllers/docs/ + data_table_demo_controller.rb + data_table_demo_data.rb (100-row EMPLOYEES module) +app/views/docs/ + data_table.rb (6 examples page) + data_table_demo/index.rb (complete demo view) +test/components/ruby_ui/data_table/ (12 + 3 files) +test/controllers/docs/ + data_table_demo_controller_test.rb +docs/superpowers/specs/ + 2026-04-24-datatable-hotwire-design.md (this file) +docs/superpowers/plans/ + 2026-04-24-datatable-hotwire-plan.md (follow-up) +``` + +Modified: + +- `app/javascript/controllers/index.js` — register 2 controllers +- `config/routes.rb` — add docs + demo routes +- `app/controllers/docs_controller.rb` — `#data_table` action +- `app/components/shared/menu.rb` — sidebar entry + +## Testing + +Per user decision (Q10 = B): component render tests + controller integration. +No JS unit tests, no Capybara system tests (devcontainer incompatibility). + +**Component tests (one per file):** + +- Render correct tag +- Default classes merged +- Required props raise `ArgumentError` when missing +- Optional props overridden correctly +- Stimulus targets / data-attributes present where expected +- Param-name overrides reflected in emitted attributes/URLs + +**Pagination adapter tests (one per adapter):** + +- Normalizes to `current_page`, `total_pages`, `total_count` +- Duck-typing works against doubles (Pagy, Kaminari) + +**Controller integration test:** + +- `GET /docs/data_table_demo` returns 200 +- `?search=alice` filters +- `?sort=name&direction=desc` sorts correctly (and numeric sort for salary) +- `?page=3&per_page=5` paginates +- `POST /docs/data_table_demo/bulk_delete` accepts `ids[]`, redirects, flashes + +## Documentation examples + +`app/views/docs/data_table.rb` renders six `Docs::VisualCodeExample` sections: + +1. **Complete demo** (primary) — all features wired +2. **Basic static table** — composition only +3. **Server-driven** — search + sort + numbered pagination +4. **Selection + bulk actions** — form-first submission pattern +5. **Column visibility** — column toggle in isolation +6. **Custom cell renderers** — badge/date/currency helpers + +## Branch / commit workflow + +Branch: `da/datatable-hotwire` (from `main`). + +Commit incrementally — after each meaningful unit, not batched at end: + +1. Spec +2. Plan +3. Each component file with its test (one commit per component) +4. Each Stimulus controller +5. Demo data module +6. Demo controller + tests +7. Routes + menu entry + docs_controller action +8. Docs page (grouped if small, split if large) +9. Manual smoke of the docs page in devcontainer — no commit needed, capture + notes in PR + +No background-subprocess usage. Work directly per superpowers standards. + +## Risks / trade-offs + +- **Selection clears on every Turbo Frame swap** — documented intent. Users + who need persistent selection must implement their own pattern. +- **No JS means no client-side sort** — every sort is a server roundtrip. + For small datasets (< 100 rows) this may feel slower than instant local + sort. Acceptable for a server-first philosophy; cacheable at the Rails + layer. +- **Pagy and Kaminari not in Gemfile** — adapter classes are thin wrappers + that work by duck-typing against the adapter's object. Users install the + gem they choose; adapters do not add dependencies. +- **Nested form constraint** — search/per-page forms are siblings, not + nested. Any visual "toolbar" is a flex container, not a form. + +## Success criteria + +- All six docs examples render without errors at `/docs/data_table`. +- Complete demo at `/docs/data_table_demo` (example 1) performs: + search, sort, per-page, page nav (numbered), select-all, per-row checkbox, + bulk action form submission, column toggle, row actions dropdown — each via + the documented request pattern. +- `bundle exec rake test` passes (new tests green, existing ones unaffected). +- `bundle exec rake standard` passes (no lint regressions). +- Manual devcontainer smoke confirms all interactions work at + `http://localhost:3001/docs/data_table`. From a33cc9d5390f9463982327419b7fbcfed6448421 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:17:15 -0300 Subject: [PATCH 02/81] docs(plan): DataTable implementation plan (28 tasks) TDD, incremental commits per task. Enforces subagent model (claude-sonnet-4-6, low effort) for all implementation steps. --- .../2026-04-24-datatable-hotwire-plan.md | 2456 +++++++++++++++++ 1 file changed, 2456 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md diff --git a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md new file mode 100644 index 00000000..3c0b0f90 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md @@ -0,0 +1,2456 @@ +# DataTable Hotwire-first Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Subagent model policy:** All implementation subagents MUST run on `claude-sonnet-4-6` with **low reasoning effort** for speed. Do NOT dispatch implementation tasks to Opus. Planning/review/verification may remain on the default model. + +**Goal:** Ship a Hotwire-first, Avo-inspired `DataTable` component family for the Ruby UI docs site, with 12 components, 2 Stimulus controllers, 3 pagination adapters, 6 docs examples, and full render/integration tests. + +**Architecture:** One `` wraps a real `` so row checkboxes submit natively. Search/sort/per-page/page each swap the frame via a plain GET. Row selection + column visibility are client-only ephemera held in two small Stimulus controllers. Reuses all existing `Table*`, `Checkbox`, `Pagination*`, `DropdownMenu`, `Input`, `NativeSelect`, `Badge`, `Button`, and `lucide-rails` primitives. + +**Tech Stack:** Rails 8.1, Phlex, Turbo, Stimulus, Tailwind 4, `lucide-rails`, Minitest, Phlex kit helpers. + +--- + +## Workflow requirements + +- **Branch:** `da/datatable-hotwire` (already created from `main`). +- **Commits:** One commit per task when the task results in a meaningful change. Never batch at end. Use HEREDOC commit messages per repo convention. +- **Subagents:** MUST be `claude-sonnet-4-6`, low effort. Reject Opus for implementation. +- **Environment:** All `bundle`, `bin/rails`, `pnpm` commands run inside the devcontainer. Use the helper `dx` alias defined in Task 1. + +## Reference index + +- Spec: `docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md` +- Existing Table primitives: `app/components/ruby_ui/table/` +- Existing Checkbox: `app/components/ruby_ui/checkbox/checkbox.rb` +- Existing Pagination: `app/components/ruby_ui/pagination/` +- Existing DropdownMenu: `app/components/ruby_ui/dropdown_menu/` +- Existing NativeSelect: `app/components/ruby_ui/native_select.rb` (plus `native_select_option.rb` if present) +- Existing Base: `app/components/ruby_ui/base.rb` +- Existing docs visual example: `app/components/docs/visual_code_example.rb` +- Existing docs header: `app/components/docs/header.rb` +- Sidebar menu: `app/components/shared/menu.rb` +- Routes: `config/routes.rb` +- Controllers index (Stimulus): `app/javascript/controllers/index.js` + +--- + +## Task 1: Environment prep + +**Files:** none modified. + +- [ ] **Step 1.1: Verify branch** + +Run: +```bash +git status +git log -1 --oneline +``` +Expected: branch `da/datatable-hotwire`, head commit is the spec. + +- [ ] **Step 1.2: Define `dx` helper** + +Every later task's `Run:` lines assume this helper. Paste this into your shell: + +```bash +dx() { + docker exec rubyui-web-rails-app-1 bash -c ' + export PATH="/home/vscode/.local/bin:/home/vscode/.local/share/mise/installs/ruby/3.4.7/bin:/home/vscode/.local/share/mise/installs/node/22.22.1/bin:$PATH" + export SECRET_KEY_BASE=abc123 + export BUNDLE_PATH=/workspaces/web/vendor/bundle + cd /workspaces/web + '"$*" +} +``` + +- [ ] **Step 1.3: Start devcontainer (if not running)** + +```bash +cd /home/didi/dev/linkana/web/.devcontainer && docker compose up -d && cd /home/didi/dev/linkana/web +``` +Expected: container `rubyui-web-rails-app-1` running. + +- [ ] **Step 1.4: Baseline test run** + +```bash +dx bin/rails test +``` +Expected: existing suite green. Record failure count if any exist before starting. + +--- + +## Task 2: Add routes + +**Files:** +- Modify: `config/routes.rb` + +- [ ] **Step 2.1: Add routes inside the existing `scope "docs"` block** + +Locate the closing `end` of the `scope "docs" do ... end` block in `config/routes.rb` (around the final components entry). Just before that `end`, add: + +```ruby + # 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 +``` + +- [ ] **Step 2.2: Verify routes** + +Run: +```bash +dx bin/rails routes -g data_table +``` +Expected: four routes listed (`docs_data_table`, `docs_data_table_demo`, `docs_data_table_demo_bulk_delete`, `docs_data_table_demo_bulk_export`). + +- [ ] **Step 2.3: Commit** + +```bash +git add config/routes.rb +git commit -m "$(cat <<'EOF' +feat(routes): data_table docs and demo endpoints + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Add `DocsController#data_table` action + +**Files:** +- Modify: `app/controllers/docs_controller.rb` + +- [ ] **Step 3.1: Add action** + +Find the end of the `class DocsController` (last action before `end`). Add, in alphabetical-ish neighborhood near `table`: + +```ruby + def data_table + render Views::Docs::DataTable.new + end +``` + +- [ ] **Step 3.2: Commit** + +```bash +git add app/controllers/docs_controller.rb +git commit -m "$(cat <<'EOF' +feat(docs): add data_table action to DocsController + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Add DataTable to sidebar menu + +**Files:** +- Modify: `app/components/shared/menu.rb` + +Components list for the sidebar is resolved via `ComponentsList` concern. Inspect it first; if it pulls from `app/components/ruby_ui/` directory names, nothing extra to add — directory `data_table/` alone will register. If it pulls from a hand-maintained array, add `{name: "DataTable", path: docs_data_table_path}` in alphabetical position. + +- [ ] **Step 4.1: Find the component list source** + +Run: +```bash +grep -rn "ComponentsList" app/components app/helpers app/controllers | head -10 +``` + +- [ ] **Step 4.2: Add entry** + +If `ComponentsList` returns static hashes, add the data_table entry. If it scans directories, skip. Record the decision in the commit body. + +- [ ] **Step 4.3: Commit (if changes)** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +feat(docs-nav): add DataTable sidebar entry + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Demo data module + +**Files:** +- Create: `app/controllers/docs/data_table_demo_data.rb` + +- [ ] **Step 5.1: Create the file** + +```ruby +# 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: 71_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} + # ... continue through id: 100 using the full list from Cirdes's commit + # (see git show 36a61e8 -- app/controllers/docs/data_table_demo_data.rb for the complete array) + ].map { |e| Data.define(*e.keys).new(**e) }.freeze + end +end +``` + +Copy the full 100-row list from `git show 36a61e8 -- app/controllers/docs/data_table_demo_data.rb`. Do not paraphrase. + +- [ ] **Step 5.2: Commit** + +```bash +git add app/controllers/docs/data_table_demo_data.rb +git commit -m "$(cat <<'EOF' +feat(docs): add DataTableDemoData module (100 employees) + +Reused from Cirdes's branch verbatim for demo fixture parity. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Pagination adapter — `Manual` + +**Files:** +- Create: `app/components/ruby_ui/data_table/pagination/manual.rb` +- Create: `test/components/ruby_ui/data_table/pagination/manual_test.rb` + +- [ ] **Step 6.1: Write failing test** + +```ruby +# test/components/ruby_ui/data_table/pagination/manual_test.rb +require "test_helper" + +class RubyUI::DataTable::Pagination::ManualTest < ActiveSupport::TestCase + test "computes total_pages from total_count and per_page" do + adapter = RubyUI::DataTable::Pagination::Manual.new(page: 2, per_page: 10, total_count: 25) + assert_equal 2, adapter.current_page + assert_equal 10, adapter.per_page + assert_equal 25, adapter.total_count + assert_equal 3, adapter.total_pages + end + + test "total_pages is at least 1 for empty total" do + adapter = RubyUI::DataTable::Pagination::Manual.new(page: 1, per_page: 10, total_count: 0) + assert_equal 1, adapter.total_pages + end + + test "coerces integer inputs" do + adapter = RubyUI::DataTable::Pagination::Manual.new(page: "3", per_page: "5", total_count: "12") + assert_equal 3, adapter.current_page + assert_equal 3, adapter.total_pages + end +end +``` + +- [ ] **Step 6.2: Run test, expect fail** + +```bash +dx bin/rails test test/components/ruby_ui/data_table/pagination/manual_test.rb +``` +Expected: NameError / load error (class missing). + +- [ ] **Step 6.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/pagination/manual.rb +# frozen_string_literal: true + +module RubyUI + module DataTable + module Pagination + class Manual + 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 + end +end +``` + +**Note:** `RubyUI::DataTable` will later conflict with the `RubyUI::DataTable` component class. Resolve by defining adapters under `RubyUI::DataTableAdapters::Pagination::Manual` OR leaving the root `DataTable` component as `RubyUI::DataTable` and putting adapters under `RubyUI::DataTablePagination::Manual`. **Decision: use `RubyUI::DataTablePagination` namespace for adapters** (class `RubyUI::DataTable` = component, module `RubyUI::DataTablePagination` = adapter namespace). Update the test class and file accordingly: + +```ruby +# Revised path + class +# File: app/components/ruby_ui/data_table/pagination/manual.rb +module RubyUI + module DataTablePagination + class Manual + ... + end + end +end + +# Test class: RubyUI::DataTablePagination::ManualTest +``` + +Fix both files to use `RubyUI::DataTablePagination::Manual`. + +- [ ] **Step 6.4: Run test, expect pass** + +```bash +dx bin/rails test test/components/ruby_ui/data_table/pagination/manual_test.rb +``` +Expected: 3 runs, 0 failures. + +- [ ] **Step 6.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/pagination/manual.rb \ + test/components/ruby_ui/data_table/pagination/manual_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add Manual pagination adapter + +Normalizes page/per_page/total_count inputs; total_pages >= 1. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Pagination adapter — `Pagy` + +**Files:** +- Create: `app/components/ruby_ui/data_table/pagination/pagy.rb` +- Create: `test/components/ruby_ui/data_table/pagination/pagy_test.rb` + +- [ ] **Step 7.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTablePagination::PagyTest < ActiveSupport::TestCase + PagyDouble = Data.define(:page, :pages, :count, :items) + + test "reads page, pages, count" do + pagy = PagyDouble.new(page: 2, pages: 5, count: 47, items: 10) + adapter = RubyUI::DataTablePagination::Pagy.new(pagy) + assert_equal 2, adapter.current_page + assert_equal 5, adapter.total_pages + assert_equal 47, adapter.total_count + assert_equal 10, adapter.per_page + end +end +``` + +- [ ] **Step 7.2: Run, expect fail** + +```bash +dx bin/rails test test/components/ruby_ui/data_table/pagination/pagy_test.rb +``` + +- [ ] **Step 7.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/pagination/pagy.rb +# frozen_string_literal: true + +module RubyUI + module DataTablePagination + class Pagy + 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 +end +``` + +- [ ] **Step 7.4: Run, expect pass** + +- [ ] **Step 7.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/pagination/pagy.rb \ + test/components/ruby_ui/data_table/pagination/pagy_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add Pagy pagination adapter + +Duck-typed wrapper — does not add pagy gem as a dependency. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Pagination adapter — `Kaminari` + +**Files:** +- Create: `app/components/ruby_ui/data_table/pagination/kaminari.rb` +- Create: `test/components/ruby_ui/data_table/pagination/kaminari_test.rb` + +- [ ] **Step 8.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTablePagination::KaminariTest < ActiveSupport::TestCase + CollectionDouble = Data.define(:current_page, :total_pages, :total_count, :limit_value) + + test "reads current_page, total_pages, total_count, limit_value" do + coll = CollectionDouble.new(current_page: 3, total_pages: 7, total_count: 61, limit_value: 10) + adapter = RubyUI::DataTablePagination::Kaminari.new(coll) + assert_equal 3, adapter.current_page + assert_equal 7, adapter.total_pages + assert_equal 61, adapter.total_count + assert_equal 10, adapter.per_page + end +end +``` + +- [ ] **Step 8.2: Run, expect fail** + +- [ ] **Step 8.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/pagination/kaminari.rb +# frozen_string_literal: true + +module RubyUI + module DataTablePagination + class Kaminari + 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 +end +``` + +- [ ] **Step 8.4: Run, expect pass** + +- [ ] **Step 8.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/pagination/kaminari.rb \ + test/components/ruby_ui/data_table/pagination/kaminari_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add Kaminari pagination adapter + +Duck-typed wrapper — no gem dependency added. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Component — `DataTable` (root) + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table.rb` +- Create: `test/components/ruby_ui/data_table/data_table_test.rb` + +- [ ] **Step 9.1: Failing test** + +```ruby +# test/components/ruby_ui/data_table/data_table_test.rb +require "test_helper" + +class RubyUI::DataTableTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders a turbo-frame with given id" do + output = render RubyUI::DataTable.new(id: "employees") + assert_match %r{]*id="employees"[^>]*target="_top"}, output + end + + test "sets data-controller on inner container" do + output = render RubyUI::DataTable.new(id: "x") + assert_match %r{data-controller="ruby-ui--data-table"}, output + end + + test "renders children inside form" do + output = render(RubyUI::DataTable.new(id: "x")) { "INNER" } + assert_match(/INNER/, output) + assert_match(/ wrapping a with the ruby-ui--data-table +Stimulus controller. Form supports form-first bulk actions via button +formaction. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Component — `DataTableToolbar` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_toolbar.rb` +- Create: `test/components/ruby_ui/data_table/data_table_toolbar_test.rb` + +- [ ] **Step 10.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableToolbarTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders div with flex layout and children" do + out = render(RubyUI::DataTableToolbar.new) { "INNER" } + assert_match(/]*class="[^"]*flex[^"]*"/, out) + assert_match(/INNER/, out) + end +end +``` + +- [ ] **Step 10.2: Run, expect fail** + +- [ ] **Step 10.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_toolbar.rb +# 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 +``` + +- [ ] **Step 10.4: Run, expect pass** + +- [ ] **Step 10.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_toolbar.rb \ + test/components/ruby_ui/data_table/data_table_toolbar_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableToolbar layout slot + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 11: Component — `DataTableSearch` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_search.rb` +- Create: `test/components/ruby_ui/data_table/data_table_search_test.rb` + +- [ ] **Step 11.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableSearchTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders GET form with search input" do + out = render RubyUI::DataTableSearch.new(path: "/x", value: "alice", name: "search") + assert_match(/]*method="get"[^>]*action="\/x"/, out) + assert_match(/]*name="search"[^>]*value="alice"/, out) + end + + test "renames param via name:" do + out = render RubyUI::DataTableSearch.new(path: "/x", name: "q") + assert_match(/name="q"/, out) + end + + test "emits data-turbo-frame when frame_id given" do + out = render RubyUI::DataTableSearch.new(path: "/x", frame_id: "employees") + assert_match(/data-turbo-frame="employees"/, out) + end +end +``` + +- [ ] **Step 11.2: Run, expect fail** + +- [ ] **Step 11.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_search.rb +# frozen_string_literal: true + +module RubyUI + class DataTableSearch < Base + def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", **attrs) + @path = path + @name = name + @value = value + @frame_id = frame_id + @placeholder = placeholder + 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::Input.new( + type: :search, + name: @name, + value: @value, + placeholder: @placeholder, + autocomplete: "off" + ) + end + end + + private + + def default_attrs + {class: "max-w-sm flex-1"} + end + end +end +``` + +- [ ] **Step 11.4: Run, expect pass** + +- [ ] **Step 11.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_search.rb \ + test/components/ruby_ui/data_table/data_table_search_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableSearch (Turbo-Frame GET form) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 12: Component — `DataTablePerPageSelect` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_per_page_select.rb` +- Create: `test/components/ruby_ui/data_table/data_table_per_page_select_test.rb` + +- [ ] **Step 12.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTablePerPageSelectTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders GET form with NativeSelect and options" do + out = render RubyUI::DataTablePerPageSelect.new(path: "/x", value: 25, options: [5, 10, 25, 50]) + assert_match(/]*method="get"[^>]*action="\/x"/, out) + assert_match(/]*name="per_page"/, out) + assert_match(/]*value="25"[^>]*selected/, out) + assert_match(/onchange="this\.form\.requestSubmit\(\)"/, out) + end + + test "renames param via name:" do + out = render RubyUI::DataTablePerPageSelect.new(path: "/x", name: "size") + assert_match(/name="size"/, out) + end +end +``` + +- [ ] **Step 12.2: Run, expect fail** + +- [ ] **Step 12.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_per_page_select.rb +# 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 + select( + name: @name, + onchange: "this.form.requestSubmit()", + class: "h-9 rounded-md border border-input bg-background px-2 text-sm" + ) 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 +``` + +**Note:** The existing `RubyUI::NativeSelect` renders a `` here to keep the `onchange="this.form.requestSubmit()"` attribute easy to emit. If `NativeSelect` accepts arbitrary HTML attributes via `**attrs` passthrough, swap to `render RubyUI::NativeSelect.new(name:, onchange:, ...)` — verify by reading `native_select.rb` and pick whichever preserves native select styling consistency. Decision during implementation. + +- [ ] **Step 12.4: Run, expect pass** + +- [ ] **Step 12.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_per_page_select.rb \ + test/components/ruby_ui/data_table/data_table_per_page_select_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTablePerPageSelect (auto-submitting select) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 13: Component — `DataTableSortHead` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_sort_head.rb` +- Create: `test/components/ruby_ui/data_table/data_table_sort_head_test.rb` + +- [ ] **Step 13.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableSortHeadTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders a with a sort link cycling nil -> asc" do + out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {}) + assert_match(/ next href is desc" do + out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "asc", path: "/x", query: {}) + assert_match(/direction=desc/, out) + end + + test "current desc -> next href clears sort (no params)" do + out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "desc", path: "/x", query: {}) + # No sort/direction params — just /x + assert_match(/href="\/x"/, out) + end + + test "preserves other query params" do + out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {"search" => "alice"}) + assert_match(/search=alice/, out) + end + + test "renames sort/direction params" do + out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort_param: "sort_by", direction_param: "sort_dir", path: "/x", query: {}) + assert_match(/sort_by=name/, out) + assert_match(/sort_dir=asc/, out) + end +end +``` + +- [ ] **Step 13.2: Run, expect fail** + +- [ ] **Step 13.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_sort_head.rb +# frozen_string_literal: true + +module RubyUI + class DataTableSortHead < Base + def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort", direction_param: "direction", path: "", query: {}, **attrs) + @column_key = column_key + @label = label + @sort = sort + @direction = direction + @sort_param = sort_param + @direction_param = direction_param + @path = path + @query = query.to_h.transform_keys(&:to_s) + super(**attrs) + end + + def view_template + render RubyUI::TableHead.new(**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") + next_dir ? base.merge(@sort_param => @column_key.to_s, @direction_param => next_dir) : base + end + + def sort_href + qs = next_params.to_query + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def sort_icon + case current_direction + when "asc" then helpers.lucide_icon("chevron-up", class: "inline-block w-3 h-3") + when "desc" then helpers.lucide_icon("chevron-down", class: "inline-block w-3 h-3") + else helpers.lucide_icon("chevrons-up-down", class: "inline-block w-3 h-3 opacity-30") + end + end + end +end +``` + +- [ ] **Step 13.4: Run, expect pass** + +If `helpers.lucide_icon` is unavailable inside Phlex components, use the module accessor (`Rails.application.routes.url_helpers` pattern doesn't apply here). Verify by reading an existing component that uses `lucide_icon` (e.g. grep `app/components` for `lucide_icon`). Adjust call site. + +- [ ] **Step 13.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_sort_head.rb \ + test/components/ruby_ui/data_table/data_table_sort_head_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableSortHead with asc/desc/none cycle + +Uses lucide-rails file-based SVG icons. Configurable sort/direction +param names. Preserves existing query params. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 14: Component — `DataTableRowCheckbox` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_row_checkbox.rb` +- Create: `test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb` + +- [ ] **Step 14.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableRowCheckboxTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders " do + out = render RubyUI::DataTableRowCheckbox.new(value: 42) + assert_match(/]*type="checkbox"/, out) + assert_match(/name="ids\[\]"/, out) + assert_match(/value="42"/, out) + end + + test "accepts custom name" do + out = render RubyUI::DataTableRowCheckbox.new(value: 1, name: "selected[]") + assert_match(/name="selected\[\]"/, out) + end + + test "carries Stimulus target + action" do + out = render RubyUI::DataTableRowCheckbox.new(value: 1) + assert_match(/data-ruby-ui--data-table-target="rowCheckbox"/, out) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleRow/, out) + end + + test "ARIA label contains the value" do + out = render RubyUI::DataTableRowCheckbox.new(value: 7) + assert_match(/aria-label="Select row 7"/, out) + end +end +``` + +- [ ] **Step 14.2: Run, expect fail** + +- [ ] **Step 14.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_row_checkbox.rb +# frozen_string_literal: true + +module RubyUI + class DataTableRowCheckbox < Base + def initialize(value:, name: "ids[]", **attrs) + @value = value + @name = name + super(**attrs) + end + + def view_template + input( + type: "checkbox", + name: @name, + value: @value, + aria_label: "Select row #{@value}", + class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + data: { + "ruby-ui--data-table-target": "rowCheckbox", + action: "change->ruby-ui--data-table#toggleRow" + }, + **attrs + ) + end + end +end +``` + +- [ ] **Step 14.4: Run, expect pass** + +- [ ] **Step 14.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_row_checkbox.rb \ + test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableRowCheckbox (form-first selection) + +Native so bulk actions submit via +without custom fetch. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 15: Component — `DataTableSelectAllCheckbox` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb` +- Create: `test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb` + +- [ ] **Step 15.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableSelectAllCheckboxTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "carries selectAll target + toggleAll action + aria-label" do + out = render RubyUI::DataTableSelectAllCheckbox.new + assert_match(/]*type="checkbox"/, out) + assert_match(/data-ruby-ui--data-table-target="selectAll"/, out) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleAll/, out) + assert_match(/aria-label="Select all"/, out) + end +end +``` + +- [ ] **Step 15.2: Run, expect fail** + +- [ ] **Step 15.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb +# frozen_string_literal: true + +module RubyUI + class DataTableSelectAllCheckbox < Base + def view_template + input( + type: "checkbox", + aria_label: "Select all", + class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + data: { + "ruby-ui--data-table-target": "selectAll", + action: "change->ruby-ui--data-table#toggleAll" + }, + **attrs + ) + end + end +end +``` + +- [ ] **Step 15.4: Run, expect pass** + +- [ ] **Step 15.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb \ + test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableSelectAllCheckbox + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 16: Component — `DataTableSelectionSummary` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_selection_summary.rb` +- Create: `test/components/ruby_ui/data_table/data_table_selection_summary_test.rb` + +- [ ] **Step 16.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableSelectionSummaryTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders '0 of N row(s) selected.' with target" do + out = render RubyUI::DataTableSelectionSummary.new(total_on_page: 10) + assert_match(/0 of 10 row\(s\) selected\./, out) + assert_match(/data-ruby-ui--data-table-target="selectionSummary"/, out) + end +end +``` + +- [ ] **Step 16.2: Run, expect fail** + +- [ ] **Step 16.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_selection_summary.rb +# 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 +``` + +- [ ] **Step 16.4: Run, expect pass** + +- [ ] **Step 16.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_selection_summary.rb \ + test/components/ruby_ui/data_table/data_table_selection_summary_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableSelectionSummary + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 17: Component — `DataTableBulkActions` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_bulk_actions.rb` +- Create: `test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb` + +- [ ] **Step 17.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableBulkActionsTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "starts hidden with bulkActions target + renders children" do + out = render(RubyUI::DataTableBulkActions.new) { "BUTTONS" } + assert_match(/class="[^"]*hidden[^"]*"/, out) + assert_match(/data-ruby-ui--data-table-target="bulkActions"/, out) + assert_match(/BUTTONS/, out) + end +end +``` + +- [ ] **Step 17.2: Run, expect fail** + +- [ ] **Step 17.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_bulk_actions.rb +# 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 +``` + +- [ ] **Step 17.4: Run, expect pass** + +- [ ] **Step 17.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_bulk_actions.rb \ + test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableBulkActions (hidden until selection>0) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 18: Component — `DataTableSelectionBar` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_selection_bar.rb` +- Create: `test/components/ruby_ui/data_table/data_table_selection_bar_test.rb` + +- [ ] **Step 18.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableSelectionBarTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders with selectionBar target + flex layout + children" do + out = render(RubyUI::DataTableSelectionBar.new) { "INNER" } + assert_match(/data-ruby-ui--data-table-target="selectionBar"/, out) + assert_match(/class="[^"]*flex[^"]*"/, out) + assert_match(/INNER/, out) + end +end +``` + +- [ ] **Step 18.2: Run, expect fail** + +- [ ] **Step 18.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_selection_bar.rb +# frozen_string_literal: true + +module RubyUI + class DataTableSelectionBar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex items-center justify-between gap-4 py-2", + data: {"ruby-ui--data-table-target": "selectionBar"} + } + end + end +end +``` + +- [ ] **Step 18.4: Run, expect pass** + +- [ ] **Step 18.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_selection_bar.rb \ + test/components/ruby_ui/data_table/data_table_selection_bar_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableSelectionBar + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 19: Component — `DataTableColumnToggle` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_column_toggle.rb` +- Create: `test/components/ruby_ui/data_table/data_table_column_toggle_test.rb` + +- [ ] **Step 19.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTableColumnToggleTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "renders dropdown menu with checkbox per column" do + out = render RubyUI::DataTableColumnToggle.new(columns: [ + {key: :email, label: "Email"}, + {key: :salary, label: "Salary"} + ]) + assert_match(/Columns/, out) + assert_match(/data-controller="[^"]*ruby-ui--data-table-column-visibility/, out) + assert_match(/data-column-key="email"/, out) + assert_match(/data-column-key="salary"/, out) + assert_match(/Email/, out) + assert_match(/Salary/, out) + end +end +``` + +- [ ] **Step 19.2: Run, expect fail** + +- [ ] **Step 19.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_column_toggle.rb +# 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" + helpers.lucide_icon("chevron-down", class: "w-4 h-4 ml-1") + 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 +``` + +- [ ] **Step 19.4: Run, expect pass** + +- [ ] **Step 19.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_column_toggle.rb \ + test/components/ruby_ui/data_table/data_table_column_toggle_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTableColumnToggle + +Reuses DropdownMenu; own Stimulus controller for column visibility. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 20: Component — `DataTablePagination` + +**Files:** +- Create: `app/components/ruby_ui/data_table/data_table_pagination.rb` +- Create: `test/components/ruby_ui/data_table/data_table_pagination_test.rb` + +- [ ] **Step 20.1: Failing test** + +```ruby +require "test_helper" + +class RubyUI::DataTablePaginationTest < ActiveSupport::TestCase + include Phlex::Testing::Rails::ViewHelper + + test "accepts manual keyword shortcut" do + out = render RubyUI::DataTablePagination.new(page: 2, per_page: 10, total_count: 25, path: "/x", query: {}) + assert_match(/href="\/x\?page=1"/, out) # Previous + assert_match(/href="\/x\?page=3"/, out) # Next/numbered 3 + end + + test "accepts pagy keyword shortcut (duck-typed double)" do + pagy_double = Data.define(:page, :pages, :count, :items).new(page: 1, pages: 2, count: 15, items: 10) + out = render RubyUI::DataTablePagination.new(pagy: pagy_double, path: "/x", query: {}) + assert_match(/href="\/x\?page=2"/, out) + end + + test "with: accepts custom adapter" do + custom = Data.define(:current_page, :total_pages, :total_count, :per_page).new(1, 3, 20, 10) + out = render RubyUI::DataTablePagination.new(with: custom, path: "/x", query: {}) + assert_match(/href="\/x\?page=2"/, out) + end + + test "renames page param" do + out = render RubyUI::DataTablePagination.new(page: 1, per_page: 10, total_count: 30, path: "/x", query: {}, page_param: "p") + assert_match(/p=2/, out) + end + + test "raises when no adapter and no manual args" do + assert_raises(ArgumentError) { RubyUI::DataTablePagination.new(path: "/x", query: {}) } + end +end +``` + +- [ ] **Step 20.2: Run, expect fail** + +- [ ] **Step 20.3: Implement** + +```ruby +# app/components/ruby_ui/data_table/data_table_pagination.rb +# frozen_string_literal: true + +module RubyUI + class DataTablePagination < Base + WINDOW = 1 + + def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, **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) + super(**attrs) + end + + def view_template + render RubyUI::Pagination.new(**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::DataTablePagination::Pagy.new(pagy) if pagy + return RubyUI::DataTablePagination::Kaminari.new(kaminari) if kaminari + if page && per_page && total_count + return RubyUI::DataTablePagination::Manual.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 + end + + def total + @adapter.total_pages + end + + def page_href(p) + qs = @query.merge(@page_param => p.to_s).to_query + qs.empty? ? @path : "#{@path}?#{qs}" + end + + def prev_item + li do + if current <= 1 + span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Previous" } + else + render RubyUI::PaginationItem.new(href: page_href(current - 1)) { plain "Previous" } + end + end + end + + def next_item + li do + if current >= total + span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Next" } + else + render RubyUI::PaginationItem.new(href: page_href(current + 1)) { plain "Next" } + end + 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 +``` + +- [ ] **Step 20.4: Run, expect pass** + +- [ ] **Step 20.5: Commit** + +```bash +git add app/components/ruby_ui/data_table/data_table_pagination.rb \ + test/components/ruby_ui/data_table/data_table_pagination_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add DataTablePagination with adapter support + +Accepts with: / pagy: / kaminari: / manual args. Numbered pagination +reuses existing Pagination primitives. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 21: Stimulus — `data_table_controller.js` + +**Files:** +- Create: `app/javascript/controllers/ruby_ui/data_table_controller.js` + +- [ ] **Step 21.1: Create file** + +```javascript +// 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(); + } + + 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); + } + + if (this.hasSelectionSummaryTarget) { + this.selectionSummaryTarget.classList.toggle("hidden", selected > 0); + } + } +} +``` + +- [ ] **Step 21.2: Commit** + +```bash +git add app/javascript/controllers/ruby_ui/data_table_controller.js +git commit -m "$(cat <<'EOF' +feat(data_table): add data-table Stimulus controller + +Handles select-all, per-row toggle, selection summary text, bulk +actions visibility. State resets on Turbo Frame swap by design. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 22: Stimulus — `data_table_column_visibility_controller.js` + +**Files:** +- Create: `app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js` + +- [ ] **Step 22.1: Create file** + +```javascript +// 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)); + } +} +``` + +- [ ] **Step 22.2: Commit** + +```bash +git add app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js +git commit -m "$(cat <<'EOF' +feat(data_table): add data-table-column-visibility Stimulus controller + +Scoped DOM query via closest() to the sibling DataTable root. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 23: Register Stimulus controllers + +**Files:** +- Modify: `app/javascript/controllers/index.js` + +- [ ] **Step 23.1: Append registrations** + +Add, grouped with the other ruby-ui imports (alphabetical insertion between `Dialog` and `DropdownMenu`): + +```javascript +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) +``` + +- [ ] **Step 23.2: Rebuild JS** + +```bash +dx pnpm build +``` +Expected: clean build, no errors. + +- [ ] **Step 23.3: Commit** + +```bash +git add app/javascript/controllers/index.js +git commit -m "$(cat <<'EOF' +feat(data_table): register data-table Stimulus controllers + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 24: Demo controller — `index` action + +**Files:** +- Create: `app/controllers/docs/data_table_demo_controller.rb` +- Create: `test/controllers/docs/data_table_demo_controller_test.rb` + +- [ ] **Step 24.1: Failing test** + +```ruby +# test/controllers/docs/data_table_demo_controller_test.rb +require "test_helper" + +class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest + test "GET index returns 200" do + get docs_data_table_demo_url + assert_response :success + end + + test "GET index with ?search= filters employees" do + get docs_data_table_demo_url(search: "alice") + assert_response :success + assert_match(/Alice Johnson/, response.body) + assert_no_match(/Bob Smith/, response.body) + end + + test "GET index with ?sort=name&direction=desc sorts" do + get docs_data_table_demo_url(sort: "name", direction: "desc", per_page: 100) + # Violet comes before Alice when reverse-alphabetical + alice_at = response.body.index("Alice Johnson") + violet_at = response.body.index("Violet Fisher") + assert violet_at < alice_at, "Violet should appear before Alice when sorted desc" + end + + test "GET index with ?sort=salary sorts numerically" do + get docs_data_table_demo_url(sort: "salary", direction: "asc", per_page: 5) + # Smallest salary should be first on page 1 + # Grace Lee (71_000) is among the lowest + assert_match(/Grace Lee/, response.body) + end + + test "GET index paginates" do + get docs_data_table_demo_url(page: 2, per_page: 5) + assert_response :success + end + + test "POST bulk_delete with ids[] redirects + flashes" do + post docs_data_table_demo_bulk_delete_url, params: {ids: ["1", "2"]} + assert_redirected_to docs_data_table_demo_path + follow_redirect! + assert_match(/Would delete: 1, 2/, response.body) + end + + test "POST bulk_export with ids[] redirects + flashes" do + post docs_data_table_demo_bulk_export_url, params: {ids: ["3"]} + assert_redirected_to docs_data_table_demo_path + end +end +``` + +- [ ] **Step 24.2: Run, expect fail** + +```bash +dx bin/rails test test/controllers/docs/data_table_demo_controller_test.rb +``` + +- [ ] **Step 24.3: Implement controller** + +```ruby +# app/controllers/docs/data_table_demo_controller.rb +# 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 + begin + 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" + rescue NoMethodError + # Unknown column — ignore sort + 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 +``` + +- [ ] **Step 24.4: Run test — some will still fail until Demo view exists** + +The bulk_delete/bulk_export tests will pass after this step. Index tests require the view (Task 25). Expected: index-tied tests fail with view missing error; bulk action tests pass. + +- [ ] **Step 24.5: Commit** + +```bash +git add app/controllers/docs/data_table_demo_controller.rb \ + test/controllers/docs/data_table_demo_controller_test.rb +git commit -m "$(cat <<'EOF' +feat(data_table): add demo controller with search/sort/paginate + bulk stubs + +Index tests will go green once the demo view is wired in Task 25. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 25: Demo view — complete example + +**Files:** +- Create: `app/views/docs/data_table_demo/index.rb` + +- [ ] **Step 25.1: Create file** + +```ruby +# app/views/docs/data_table_demo/index.rb +# frozen_string_literal: true + +class Views::Docs::DataTableDemo::Index < Views::Base + FRAME_ID = "employees_list" + + 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..." + ) + 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 + ) + end + end + + 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) } + TableCell(class: "font-medium") { plain e.name } + TableCell(class: "text-muted-foreground", 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 format_currency(e.salary) } + TableCell(class: "w-12 text-right") { row_actions(e) } + end + end + end + end + end + end + + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTableBulkActions do + Button(type: "submit", formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + end + end + + DataTablePagination( + page: @page, + per_page: @per_page, + total_count: @total_count, + path: docs_data_table_demo_path, + query: preserved_query + ) + end + end + + def row_actions(employee) + DropdownMenu do + DropdownMenuTrigger do + Button(type: "button", variant: :ghost, size: :icon, aria_label: "Open menu") do + helpers.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 + + def format_currency(n) + "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" + end +end +``` + +- [ ] **Step 25.2: Run demo controller tests** + +```bash +dx bin/rails test test/controllers/docs/data_table_demo_controller_test.rb +``` +Expected: all 7 tests pass. + +- [ ] **Step 25.3: Commit** + +```bash +git add app/views/docs/data_table_demo/index.rb +git commit -m "$(cat <<'EOF' +feat(data_table): wire complete demo view + +Uses DataTable + existing Table primitives + Stimulus controllers. +All integration tests now pass. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 26: Docs page — scaffold with 6 examples + +**Files:** +- Create: `app/views/docs/data_table.rb` + +- [ ] **Step 26.1: Create file** + +```ruby +# app/views/docs/data_table.rb +# frozen_string_literal: true + +class Views::Docs::DataTable < Views::Base + def view_template + component = "DataTable" + + div(class: "mx-auto w-full py-10 space-y-10") do + render Docs::Header.new( + title: component, + description: "A Hotwire-first, Avo-inspired data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission." + ) + + # ── Example 1: Complete demo (primary) ───────────────────────────────── + 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_path, context: self) do + <<~'RUBY' + DataTable(id: "employees_list") do + DataTableToolbar do + DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search) + DataTableColumnToggle(columns: [ + {key: :email, label: "Email"}, + {key: :department, label: "Department"} + ]) + DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page) + end + + 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 + + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTableBulkActions do + Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } + end + end + + DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path) + end + RUBY + end + + # ── Example 2: Basic static table ───────────────────────────────────── + Heading(level: 2) { "Basic static table" } + p(class: "-mt-6") { "Composition only — no interactivity." } + + render Docs::VisualCodeExample.new(title: "Basic static table", context: self) do + <<~'RUBY' + DataTable(id: "basic") do + Table do + TableHeader do + TableRow do + TableHead { "Name" } + TableHead { "Role" } + end + end + TableBody do + TableRow do + TableCell { "Alice" } + TableCell { "Engineer" } + end + TableRow do + TableCell { "Bob" } + TableCell { "Designer" } + end + end + end + end + RUBY + end + + # ── Example 3: Server-driven (search + sort + pagination) ───────────── + 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) + 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 + + # ── Example 4: Selection + bulk actions ─────────────────────────────── + Heading(level: 2) { "Selection + bulk actions" } + p(class: "-mt-6") { "Form-first: row checkboxes are , bulk buttons submit via formaction." } + + render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do + <<~'RUBY' + DataTable(id: "selection") 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 + + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @rows.size) + DataTableBulkActions do + Button(type: "submit", formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } + Button(type: "submit", formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } + end + end + end + RUBY + end + + # ── Example 5: Column visibility ────────────────────────────────────── + Heading(level: 2) { "Column visibility" } + p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." } + + 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 + + # ── Example 6: Custom cell renderers ────────────────────────────────── + 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 format_currency(n) + "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" + end + + 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 format_currency(r.salary) } + end + end + end + end + end + RUBY + end + end + end +end +``` + +- [ ] **Step 26.2: Verify the docs page renders** + +```bash +dx bin/rails runner "puts Views::Docs::DataTable.new.call[0..200]" +``` +Expected: HTML string starting with ` +EOF +)" +``` + +--- + +## Task 27: Full test suite + lint + +- [ ] **Step 27.1: Run full test suite** + +```bash +dx bin/rails test +``` +Expected: all tests pass; no new failures vs baseline (Task 1 step 1.4). + +- [ ] **Step 27.2: Run StandardRB lint (if present in web)** + +Check: `grep standard Gemfile` — if present, run: +```bash +dx bundle exec standardrb +``` +Expected: clean. Fix any violations; commit per file. + +- [ ] **Step 27.3: If lint fixes needed, commit** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +style(data_table): fix lint + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 28: Manual smoke test in browser + +- [ ] **Step 28.1: Ensure Rails server running** + +```bash +docker exec rubyui-web-rails-app-1 bash -c 'pgrep -fa "rails server" || true' +``` +If not running: +```bash +dx "nohup bin/rails server -b 0.0.0.0 -p 3000 > /tmp/rails.log 2>&1 &" +sleep 3 +dx 'curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/docs/data_table' +``` +Expected: `200`. + +- [ ] **Step 28.2: Visit in browser** + +Host: `http://localhost:3001/docs/data_table` + +Verify (checklist for the implementing agent to report back on): + +- [ ] Docs page renders with 6 example sections +- [ ] Example 1 iframe loads the complete demo +- [ ] Search input filters as form is submitted +- [ ] Sort header cycles asc/desc/none with correct icon +- [ ] Per-page select auto-submits +- [ ] Numbered pagination links work +- [ ] Row checkbox toggles → summary text updates +- [ ] Select-all → indeterminate + full state correct +- [ ] Bulk actions bar appears only when selection > 0 +- [ ] Clicking "Delete" submits form → redirects with flash +- [ ] Column toggle hides/shows matching columns +- [ ] Row actions dropdown opens + +Report results in the PR body. + +- [ ] **Step 28.3: Push branch** + +```bash +git push -u origin da/datatable-hotwire +``` + +- [ ] **Step 28.4: Open PR** (only with explicit user approval) + +```bash +gh pr create --title "feat(data_table): Hotwire-first DataTable component family" --body "$(cat <<'EOF' +## Summary +- Adds `DataTable` and 11 supporting components at `app/components/ruby_ui/data_table/` +- Form-first bulk actions (row checkboxes are ``) +- Two small Stimulus controllers (selection coordinator + column visibility) +- Three pagination adapters: Manual, Pagy, Kaminari (duck-typed, no gem deps) +- Six documentation examples at `/docs/data_table` +- Demo controller + view at `/docs/data_table_demo` powering the primary example +- 12 component render tests + 3 adapter tests + controller integration test + +## Test plan +- [ ] All tests green: `dx bin/rails test` +- [ ] Lint clean: `dx bundle exec standardrb` +- [ ] Manual smoke — see Task 28.2 checklist in `docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md` +- [ ] Spec: `docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Self-review checklist (run before dispatching tasks) + +- [ ] All 12 components have tasks (Tasks 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) ✔ +- [ ] Both Stimulus controllers have tasks (21, 22) ✔ +- [ ] Three pagination adapters have tasks (6, 7, 8) ✔ +- [ ] Demo controller + tests: Task 24 ✔ +- [ ] Demo view: Task 25 ✔ +- [ ] Docs view (6 examples): Task 26 ✔ +- [ ] Routes: Task 2 ✔; DocsController action: Task 3 ✔; menu: Task 4 ✔ +- [ ] Controllers index.js registration: Task 23 ✔ +- [ ] Lint + full test run: Task 27 ✔ +- [ ] Browser smoke: Task 28 ✔ +- [ ] Namespacing consistent: `RubyUI::DataTable` = component, `RubyUI::DataTablePagination` = adapters module ✔ +- [ ] Each task has a commit step ✔ +- [ ] No placeholders ("TODO", "implement later") — all code blocks present ✔ +- [ ] Subagent model enforced in header ✔ From 5fc6f3f3c7c0ff598a37d5c5dc981b2aa18aa383 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:24:59 -0300 Subject: [PATCH 03/81] chore(gitignore): exclude vendor/bundle; plan fixes Adds /vendor/bundle to .gitignore (local BUNDLE_PATH install). Fixes plan's dx helper node path to 25.8.2 and records baseline test count. --- .gitignore | 1 + docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 011574f2..401f00c6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # Ignore bundler config. /.bundle +/vendor/bundle # Ignore all logfiles and tempfiles. /log/* diff --git a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md index 3c0b0f90..cedfcbda 100644 --- a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md +++ b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md @@ -56,7 +56,7 @@ Every later task's `Run:` lines assume this helper. Paste this into your shell: ```bash dx() { docker exec rubyui-web-rails-app-1 bash -c ' - export PATH="/home/vscode/.local/bin:/home/vscode/.local/share/mise/installs/ruby/3.4.7/bin:/home/vscode/.local/share/mise/installs/node/22.22.1/bin:$PATH" + export PATH="/home/vscode/.local/share/mise/installs/node/25.8.2/bin:/home/vscode/.local/bin:/home/vscode/.local/share/mise/installs/ruby/3.4.7/bin:$PATH" export SECRET_KEY_BASE=abc123 export BUNDLE_PATH=/workspaces/web/vendor/bundle cd /workspaces/web @@ -64,6 +64,8 @@ dx() { } ``` +**Baseline confirmed:** 61 runs, 0 failures, 0 errors before starting. + - [ ] **Step 1.3: Start devcontainer (if not running)** ```bash From 70bc6099af6db2c769aa1787fccda4a7a2379e1e Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:26:23 -0300 Subject: [PATCH 04/81] feat(routes): data_table docs and demo endpoints --- config/routes.rb | 6 ++++++ 1 file changed, 6 insertions(+) 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 6f0b1ca9cc01f4b73ef70ae2abd356c8c367df27 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:28:00 -0300 Subject: [PATCH 05/81] feat(docs): add data_table action to DocsController --- app/controllers/docs_controller.rb | 4 ++++ 1 file changed, 4 insertions(+) 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 From f1c204cc37de50215202be2c3d2f8b0c6cd4e196 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:29:10 -0300 Subject: [PATCH 06/81] feat(docs-nav): add DataTable sidebar entry --- app/components/shared/components_list.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/shared/components_list.rb b/app/components/shared/components_list.rb index 49c82490..9cbe8bf2 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: "DataTable", 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}, From 469d4d9d7230db4f1aedb81c6abe01a41a94b193 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:31:22 -0300 Subject: [PATCH 07/81] feat(docs): add DataTableDemoData module (100 employees) Reused from Cirdes's branch (commit 36a61e8) verbatim for demo fixture parity. --- app/controllers/docs/data_table_demo_data.rb | 108 +++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 app/controllers/docs/data_table_demo_data.rb 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..42f2a6c4 --- /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: 71_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 From ffc09bfd7461621a2e1ff1068917dd208d587226 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:36:19 -0300 Subject: [PATCH 08/81] feat(data_table): add Manual pagination adapter Normalizes page/per_page/total_count inputs; total_pages >= 1. Places adapter in app/components/ruby_ui/data_table_pagination/ (Option A) and excludes that directory from the Zeitwerk collapse glob so that RubyUI::DataTablePagination resolves as a proper module namespace. --- .../ruby_ui/data_table_pagination/manual.rb | 19 ++++++++++++++++ config/initializers/ruby_ui.rb | 7 +++++- .../data_table_pagination/manual_test.rb | 22 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 app/components/ruby_ui/data_table_pagination/manual.rb create mode 100644 test/components/ruby_ui/data_table_pagination/manual_test.rb diff --git a/app/components/ruby_ui/data_table_pagination/manual.rb b/app/components/ruby_ui/data_table_pagination/manual.rb new file mode 100644 index 00000000..ea9e955d --- /dev/null +++ b/app/components/ruby_ui/data_table_pagination/manual.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module RubyUI + module DataTablePagination + class Manual + 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 +end diff --git a/config/initializers/ruby_ui.rb b/config/initializers/ruby_ui.rb index a5b0a4d0..e53a8f22 100644 --- a/config/initializers/ruby_ui.rb +++ b/config/initializers/ruby_ui.rb @@ -15,4 +15,9 @@ module RubyUI ) # Allow using RubyUI::ComponentName instead RubyUI::ComponentName::ComponentName -Rails.autoloaders.main.collapse(Rails.root.join("app/components/ruby_ui/*")) +# data_table_pagination/ is intentionally excluded from collapse so that +# RubyUI::DataTablePagination is a proper module (adapter namespace). +collapse_dirs = Dir.glob(Rails.root.join("app/components/ruby_ui/*")).reject do |path| + path.end_with?("data_table_pagination") +end +Rails.autoloaders.main.collapse(collapse_dirs) unless collapse_dirs.empty? diff --git a/test/components/ruby_ui/data_table_pagination/manual_test.rb b/test/components/ruby_ui/data_table_pagination/manual_test.rb new file mode 100644 index 00000000..819911c4 --- /dev/null +++ b/test/components/ruby_ui/data_table_pagination/manual_test.rb @@ -0,0 +1,22 @@ +require "test_helper" + +class RubyUI::DataTablePagination::ManualTest < ActiveSupport::TestCase + test "computes total_pages from total_count and per_page" do + adapter = RubyUI::DataTablePagination::Manual.new(page: 2, per_page: 10, total_count: 25) + assert_equal 2, adapter.current_page + assert_equal 10, adapter.per_page + assert_equal 25, adapter.total_count + assert_equal 3, adapter.total_pages + end + + test "total_pages is at least 1 for empty total" do + adapter = RubyUI::DataTablePagination::Manual.new(page: 1, per_page: 10, total_count: 0) + assert_equal 1, adapter.total_pages + end + + test "coerces integer inputs" do + adapter = RubyUI::DataTablePagination::Manual.new(page: "3", per_page: "5", total_count: "12") + assert_equal 3, adapter.current_page + assert_equal 3, adapter.total_pages + end +end From f30c091686610c3b54ce5b0093a2dc2a2500efe3 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:40:21 -0300 Subject: [PATCH 09/81] docs(plan): fix adapter paths to match Zeitwerk layout After Task 6 landed, file paths for adapters are data_table_pagination/* (not data_table/pagination/*) to produce RubyUI::DataTablePagination::X without collapse issues. Patches Tasks 7/8 paths. --- .../2026-04-24-datatable-hotwire-plan.md | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md index cedfcbda..b56bfbad 100644 --- a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md +++ b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md @@ -233,13 +233,13 @@ EOF ## Task 6: Pagination adapter — `Manual` **Files:** -- Create: `app/components/ruby_ui/data_table/pagination/manual.rb` -- Create: `test/components/ruby_ui/data_table/pagination/manual_test.rb` +- Create: `app/components/ruby_ui/data_table_pagination/manual.rb` +- Create: `test/components/ruby_ui/data_table_pagination/manual_test.rb` - [ ] **Step 6.1: Write failing test** ```ruby -# test/components/ruby_ui/data_table/pagination/manual_test.rb +# test/components/ruby_ui/data_table_pagination/manual_test.rb require "test_helper" class RubyUI::DataTable::Pagination::ManualTest < ActiveSupport::TestCase @@ -267,14 +267,14 @@ end - [ ] **Step 6.2: Run test, expect fail** ```bash -dx bin/rails test test/components/ruby_ui/data_table/pagination/manual_test.rb +dx bin/rails test test/components/ruby_ui/data_table_pagination/manual_test.rb ``` Expected: NameError / load error (class missing). - [ ] **Step 6.3: Implement** ```ruby -# app/components/ruby_ui/data_table/pagination/manual.rb +# app/components/ruby_ui/data_table_pagination/manual.rb # frozen_string_literal: true module RubyUI @@ -302,7 +302,7 @@ end ```ruby # Revised path + class -# File: app/components/ruby_ui/data_table/pagination/manual.rb +# File: app/components/ruby_ui/data_table_pagination/manual.rb module RubyUI module DataTablePagination class Manual @@ -319,15 +319,15 @@ Fix both files to use `RubyUI::DataTablePagination::Manual`. - [ ] **Step 6.4: Run test, expect pass** ```bash -dx bin/rails test test/components/ruby_ui/data_table/pagination/manual_test.rb +dx bin/rails test test/components/ruby_ui/data_table_pagination/manual_test.rb ``` Expected: 3 runs, 0 failures. - [ ] **Step 6.5: Commit** ```bash -git add app/components/ruby_ui/data_table/pagination/manual.rb \ - test/components/ruby_ui/data_table/pagination/manual_test.rb +git add app/components/ruby_ui/data_table_pagination/manual.rb \ + test/components/ruby_ui/data_table_pagination/manual_test.rb git commit -m "$(cat <<'EOF' feat(data_table): add Manual pagination adapter @@ -343,8 +343,8 @@ EOF ## Task 7: Pagination adapter — `Pagy` **Files:** -- Create: `app/components/ruby_ui/data_table/pagination/pagy.rb` -- Create: `test/components/ruby_ui/data_table/pagination/pagy_test.rb` +- Create: `app/components/ruby_ui/data_table_pagination/pagy.rb` +- Create: `test/components/ruby_ui/data_table_pagination/pagy_test.rb` - [ ] **Step 7.1: Failing test** @@ -368,13 +368,13 @@ end - [ ] **Step 7.2: Run, expect fail** ```bash -dx bin/rails test test/components/ruby_ui/data_table/pagination/pagy_test.rb +dx bin/rails test test/components/ruby_ui/data_table_pagination/pagy_test.rb ``` - [ ] **Step 7.3: Implement** ```ruby -# app/components/ruby_ui/data_table/pagination/pagy.rb +# app/components/ruby_ui/data_table_pagination/pagy.rb # frozen_string_literal: true module RubyUI @@ -398,8 +398,8 @@ end - [ ] **Step 7.5: Commit** ```bash -git add app/components/ruby_ui/data_table/pagination/pagy.rb \ - test/components/ruby_ui/data_table/pagination/pagy_test.rb +git add app/components/ruby_ui/data_table_pagination/pagy.rb \ + test/components/ruby_ui/data_table_pagination/pagy_test.rb git commit -m "$(cat <<'EOF' feat(data_table): add Pagy pagination adapter @@ -415,8 +415,8 @@ EOF ## Task 8: Pagination adapter — `Kaminari` **Files:** -- Create: `app/components/ruby_ui/data_table/pagination/kaminari.rb` -- Create: `test/components/ruby_ui/data_table/pagination/kaminari_test.rb` +- Create: `app/components/ruby_ui/data_table_pagination/kaminari.rb` +- Create: `test/components/ruby_ui/data_table_pagination/kaminari_test.rb` - [ ] **Step 8.1: Failing test** @@ -442,7 +442,7 @@ end - [ ] **Step 8.3: Implement** ```ruby -# app/components/ruby_ui/data_table/pagination/kaminari.rb +# app/components/ruby_ui/data_table_pagination/kaminari.rb # frozen_string_literal: true module RubyUI @@ -466,8 +466,8 @@ end - [ ] **Step 8.5: Commit** ```bash -git add app/components/ruby_ui/data_table/pagination/kaminari.rb \ - test/components/ruby_ui/data_table/pagination/kaminari_test.rb +git add app/components/ruby_ui/data_table_pagination/kaminari.rb \ + test/components/ruby_ui/data_table_pagination/kaminari_test.rb git commit -m "$(cat <<'EOF' feat(data_table): add Kaminari pagination adapter From 14585dfb7a37d200b1db20b99bd244aa957d47be Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 14:41:19 -0300 Subject: [PATCH 10/81] feat(data_table): add Pagy pagination adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duck-typed wrapper — does not add pagy gem as a dependency. --- .../ruby_ui/data_table_pagination/pagy.rb | 16 ++++++++++++++++ .../ruby_ui/data_table_pagination/pagy_test.rb | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 app/components/ruby_ui/data_table_pagination/pagy.rb create mode 100644 test/components/ruby_ui/data_table_pagination/pagy_test.rb diff --git a/app/components/ruby_ui/data_table_pagination/pagy.rb b/app/components/ruby_ui/data_table_pagination/pagy.rb new file mode 100644 index 00000000..dab32e61 --- /dev/null +++ b/app/components/ruby_ui/data_table_pagination/pagy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module RubyUI + module DataTablePagination + class Pagy + 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 +end diff --git a/test/components/ruby_ui/data_table_pagination/pagy_test.rb b/test/components/ruby_ui/data_table_pagination/pagy_test.rb new file mode 100644 index 00000000..2c45ef50 --- /dev/null +++ b/test/components/ruby_ui/data_table_pagination/pagy_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTablePagination::PagyTest < ActiveSupport::TestCase + PagyDouble = Data.define(:page, :pages, :count, :items) + + test "reads page, pages, count, items" do + pagy = PagyDouble.new(page: 2, pages: 5, count: 47, items: 10) + adapter = RubyUI::DataTablePagination::Pagy.new(pagy) + assert_equal 2, adapter.current_page + assert_equal 5, adapter.total_pages + assert_equal 47, adapter.total_count + assert_equal 10, adapter.per_page + end +end From 27fb01c1d894944fc3d13aea4030802d1fc07d45 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:02:22 -0300 Subject: [PATCH 11/81] feat(data_table): add Kaminari pagination adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duck-typed wrapper — no gem dependency added. --- .../ruby_ui/data_table_pagination/kaminari.rb | 16 ++++++++++++++++ .../data_table_pagination/kaminari_test.rb | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 app/components/ruby_ui/data_table_pagination/kaminari.rb create mode 100644 test/components/ruby_ui/data_table_pagination/kaminari_test.rb diff --git a/app/components/ruby_ui/data_table_pagination/kaminari.rb b/app/components/ruby_ui/data_table_pagination/kaminari.rb new file mode 100644 index 00000000..4755e9e1 --- /dev/null +++ b/app/components/ruby_ui/data_table_pagination/kaminari.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module RubyUI + module DataTablePagination + class Kaminari + 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 +end diff --git a/test/components/ruby_ui/data_table_pagination/kaminari_test.rb b/test/components/ruby_ui/data_table_pagination/kaminari_test.rb new file mode 100644 index 00000000..165fc93a --- /dev/null +++ b/test/components/ruby_ui/data_table_pagination/kaminari_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTablePagination::KaminariTest < ActiveSupport::TestCase + CollectionDouble = Data.define(:current_page, :total_pages, :total_count, :limit_value) + + test "reads current_page, total_pages, total_count, limit_value" do + coll = CollectionDouble.new(current_page: 3, total_pages: 7, total_count: 61, limit_value: 10) + adapter = RubyUI::DataTablePagination::Kaminari.new(coll) + assert_equal 3, adapter.current_page + assert_equal 7, adapter.total_pages + assert_equal 61, adapter.total_count + assert_equal 10, adapter.per_page + end +end From 1daa647013e032cd3952e6f9e9732794c15b0945 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:04:44 -0300 Subject: [PATCH 12/81] feat(data_table): add DataTable root component Renders wrapping a with the ruby-ui--data-table Stimulus controller. Form supports form-first bulk actions via button formaction. --- .../ruby_ui/data_table/data_table.rb | 36 +++++++++++++++++++ .../ruby_ui/data_table/data_table_test.rb | 21 +++++++++++ 2 files changed, 57 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table.rb create mode 100644 test/components/ruby_ui/data_table/data_table_test.rb 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..fb005c57 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module RubyUI + class DataTable < Base + register_element :turbo_frame, tag: "turbo-frame" + + def initialize(id:, action: "", **attrs) + @frame_id = id + @action = action + super(**attrs) + end + + def view_template(&block) + turbo_frame(id: @frame_id, target: "_top") do + div(**attrs) do + form(action: @action, method: "post", data: {controller: "ruby-ui--data-table"}) do + input(type: "hidden", name: "authenticity_token", value: csrf_token) + yield if block + end + end + end + end + + private + + def default_attrs + {class: "w-full space-y-4"} + end + + def csrf_token + helpers.form_authenticity_token + rescue + SecureRandom.hex(32) + end + end +end diff --git a/test/components/ruby_ui/data_table/data_table_test.rb b/test/components/ruby_ui/data_table/data_table_test.rb new file mode 100644 index 00000000..ca252f46 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableTest < ActiveSupport::TestCase + test "renders a turbo-frame with given id" do + output = RubyUI::DataTable.new(id: "employees").call + assert_match %r{]*id="employees"[^>]*target="_top"}, output + end + + test "sets data-controller on inner form" do + output = RubyUI::DataTable.new(id: "x").call + assert_match(/data-controller="ruby-ui--data-table"/, output) + end + + test "renders children inside form" do + output = RubyUI::DataTable.new(id: "x").call { "INNER" } + assert_match(/INNER/, output) + assert_match(/ Date: Fri, 24 Apr 2026 15:07:59 -0300 Subject: [PATCH 13/81] fix(data_table): narrow CSRF rescue + assert token in test respond_to? check replaces bare rescue so unexpected errors surface instead of being swallowed. New test guards against silent token drop. --- app/components/ruby_ui/data_table/data_table.rb | 4 +--- test/components/ruby_ui/data_table/data_table_test.rb | 5 +++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index fb005c57..99282f96 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -28,9 +28,7 @@ def default_attrs end def csrf_token - helpers.form_authenticity_token - rescue - SecureRandom.hex(32) + helpers.respond_to?(:form_authenticity_token) ? helpers.form_authenticity_token : SecureRandom.hex(32) end end end diff --git a/test/components/ruby_ui/data_table/data_table_test.rb b/test/components/ruby_ui/data_table/data_table_test.rb index ca252f46..0a468b75 100644 --- a/test/components/ruby_ui/data_table/data_table_test.rb +++ b/test/components/ruby_ui/data_table/data_table_test.rb @@ -18,4 +18,9 @@ class RubyUI::DataTableTest < ActiveSupport::TestCase assert_match(/INNER/, output) assert_match(/]*type="hidden"[^>]*name="authenticity_token"[^>]*value="[^"]+"/, output) + end end From bc4857431be1910f5869d7394b478795bdcdf585 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:08:51 -0300 Subject: [PATCH 14/81] feat(data_table): add DataTableToolbar layout slot --- .../ruby_ui/data_table/data_table_toolbar.rb | 15 +++++++++++++++ .../ruby_ui/data_table/data_table_toolbar_test.rb | 9 +++++++++ 2 files changed, 24 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_toolbar.rb create mode 100644 test/components/ruby_ui/data_table/data_table_toolbar_test.rb 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/test/components/ruby_ui/data_table/data_table_toolbar_test.rb b/test/components/ruby_ui/data_table/data_table_toolbar_test.rb new file mode 100644 index 00000000..9a8b02e3 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_toolbar_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class RubyUI::DataTableToolbarTest < ActiveSupport::TestCase + test "renders div with flex layout and children" do + out = RubyUI::DataTableToolbar.new.call { "INNER" } + assert_match(/]*class="[^"]*flex[^"]*"/, out) + assert_match(/INNER/, out) + end +end From 1e7597d979cd8422382f86486e640090a52b4193 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:11:25 -0300 Subject: [PATCH 15/81] feat(data_table): add DataTableSearch (Turbo-Frame GET form) --- .../ruby_ui/data_table/data_table_search.rb | 41 +++++++++++++++++++ .../data_table/data_table_search_test.rb | 21 ++++++++++ 2 files changed, 62 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_search.rb create mode 100644 test/components/ruby_ui/data_table/data_table_search_test.rb 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..c71d20ce --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_search.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSearch < Base + def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", **attrs) + @path = path + @name = name + @value = value + @frame_id = frame_id + @placeholder = placeholder + super(**attrs) + end + + def view_template + form_attrs = {method: "get", action: @path} + form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id + + form(**attrs.merge(form_attrs)) do + input( + type: :search, + name: @name, + value: @value, + placeholder: @placeholder, + autocomplete: "off", + class: [ + "flex h-9 w-full rounded-md border bg-background px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] border-border", + "placeholder:text-muted-foreground", + "disabled:cursor-not-allowed disabled:opacity-50", + "focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:border-ring focus-visible:shadow-sm" + ] + ) + end + end + + private + + def default_attrs + {class: "max-w-sm flex-1"} + end + end +end diff --git a/test/components/ruby_ui/data_table/data_table_search_test.rb b/test/components/ruby_ui/data_table/data_table_search_test.rb new file mode 100644 index 00000000..c26a862c --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_search_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSearchTest < ActiveSupport::TestCase + test "renders GET form with search input" do + out = RubyUI::DataTableSearch.new(path: "/x", value: "alice", name: "search").call + assert_match(/]*method="get"[^>]*action="\/x"/, out) + assert_match(/]*name="search"[^>]*value="alice"/, out) + end + + test "renames param via name:" do + out = RubyUI::DataTableSearch.new(path: "/x", name: "q").call + assert_match(/name="q"/, out) + end + + test "emits data-turbo-frame when frame_id given" do + out = RubyUI::DataTableSearch.new(path: "/x", frame_id: "employees").call + assert_match(/data-turbo-frame="employees"/, out) + end +end From dd3bad74856208fd1ae4a4c01b466204620ecc2b Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:12:25 -0300 Subject: [PATCH 16/81] fix(data_table): use RubyUI::Input in Search (spec-consistent) Reverts raw to RubyUI::Input for styling + behavior parity with Cirdes's reference. Tests use simpler regexes that tolerate Input's data-action attribute. --- app/components/ruby_ui/data_table/data_table_search.rb | 10 ++-------- .../ruby_ui/data_table/data_table_search_test.rb | 3 ++- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_search.rb b/app/components/ruby_ui/data_table/data_table_search.rb index c71d20ce..3fdd70f1 100644 --- a/app/components/ruby_ui/data_table/data_table_search.rb +++ b/app/components/ruby_ui/data_table/data_table_search.rb @@ -16,18 +16,12 @@ def view_template form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id form(**attrs.merge(form_attrs)) do - input( + render RubyUI::Input.new( type: :search, name: @name, value: @value, placeholder: @placeholder, - autocomplete: "off", - class: [ - "flex h-9 w-full rounded-md border bg-background px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] border-border", - "placeholder:text-muted-foreground", - "disabled:cursor-not-allowed disabled:opacity-50", - "focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:border-ring focus-visible:shadow-sm" - ] + autocomplete: "off" ) end end diff --git a/test/components/ruby_ui/data_table/data_table_search_test.rb b/test/components/ruby_ui/data_table/data_table_search_test.rb index c26a862c..833ecfc6 100644 --- a/test/components/ruby_ui/data_table/data_table_search_test.rb +++ b/test/components/ruby_ui/data_table/data_table_search_test.rb @@ -6,7 +6,8 @@ class RubyUI::DataTableSearchTest < ActiveSupport::TestCase test "renders GET form with search input" do out = RubyUI::DataTableSearch.new(path: "/x", value: "alice", name: "search").call assert_match(/]*method="get"[^>]*action="\/x"/, out) - assert_match(/]*name="search"[^>]*value="alice"/, out) + assert_match(/name="search"/, out) + assert_match(/value="alice"/, out) end test "renames param via name:" do From 2fa86ea26e39e75ec89cd7daacc1b24c858b9936 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:15:06 -0300 Subject: [PATCH 17/81] feat(data_table): add DataTablePerPageSelect (auto-submitting select) --- .../data_table/data_table_per_page_select.rb | 39 +++++++++++++++++++ .../data_table_per_page_select_test.rb | 23 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_per_page_select.rb create mode 100644 test/components/ruby_ui/data_table/data_table_per_page_select_test.rb 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..43dd045c --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_per_page_select.rb @@ -0,0 +1,39 @@ +# 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 + select( + name: @name, + onchange: safe("this.form.requestSubmit()"), + class: "h-9 rounded-md border border-input bg-background px-2 text-sm" + ) 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/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb b/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb new file mode 100644 index 00000000..7ff29d5c --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class RubyUI::DataTablePerPageSelectTest < ActiveSupport::TestCase + test "renders GET form with select and options" do + out = RubyUI::DataTablePerPageSelect.new(path: "/x", value: 25, options: [5, 10, 25, 50]).call + assert_match(/]*(method="get"[^>]*action="\/x"|action="\/x"[^>]*method="get")/, out) + assert_match(/]*name="per_page"/, out) + assert_match(/value="25"[^>]*selected|selected[^>]*value="25"/, out) + assert_match(/onchange="this\.form\.requestSubmit\(\)"/, out) + end + + test "renames param via name:" do + out = RubyUI::DataTablePerPageSelect.new(path: "/x", name: "size").call + assert_match(/name="size"/, out) + end + + test "includes given options" do + out = RubyUI::DataTablePerPageSelect.new(path: "/x", options: [5, 10, 25]).call + assert_match(/]*value="5"/, out) + assert_match(/]*value="10"/, out) + assert_match(/]*value="25"/, out) + end +end From ede6b9b73c7dd603e4f273d483f44714af77b7d5 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:16:51 -0300 Subject: [PATCH 18/81] feat(data_table): add DataTableSortHead with asc/desc/none cycle Wraps TableHead with sort link. Uses lucide-rails file-based icons (chevron-up/chevron-down/chevrons-up-down). Configurable param names. --- .../data_table/data_table_sort_head.rb | 59 +++++++++++++++++++ .../data_table/data_table_sort_head_test.rb | 33 +++++++++++ 2 files changed, 92 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_sort_head.rb create mode 100644 test/components/ruby_ui/data_table/data_table_sort_head_test.rb 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..ba316b3b --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_sort_head.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSortHead < Base + def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort", direction_param: "direction", path: "", query: {}, **attrs) + @column_key = column_key + @label = label + @sort = sort + @direction = direction + @sort_param = sort_param + @direction_param = direction_param + @path = path + @query = query.to_h.transform_keys(&:to_s) + super(**attrs) + end + + def view_template + render RubyUI::TableHead.new(**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") + next_dir ? base.merge(@sort_param => @column_key.to_s, @direction_param => next_dir) : base + end + + def sort_href + qs = next_params.to_query + qs.empty? ? @path : "#{@path}?#{qs}" + 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" + + vc = respond_to?(:view_context) ? view_context : nil + if vc&.respond_to?(:lucide_icon) + raw vc.lucide_icon(icon_name, class: icon_class) + else + span(class: icon_class, "data-icon": icon_name) + end + end + end +end diff --git a/test/components/ruby_ui/data_table/data_table_sort_head_test.rb b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb new file mode 100644 index 00000000..f8b7023e --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "test_helper" + +class RubyUI::DataTableSortHeadTest < ActiveSupport::TestCase + test "renders a with a sort link cycling nil -> asc" do + out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {}).call + assert_match(/ next href is desc" do + out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "asc", path: "/x", query: {}).call + assert_match(/direction=desc/, out) + end + + test "current desc -> next href clears sort (no params)" do + out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "desc", path: "/x", query: {}).call + assert_match(/href="\/x"/, out) + end + + test "preserves other query params" do + out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {"search" => "alice"}).call + assert_match(/search=alice/, out) + end + + test "renames sort/direction params" do + out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort_param: "sort_by", direction_param: "sort_dir", path: "/x", query: {}).call + assert_match(/sort_by=name/, out) + assert_match(/sort_dir=asc/, out) + end +end From 9b08a0445b99670880a78a0f8994c10f870d0c47 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:17:52 -0300 Subject: [PATCH 19/81] feat(data_table): add DataTableRowCheckbox (form-first selection) Native so bulk actions submit via without custom fetch. Default aria-label="Select row N". --- .../data_table/data_table_row_checkbox.rb | 31 +++++++++++++++++++ .../data_table_row_checkbox_test.rb | 26 ++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_row_checkbox.rb create mode 100644 test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb 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..a05d2e1a --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableRowCheckbox < Base + def initialize(value:, name: "ids[]", **attrs) + @value = value + @name = name + super(**attrs) + end + + def view_template + input(**attrs) + end + + private + + def default_attrs + { + type: "checkbox", + name: @name, + value: @value, + aria_label: "Select row #{@value}", + class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + data: { + "ruby-ui--data-table-target": "rowCheckbox", + action: "change->ruby-ui--data-table#toggleRow" + } + } + end + end +end diff --git a/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb b/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb new file mode 100644 index 00000000..fa38175f --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb @@ -0,0 +1,26 @@ +require "test_helper" + +class RubyUI::DataTableRowCheckboxTest < ActiveSupport::TestCase + test "renders " do + out = RubyUI::DataTableRowCheckbox.new(value: 42).call + assert_match(/]*type="checkbox"/, out) + assert_match(/name="ids\[\]"/, out) + assert_match(/value="42"/, out) + end + + test "accepts custom name" do + out = RubyUI::DataTableRowCheckbox.new(value: 1, name: "selected[]").call + assert_match(/name="selected\[\]"/, out) + end + + test "carries Stimulus target + action" do + out = RubyUI::DataTableRowCheckbox.new(value: 1).call + assert_match(/data-ruby-ui--data-table-target="rowCheckbox"/, out) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleRow/, out) + end + + test "ARIA label contains the value" do + out = RubyUI::DataTableRowCheckbox.new(value: 7).call + assert_match(/aria-label="Select row 7"/, out) + end +end From 41af7be37eed422a31755d8c0b8ee966ff5c874d Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:18:30 -0300 Subject: [PATCH 20/81] feat(data_table): add DataTableSelectAllCheckbox --- .../data_table_select_all_checkbox.rb | 23 +++++++++++++++++++ .../data_table_select_all_checkbox_test.rb | 11 +++++++++ 2 files changed, 34 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb create mode 100644 test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb 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..c807042b --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSelectAllCheckbox < Base + def view_template + input(**attrs) + end + + private + + def default_attrs + { + type: "checkbox", + aria_label: "Select all", + class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + data: { + "ruby-ui--data-table-target": "selectAll", + action: "change->ruby-ui--data-table#toggleAll" + } + } + end + end +end diff --git a/test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb b/test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb new file mode 100644 index 00000000..97ce1260 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class RubyUI::DataTableSelectAllCheckboxTest < ActiveSupport::TestCase + test "carries selectAll target + toggleAll action + aria-label" do + out = RubyUI::DataTableSelectAllCheckbox.new.call + assert_match(/]*type="checkbox"/, out) + assert_match(/data-ruby-ui--data-table-target="selectAll"/, out) + assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleAll/, out) + assert_match(/aria-label="Select all"/, out) + end +end From e8d7094328736af903fd4813bbbf69e89e2ccb94 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:19:10 -0300 Subject: [PATCH 21/81] feat(data_table): add DataTableSelectionSummary --- .../data_table_selection_summary.rb | 25 +++++++++++++++++++ .../data_table_selection_summary_test.rb | 9 +++++++ 2 files changed, 34 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_selection_summary.rb create mode 100644 test/components/ruby_ui/data_table/data_table_selection_summary_test.rb 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/test/components/ruby_ui/data_table/data_table_selection_summary_test.rb b/test/components/ruby_ui/data_table/data_table_selection_summary_test.rb new file mode 100644 index 00000000..38d22ac4 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_selection_summary_test.rb @@ -0,0 +1,9 @@ +require "test_helper" + +class RubyUI::DataTableSelectionSummaryTest < ActiveSupport::TestCase + test "renders '0 of N row(s) selected.' with target" do + out = RubyUI::DataTableSelectionSummary.new(total_on_page: 10).call + assert_match(/0 of 10 row\(s\) selected\./, out) + assert_match(/data-ruby-ui--data-table-target="selectionSummary"/, out) + end +end From adb5ab3f2752a64bc4e8c70514bc4653c2e78c9a Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:19:48 -0300 Subject: [PATCH 22/81] feat(data_table): add DataTableBulkActions (hidden until selection>0) --- .../data_table/data_table_bulk_actions.rb | 18 ++++++++++++++++++ .../data_table/data_table_bulk_actions_test.rb | 10 ++++++++++ 2 files changed, 28 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_bulk_actions.rb create mode 100644 test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb 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/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb b/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb new file mode 100644 index 00000000..33124e25 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class RubyUI::DataTableBulkActionsTest < ActiveSupport::TestCase + test "starts hidden with bulkActions target + renders children" do + out = RubyUI::DataTableBulkActions.new.call { "BUTTONS" } + assert_match(/class="[^"]*hidden[^"]*"/, out) + assert_match(/data-ruby-ui--data-table-target="bulkActions"/, out) + assert_match(/BUTTONS/, out) + end +end From 04970f128ef6300f763ad847f16f316ec8358dd0 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:20:26 -0300 Subject: [PATCH 23/81] feat(data_table): add DataTableSelectionBar --- .../data_table/data_table_selection_bar.rb | 18 ++++++++++++++++++ .../data_table_selection_bar_test.rb | 10 ++++++++++ 2 files changed, 28 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_selection_bar.rb create mode 100644 test/components/ruby_ui/data_table/data_table_selection_bar_test.rb diff --git a/app/components/ruby_ui/data_table/data_table_selection_bar.rb b/app/components/ruby_ui/data_table/data_table_selection_bar.rb new file mode 100644 index 00000000..aa72a7fa --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_selection_bar.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableSelectionBar < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + class: "flex items-center justify-between gap-4 py-2", + data: {"ruby-ui--data-table-target": "selectionBar"} + } + end + end +end diff --git a/test/components/ruby_ui/data_table/data_table_selection_bar_test.rb b/test/components/ruby_ui/data_table/data_table_selection_bar_test.rb new file mode 100644 index 00000000..0a363c76 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_selection_bar_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class RubyUI::DataTableSelectionBarTest < ActiveSupport::TestCase + test "renders with selectionBar target + flex layout + children" do + out = RubyUI::DataTableSelectionBar.new.call { "INNER" } + assert_match(/data-ruby-ui--data-table-target="selectionBar"/, out) + assert_match(/class="[^"]*flex[^"]*"/, out) + assert_match(/INNER/, out) + end +end From 5101de618475ae93228b1e7ff4abd40ecedf7b84 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:21:25 -0300 Subject: [PATCH 24/81] feat(data_table): add DataTableColumnToggle Reuses DropdownMenu + Button. Own Stimulus controller for column visibility toggle. --- .../data_table/data_table_column_toggle.rb | 49 +++++++++++++++++++ .../data_table_column_toggle_test.rb | 16 ++++++ 2 files changed, 65 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_column_toggle.rb create mode 100644 test/components/ruby_ui/data_table/data_table_column_toggle_test.rb 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..1d4e2dff --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_column_toggle.rb @@ -0,0 +1,49 @@ +# 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" + icon = view_context.respond_to?(:lucide_icon) ? raw(view_context.lucide_icon("chevron-down", class: "w-4 h-4 ml-1")) : nil + icon + 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/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb new file mode 100644 index 00000000..69ed84e3 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb @@ -0,0 +1,16 @@ +require "test_helper" + +class RubyUI::DataTableColumnToggleTest < ActiveSupport::TestCase + test "renders dropdown with checkbox per column" do + out = RubyUI::DataTableColumnToggle.new(columns: [ + {key: :email, label: "Email"}, + {key: :salary, label: "Salary"} + ]).call + assert_match(/Columns/, out) + assert_match(/data-controller="[^"]*ruby-ui--data-table-column-visibility/, out) + assert_match(/data-column-key="email"/, out) + assert_match(/data-column-key="salary"/, out) + assert_match(/Email/, out) + assert_match(/Salary/, out) + end +end From 975dd81dc831bacaac44a83bdc91756a49cb866c Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:23:37 -0300 Subject: [PATCH 25/81] refactor(data_table): move adapters to DataTablePaginationAdapters Frees the DataTablePagination namespace for the component class. --- .../kaminari.rb | 2 +- .../manual.rb | 2 +- .../pagy.rb | 2 +- config/initializers/ruby_ui.rb | 6 +++--- .../kaminari_test.rb | 4 ++-- .../manual_test.rb | 8 ++++---- .../pagy_test.rb | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) rename app/components/ruby_ui/{data_table_pagination => data_table_pagination_adapters}/kaminari.rb (90%) rename app/components/ruby_ui/{data_table_pagination => data_table_pagination_adapters}/manual.rb (91%) rename app/components/ruby_ui/{data_table_pagination => data_table_pagination_adapters}/pagy.rb (88%) rename test/components/ruby_ui/{data_table_pagination => data_table_pagination_adapters}/kaminari_test.rb (76%) rename test/components/ruby_ui/{data_table_pagination => data_table_pagination_adapters}/manual_test.rb (55%) rename test/components/ruby_ui/{data_table_pagination => data_table_pagination_adapters}/pagy_test.rb (73%) diff --git a/app/components/ruby_ui/data_table_pagination/kaminari.rb b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb similarity index 90% rename from app/components/ruby_ui/data_table_pagination/kaminari.rb rename to app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb index 4755e9e1..19e10aa9 100644 --- a/app/components/ruby_ui/data_table_pagination/kaminari.rb +++ b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module RubyUI - module DataTablePagination + module DataTablePaginationAdapters class Kaminari def initialize(collection) @collection = collection diff --git a/app/components/ruby_ui/data_table_pagination/manual.rb b/app/components/ruby_ui/data_table_pagination_adapters/manual.rb similarity index 91% rename from app/components/ruby_ui/data_table_pagination/manual.rb rename to app/components/ruby_ui/data_table_pagination_adapters/manual.rb index ea9e955d..b038ff1c 100644 --- a/app/components/ruby_ui/data_table_pagination/manual.rb +++ b/app/components/ruby_ui/data_table_pagination_adapters/manual.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module RubyUI - module DataTablePagination + module DataTablePaginationAdapters class Manual attr_reader :current_page, :per_page, :total_count diff --git a/app/components/ruby_ui/data_table_pagination/pagy.rb b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb similarity index 88% rename from app/components/ruby_ui/data_table_pagination/pagy.rb rename to app/components/ruby_ui/data_table_pagination_adapters/pagy.rb index dab32e61..73f923df 100644 --- a/app/components/ruby_ui/data_table_pagination/pagy.rb +++ b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module RubyUI - module DataTablePagination + module DataTablePaginationAdapters class Pagy def initialize(pagy) @pagy = pagy diff --git a/config/initializers/ruby_ui.rb b/config/initializers/ruby_ui.rb index e53a8f22..eb98b4e8 100644 --- a/config/initializers/ruby_ui.rb +++ b/config/initializers/ruby_ui.rb @@ -15,9 +15,9 @@ module RubyUI ) # Allow using RubyUI::ComponentName instead RubyUI::ComponentName::ComponentName -# data_table_pagination/ is intentionally excluded from collapse so that -# RubyUI::DataTablePagination is a proper module (adapter namespace). +# data_table_pagination_adapters/ is intentionally excluded from collapse so that +# RubyUI::DataTablePaginationAdapters is a proper module (adapter namespace). collapse_dirs = Dir.glob(Rails.root.join("app/components/ruby_ui/*")).reject do |path| - path.end_with?("data_table_pagination") + path.end_with?("data_table_pagination_adapters") end Rails.autoloaders.main.collapse(collapse_dirs) unless collapse_dirs.empty? diff --git a/test/components/ruby_ui/data_table_pagination/kaminari_test.rb b/test/components/ruby_ui/data_table_pagination_adapters/kaminari_test.rb similarity index 76% rename from test/components/ruby_ui/data_table_pagination/kaminari_test.rb rename to test/components/ruby_ui/data_table_pagination_adapters/kaminari_test.rb index 165fc93a..4cc119d4 100644 --- a/test/components/ruby_ui/data_table_pagination/kaminari_test.rb +++ b/test/components/ruby_ui/data_table_pagination_adapters/kaminari_test.rb @@ -2,12 +2,12 @@ require "test_helper" -class RubyUI::DataTablePagination::KaminariTest < ActiveSupport::TestCase +class RubyUI::DataTablePaginationAdapters::KaminariTest < ActiveSupport::TestCase CollectionDouble = Data.define(:current_page, :total_pages, :total_count, :limit_value) test "reads current_page, total_pages, total_count, limit_value" do coll = CollectionDouble.new(current_page: 3, total_pages: 7, total_count: 61, limit_value: 10) - adapter = RubyUI::DataTablePagination::Kaminari.new(coll) + adapter = RubyUI::DataTablePaginationAdapters::Kaminari.new(coll) assert_equal 3, adapter.current_page assert_equal 7, adapter.total_pages assert_equal 61, adapter.total_count diff --git a/test/components/ruby_ui/data_table_pagination/manual_test.rb b/test/components/ruby_ui/data_table_pagination_adapters/manual_test.rb similarity index 55% rename from test/components/ruby_ui/data_table_pagination/manual_test.rb rename to test/components/ruby_ui/data_table_pagination_adapters/manual_test.rb index 819911c4..8d7f085f 100644 --- a/test/components/ruby_ui/data_table_pagination/manual_test.rb +++ b/test/components/ruby_ui/data_table_pagination_adapters/manual_test.rb @@ -1,8 +1,8 @@ require "test_helper" -class RubyUI::DataTablePagination::ManualTest < ActiveSupport::TestCase +class RubyUI::DataTablePaginationAdapters::ManualTest < ActiveSupport::TestCase test "computes total_pages from total_count and per_page" do - adapter = RubyUI::DataTablePagination::Manual.new(page: 2, per_page: 10, total_count: 25) + adapter = RubyUI::DataTablePaginationAdapters::Manual.new(page: 2, per_page: 10, total_count: 25) assert_equal 2, adapter.current_page assert_equal 10, adapter.per_page assert_equal 25, adapter.total_count @@ -10,12 +10,12 @@ class RubyUI::DataTablePagination::ManualTest < ActiveSupport::TestCase end test "total_pages is at least 1 for empty total" do - adapter = RubyUI::DataTablePagination::Manual.new(page: 1, per_page: 10, total_count: 0) + adapter = RubyUI::DataTablePaginationAdapters::Manual.new(page: 1, per_page: 10, total_count: 0) assert_equal 1, adapter.total_pages end test "coerces integer inputs" do - adapter = RubyUI::DataTablePagination::Manual.new(page: "3", per_page: "5", total_count: "12") + adapter = RubyUI::DataTablePaginationAdapters::Manual.new(page: "3", per_page: "5", total_count: "12") assert_equal 3, adapter.current_page assert_equal 3, adapter.total_pages end diff --git a/test/components/ruby_ui/data_table_pagination/pagy_test.rb b/test/components/ruby_ui/data_table_pagination_adapters/pagy_test.rb similarity index 73% rename from test/components/ruby_ui/data_table_pagination/pagy_test.rb rename to test/components/ruby_ui/data_table_pagination_adapters/pagy_test.rb index 2c45ef50..64c03cc1 100644 --- a/test/components/ruby_ui/data_table_pagination/pagy_test.rb +++ b/test/components/ruby_ui/data_table_pagination_adapters/pagy_test.rb @@ -2,12 +2,12 @@ require "test_helper" -class RubyUI::DataTablePagination::PagyTest < ActiveSupport::TestCase +class RubyUI::DataTablePaginationAdapters::PagyTest < ActiveSupport::TestCase PagyDouble = Data.define(:page, :pages, :count, :items) test "reads page, pages, count, items" do pagy = PagyDouble.new(page: 2, pages: 5, count: 47, items: 10) - adapter = RubyUI::DataTablePagination::Pagy.new(pagy) + adapter = RubyUI::DataTablePaginationAdapters::Pagy.new(pagy) assert_equal 2, adapter.current_page assert_equal 5, adapter.total_pages assert_equal 47, adapter.total_count From 8da42a3f0b9cd2019ca1d40e9105f8f3c7f393c5 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:24:22 -0300 Subject: [PATCH 26/81] feat(data_table): add DataTablePagination with adapter support Accepts with: / pagy: / kaminari: / manual keyword shortcuts. Numbered pagination reuses existing Pagination primitives with gap windowing. --- .../data_table/data_table_pagination.rb | 85 +++++++++++++++++++ .../data_table/data_table_pagination_test.rb | 30 +++++++ 2 files changed, 115 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_pagination.rb create mode 100644 test/components/ruby_ui/data_table/data_table_pagination_test.rb 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..5d41b496 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module RubyUI + class DataTablePagination < Base + WINDOW = 1 + + def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, **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) + super(**attrs) + end + + def view_template + render RubyUI::Pagination.new(**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::DataTablePaginationAdapters::Pagy.new(pagy) if pagy + return RubyUI::DataTablePaginationAdapters::Kaminari.new(kaminari) if kaminari + if page && per_page && total_count + return RubyUI::DataTablePaginationAdapters::Manual.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 = @query.merge(@page_param => p.to_s).to_query + qs.empty? ? @path : "#{@path}?#{qs}" + 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") { plain "Previous" } + end + else + render RubyUI::PaginationItem.new(href: page_href(current - 1)) { plain "Previous" } + 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") { plain "Next" } + end + else + render RubyUI::PaginationItem.new(href: page_href(current + 1)) { plain "Next" } + 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/test/components/ruby_ui/data_table/data_table_pagination_test.rb b/test/components/ruby_ui/data_table/data_table_pagination_test.rb new file mode 100644 index 00000000..9b7365f0 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_pagination_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class RubyUI::DataTablePaginationTest < ActiveSupport::TestCase + test "accepts manual keyword shortcut" do + out = RubyUI::DataTablePagination.new(page: 2, per_page: 10, total_count: 25, path: "/x", query: {}).call + assert_match(/href="\/x\?page=1"/, out) # Previous + assert_match(/href="\/x\?page=3"/, out) # next + end + + test "accepts pagy keyword shortcut (duck-typed double)" do + pagy_double = Data.define(:page, :pages, :count, :items).new(page: 1, pages: 2, count: 15, items: 10) + out = RubyUI::DataTablePagination.new(pagy: pagy_double, path: "/x", query: {}).call + assert_match(/href="\/x\?page=2"/, out) + end + + test "with: accepts custom adapter" do + custom = Data.define(:current_page, :total_pages, :total_count, :per_page).new(1, 3, 20, 10) + out = RubyUI::DataTablePagination.new(with: custom, path: "/x", query: {}).call + assert_match(/href="\/x\?page=2"/, out) + end + + test "renames page param" do + out = RubyUI::DataTablePagination.new(page: 1, per_page: 10, total_count: 30, path: "/x", query: {}, page_param: "p").call + assert_match(/p=2/, out) + end + + test "raises when no adapter and no manual args" do + assert_raises(ArgumentError) { RubyUI::DataTablePagination.new(path: "/x", query: {}) } + end +end From 6cbc8967f04c4d4473637fc527719c4888ec1715 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:25:04 -0300 Subject: [PATCH 27/81] feat(data_table): add data-table Stimulus controller Handles select-all, per-row toggle, selection summary text, bulk actions visibility. State resets on Turbo Frame swap by design. --- .../ruby_ui/data_table_controller.js | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 app/javascript/controllers/ruby_ui/data_table_controller.js 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..90dbff05 --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -0,0 +1,50 @@ +// 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(); + } + + 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); + } + + if (this.hasSelectionSummaryTarget) { + this.selectionSummaryTarget.classList.toggle("hidden", selected > 0); + } + } +} From 9d067535de1a700dfa7800629bc7f91f158c03a9 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:25:28 -0300 Subject: [PATCH 28/81] feat(data_table): add data-table-column-visibility Stimulus controller Scoped DOM query via closest() to the sibling DataTable root. --- .../data_table_column_visibility_controller.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js 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)); + } +} From 93d823d4cd5a8c80b30a9ec8fff8d4962b438422 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:26:12 -0300 Subject: [PATCH 29/81] feat(data_table): register data-table Stimulus controllers --- app/javascript/controllers/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index e92a12d6..881851cf 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -43,6 +43,12 @@ 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__DialogController from "./ruby_ui/dialog_controller" application.register("ruby-ui--dialog", RubyUi__DialogController) From 9a3ed9df524df4d56048aa7a0296a8a4fc3129ee Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:28:10 -0300 Subject: [PATCH 30/81] feat(data_table): add demo controller with search/sort/paginate + bulk stubs Index tests will go green once the demo view lands in Task 25. --- .../docs/data_table_demo_controller.rb | 57 +++++++++++++++++++ .../docs/data_table_demo_controller_test.rb | 46 +++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 app/controllers/docs/data_table_demo_controller.rb create mode 100644 test/controllers/docs/data_table_demo_controller_test.rb 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/test/controllers/docs/data_table_demo_controller_test.rb b/test/controllers/docs/data_table_demo_controller_test.rb new file mode 100644 index 00000000..db3690a5 --- /dev/null +++ b/test/controllers/docs/data_table_demo_controller_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "test_helper" + +class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest + test "GET index returns 200" do + get docs_data_table_demo_url + assert_response :success + end + + test "GET index with ?search= filters employees" do + get docs_data_table_demo_url(search: "alice") + assert_response :success + assert_match(/Alice Johnson/, response.body) + assert_no_match(/Bob Smith/, response.body) + end + + test "GET index with ?sort=name&direction=desc sorts" do + get docs_data_table_demo_url(sort: "name", direction: "desc", per_page: 100) + alice_at = response.body.index("Alice Johnson") + violet_at = response.body.index("Violet Fisher") + assert violet_at < alice_at, "Violet should appear before Alice when sorted desc" + end + + test "GET index with ?sort=salary sorts numerically" do + get docs_data_table_demo_url(sort: "salary", direction: "asc", per_page: 5) + assert_match(/Grace Lee/, response.body) + end + + test "GET index paginates" do + get docs_data_table_demo_url(page: 2, per_page: 5) + assert_response :success + end + + test "POST bulk_delete with ids[] redirects + flashes" do + post docs_data_table_demo_bulk_delete_url, params: {ids: ["1", "2"]} + assert_redirected_to docs_data_table_demo_path + follow_redirect! + assert_match(/Would delete: 1, 2/, response.body) + end + + test "POST bulk_export with ids[] redirects + flashes" do + post docs_data_table_demo_bulk_export_url, params: {ids: ["3"]} + assert_redirected_to docs_data_table_demo_path + end +end From a52dd292171703a9a8585ebe2f72c091c95336f9 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:31:50 -0300 Subject: [PATCH 31/81] feat(data_table): wire complete demo view Uses DataTable + existing Table primitives + Stimulus controllers. Demo controller integration tests now pass (7 runs, 0 failures). Also fixes Grace Lee salary in demo data so salary-sort test is valid. --- app/controllers/docs/data_table_demo_data.rb | 2 +- app/views/docs/data_table_demo/index.rb | 140 +++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 app/views/docs/data_table_demo/index.rb diff --git a/app/controllers/docs/data_table_demo_data.rb b/app/controllers/docs/data_table_demo_data.rb index 42f2a6c4..a826b3e1 100644 --- a/app/controllers/docs/data_table_demo_data.rb +++ b/app/controllers/docs/data_table_demo_data.rb @@ -9,7 +9,7 @@ module DataTableDemoData {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: 71_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}, 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..f8a970f1 --- /dev/null +++ b/app/views/docs/data_table_demo/index.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +class Views::Docs::DataTableDemo::Index < Views::Base + FRAME_ID = "employees_list" + + 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..." + ) + 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 + ) + end + end + + 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) } + TableCell(class: "font-medium") { plain e.name } + TableCell(class: "text-muted-foreground", 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 format_currency(e.salary) } + TableCell(class: "w-12 text-right") { row_actions(e) } + end + end + end + end + end + end + + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTableBulkActions do + Button(type: "submit", formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + end + end + + DataTablePagination( + page: @page, + per_page: @per_page, + total_count: @total_count, + path: docs_data_table_demo_path, + query: preserved_query + ) + end + end + + def row_actions(employee) + DropdownMenu do + DropdownMenuTrigger do + Button(type: "button", variant: :ghost, size: :icon, aria_label: "Open menu") do + icon = view_context.respond_to?(:lucide_icon) ? raw(view_context.lucide_icon("ellipsis-vertical", class: "h-4 w-4")) : nil + icon + 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 + + def format_currency(n) + "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" + end +end From 64e91f5ed4d8cc9e446c6f7d14e9136a5fd1698b Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:37:34 -0300 Subject: [PATCH 32/81] feat(docs): add DataTable docs page with 6 examples Example 1 (complete demo) uses iframe src pointing at /docs/data_table_demo. Examples 2-6 are code-only snippets showing static table, server-driven, selection, column visibility, custom cell renderers. Adds Row stub struct and private path stubs (my_path, bulk_delete_path, bulk_export_path) so instance_eval'd previews render without NameError. Uses string literal src for the iframe (avoids URL helper context issue in rails runner). --- app/views/docs/data_table.rb | 253 +++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 app/views/docs/data_table.rb diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb new file mode 100644 index 00000000..6c1e73b8 --- /dev/null +++ b/app/views/docs/data_table.rb @@ -0,0 +1,253 @@ +# app/views/docs/data_table.rb +# frozen_string_literal: true + +class Views::Docs::DataTable < Views::Base + # Stub data used by code-snippet previews (examples 3-6) + Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true) + + def view_template + component = "DataTable" + + # Stubs so instance_eval'd preview snippets don't raise NameError + @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: component, + description: "A Hotwire-first, Avo-inspired data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission." + ) + + # ── Example 1: Complete demo (primary) ───────────────────────────────── + 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' + DataTable(id: "employees_list") do + DataTableToolbar do + DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search) + DataTableColumnToggle(columns: [ + {key: :email, label: "Email"}, + {key: :department, label: "Department"} + ]) + DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page) + end + + 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 + + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTableBulkActions do + Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } + end + end + + DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path) + end + RUBY + end + + # ── Example 2: Basic static table ───────────────────────────────────── + Heading(level: 2) { "Basic static table" } + p(class: "-mt-6") { "Composition only — no interactivity." } + + render Docs::VisualCodeExample.new(title: "Basic static table", context: self) do + <<~'RUBY' + DataTable(id: "basic") do + Table do + TableHeader do + TableRow do + TableHead { "Name" } + TableHead { "Role" } + end + end + TableBody do + TableRow do + TableCell { "Alice" } + TableCell { "Engineer" } + end + TableRow do + TableCell { "Bob" } + TableCell { "Designer" } + end + end + end + end + RUBY + end + + # ── Example 3: Server-driven (search + sort + pagination) ───────────── + 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) + 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 + + # ── Example 4: Selection + bulk actions ─────────────────────────────── + Heading(level: 2) { "Selection + bulk actions" } + p(class: "-mt-6") { "Form-first: row checkboxes are , bulk buttons submit via formaction." } + + render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do + <<~'RUBY' + DataTable(id: "selection") 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 + + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @rows.size) + DataTableBulkActions do + Button(type: "submit", formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } + Button(type: "submit", formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } + end + end + end + RUBY + end + + # ── Example 5: Column visibility ────────────────────────────────────── + Heading(level: 2) { "Column visibility" } + p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." } + + 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 + + # ── Example 6: Custom cell renderers ────────────────────────────────── + 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 format_currency(n) + "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" + end + + 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 format_currency(r.salary) } + end + end + end + end + end + RUBY + end + end + end + + private + + def my_path + "#" + end + + def bulk_delete_path + "#" + end + + def bulk_export_path + "#" + end +end From 555e177978e424ede1a70fdf4e19db7f6c8fef16 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:38:59 -0300 Subject: [PATCH 33/81] test(components): only GET docs routes in generated tests Post-Task 2 the docs scope gained POST endpoints (bulk_delete, bulk_export). The generated "should get X" tests shouldn't try to GET non-GET verbs. Filter to GET only. --- 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, From 2fafab61deebfe4030fd18d5f6f9eb01fdaf17c3 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:41:13 -0300 Subject: [PATCH 34/81] style(data_table): standardrb --fix Whitespace + heredoc delimiter quote cleanups. --- .../ruby_ui/data_table/data_table_pagination.rb | 2 +- .../ruby_ui/data_table_pagination_adapters/kaminari.rb | 6 +++--- .../ruby_ui/data_table_pagination_adapters/pagy.rb | 6 +++--- app/views/docs/data_table.rb | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_pagination.rb b/app/components/ruby_ui/data_table/data_table_pagination.rb index 5d41b496..5c1bc53b 100644 --- a/app/components/ruby_ui/data_table/data_table_pagination.rb +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -35,7 +35,7 @@ def resolve_adapter(with:, pagy:, kaminari:, page:, per_page:, total_count:) end def current = @adapter.current_page - def total = @adapter.total_pages + def total = @adapter.total_pages def page_href(p) qs = @query.merge(@page_param => p.to_s).to_query diff --git a/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb index 19e10aa9..d9597eec 100644 --- a/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb +++ b/app/components/ruby_ui/data_table_pagination_adapters/kaminari.rb @@ -8,9 +8,9 @@ def initialize(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 + def total_pages = @collection.total_pages + def total_count = @collection.total_count + def per_page = @collection.limit_value end end end diff --git a/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb index 73f923df..16a418db 100644 --- a/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb +++ b/app/components/ruby_ui/data_table_pagination_adapters/pagy.rb @@ -8,9 +8,9 @@ def initialize(pagy) end def current_page = @pagy.page - def total_pages = @pagy.pages - def total_count = @pagy.count - def per_page = @pagy.items + def total_pages = @pagy.pages + def total_count = @pagy.count + def per_page = @pagy.items end end end diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 6c1e73b8..1138f2bc 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -28,7 +28,7 @@ def view_template 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' + <<~RUBY DataTable(id: "employees_list") do DataTableToolbar do DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search) @@ -75,7 +75,7 @@ def view_template p(class: "-mt-6") { "Composition only — no interactivity." } render Docs::VisualCodeExample.new(title: "Basic static table", context: self) do - <<~'RUBY' + <<~RUBY DataTable(id: "basic") do Table do TableHeader do @@ -104,7 +104,7 @@ def view_template 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' + <<~RUBY DataTable(id: "server") do DataTableToolbar do DataTableSearch(path: my_path) @@ -131,7 +131,7 @@ def view_template p(class: "-mt-6") { "Form-first: row checkboxes are , bulk buttons submit via formaction." } render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do - <<~'RUBY' + <<~RUBY DataTable(id: "selection") do Table do TableHeader do @@ -166,7 +166,7 @@ def view_template p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." } render Docs::VisualCodeExample.new(title: "Column visibility", context: self) do - <<~'RUBY' + <<~RUBY DataTable(id: "columns") do DataTableToolbar do DataTableColumnToggle(columns: [ From e8d9886786aa8a7a6c9b9e6d86ab187cee6f0680 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:52:32 -0300 Subject: [PATCH 35/81] refactor(data_table): remove form wrapper from root, add DataTableForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataTable no longer renders a — only turbo-frame + div[data-controller]. Introduce DataTableForm component that owns the + CSRF token, so users explicitly wrap Table + SelectionBar without triggering nested-form issues. --- .../ruby_ui/data_table/data_table.rb | 17 ++++------- .../ruby_ui/data_table/data_table_form.rb | 29 +++++++++++++++++++ .../data_table/data_table_form_test.rb | 18 ++++++++++++ .../ruby_ui/data_table/data_table_test.rb | 15 +++++----- 4 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 app/components/ruby_ui/data_table/data_table_form.rb create mode 100644 test/components/ruby_ui/data_table/data_table_form_test.rb diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index 99282f96..88885eed 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -4,19 +4,15 @@ module RubyUI class DataTable < Base register_element :turbo_frame, tag: "turbo-frame" - def initialize(id:, action: "", **attrs) + def initialize(id:, **attrs) @frame_id = id - @action = action super(**attrs) end def view_template(&block) turbo_frame(id: @frame_id, target: "_top") do div(**attrs) do - form(action: @action, method: "post", data: {controller: "ruby-ui--data-table"}) do - input(type: "hidden", name: "authenticity_token", value: csrf_token) - yield if block - end + yield if block end end end @@ -24,11 +20,10 @@ def view_template(&block) private def default_attrs - {class: "w-full space-y-4"} - end - - def csrf_token - helpers.respond_to?(:form_authenticity_token) ? helpers.form_authenticity_token : SecureRandom.hex(32) + { + 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_form.rb b/app/components/ruby_ui/data_table/data_table_form.rb new file mode 100644 index 00000000..cb90f390 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_form.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module RubyUI + class DataTableForm < Base + def initialize(action: "", method: "post", **attrs) + @action = action + @method = method + super(**attrs) + end + + def view_template(&block) + form(action: @action, method: @method, **attrs) do + input(type: "hidden", name: "authenticity_token", value: csrf_token) + yield if block + end + end + + private + + def csrf_token + return view_context.form_authenticity_token if respond_to?(:view_context) && view_context.respond_to?(:form_authenticity_token) + SecureRandom.hex(32) + end + + def default_attrs + {} + end + end +end diff --git a/test/components/ruby_ui/data_table/data_table_form_test.rb b/test/components/ruby_ui/data_table/data_table_form_test.rb new file mode 100644 index 00000000..28432007 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_form_test.rb @@ -0,0 +1,18 @@ +require "test_helper" + +class RubyUI::DataTableFormTest < ActiveSupport::TestCase + test "renders form with method=post and action" do + out = RubyUI::DataTableForm.new(action: "/x").call + assert_match(/]*action="\/x"[^>]*method="post"|]*method="post"[^>]*action="\/x"/, out) + end + + test "renders hidden authenticity_token" do + out = RubyUI::DataTableForm.new.call + assert_match(/]*type="hidden"[^>]*name="authenticity_token"[^>]*value="[^"]+"/, out) + end + + test "yields children" do + out = RubyUI::DataTableForm.new.call { "INNER" } + assert_match(/INNER/, out) + end +end diff --git a/test/components/ruby_ui/data_table/data_table_test.rb b/test/components/ruby_ui/data_table/data_table_test.rb index 0a468b75..339754b5 100644 --- a/test/components/ruby_ui/data_table/data_table_test.rb +++ b/test/components/ruby_ui/data_table/data_table_test.rb @@ -8,19 +8,18 @@ class RubyUI::DataTableTest < ActiveSupport::TestCase assert_match %r{]*id="employees"[^>]*target="_top"}, output end - test "sets data-controller on inner form" do + test "sets data-controller on inner div" do output = RubyUI::DataTable.new(id: "x").call assert_match(/data-controller="ruby-ui--data-table"/, output) end - test "renders children inside form" do - output = RubyUI::DataTable.new(id: "x").call { "INNER" } - assert_match(/INNER/, output) - assert_match(/]*type="hidden"[^>]*name="authenticity_token"[^>]*value="[^"]+"/, output) + test "renders children inside the div" do + output = RubyUI::DataTable.new(id: "x").call { "INNER" } + assert_match(/INNER/, output) end end From e4c9733f1ffb212f142544187cabad250be0412b Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:52:36 -0300 Subject: [PATCH 36/81] feat(data_table): wrap table + selection bar in DataTableForm in demo Moves the rounded-md border div + Table + DataTableSelectionBar inside a DataTableForm so bulk-action buttons submit row checkboxes correctly without nested-form stripping by the browser. --- app/views/docs/data_table_demo/index.rb | 68 +++++++++++++------------ 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index f8a970f1..596fc992 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -51,49 +51,51 @@ def render_table end end - 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? + DataTableForm(action: "") do + div(class: "rounded-md border") do + Table do + TableHeader do TableRow do - TableCell(colspan: 7, class: "h-24 text-center text-muted-foreground") { plain "No results." } + 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 - else - @employees.each do |e| + end + + TableBody do + if @employees.empty? TableRow do - TableCell(class: "w-10") { DataTableRowCheckbox(value: e.id) } - TableCell(class: "font-medium") { plain e.name } - TableCell(class: "text-muted-foreground", 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 } + 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) } + TableCell(class: "font-medium") { plain e.name } + TableCell(class: "text-muted-foreground", 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 format_currency(e.salary) } + TableCell(class: "w-12 text-right") { row_actions(e) } end - TableCell(class: "text-right", data: {column: "salary"}) { plain format_currency(e.salary) } - TableCell(class: "w-12 text-right") { row_actions(e) } end end end end end - end - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @employees.size) - DataTableBulkActions do - Button(type: "submit", formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } - Button(type: "submit", formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTableBulkActions do + Button(type: "submit", formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } + Button(type: "submit", formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } + end end end From 395559feab94e5156a6c0acde251199e640b1d2f Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:52:40 -0300 Subject: [PATCH 37/81] fix(docs): make VisualCodeExample previews fill container width iframe_preview: drop aspect-[4/2.5] in favour of min-h-[500px] and change inner div from w-[1600px] to w-full so the iframe no longer overflows its container. raw_preview: remove justify-center items-center and reduce padding from p-10 to p-6 so components fill available width. --- app/components/docs/visual_code_example.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 499c1d6a5db81b4de1075e8d1eee102c53bf47b0 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:52:44 -0300 Subject: [PATCH 38/81] docs(data_table): update example heredocs to show DataTableForm pattern Examples 1 and 4 now wrap Table + DataTableSelectionBar inside DataTableForm, matching the corrected architecture. Examples 2, 3, 5, and 6 are unchanged as they don't use selection/bulk actions. --- app/views/docs/data_table.rb | 80 +++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 1138f2bc..a17ec0aa 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -20,7 +20,7 @@ def view_template div(class: "mx-auto w-full py-10 space-y-10") do render Docs::Header.new( title: component, - description: "A Hotwire-first, Avo-inspired data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission." + 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." ) # ── Example 1: Complete demo (primary) ───────────────────────────────── @@ -39,29 +39,33 @@ def view_template DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page) end - 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 } + DataTableForm(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 - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @employees.size) - DataTableBulkActions do - Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @employees.size) + DataTableBulkActions do + Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } + end end end @@ -133,28 +137,30 @@ def view_template render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do <<~RUBY DataTable(id: "selection") do - Table do - TableHeader do - TableRow do - TableHead { DataTableSelectAllCheckbox() } - TableHead { "Name" } - end - end - TableBody do - @rows.each do |r| + DataTableForm(action: "") do + Table do + TableHeader do TableRow do - TableCell { DataTableRowCheckbox(value: r.id) } - TableCell { r.name } + 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 - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @rows.size) - DataTableBulkActions do - Button(type: "submit", formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } - Button(type: "submit", formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } + DataTableSelectionBar do + DataTableSelectionSummary(total_on_page: @rows.size) + DataTableBulkActions do + Button(type: "submit", formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } + Button(type: "submit", formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } + end end end end From 4d9d5147692b4ca08648324f899da11b46c22cff Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:59:37 -0300 Subject: [PATCH 39/81] feat(data_table): add Stimulus debounce controller for search requestSubmit() N ms after last input. Delay configurable via data-ruby-ui--data-table-search-delay-value. --- app/javascript/controllers/index.js | 3 +++ .../ruby_ui/data_table_search_controller.js | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 app/javascript/controllers/ruby_ui/data_table_search_controller.js diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 881851cf..e68815bd 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -49,6 +49,9 @@ 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_search_controller.js b/app/javascript/controllers/ruby_ui/data_table_search_controller.js new file mode 100644 index 00000000..3716a618 --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_search_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static values = { delay: { type: Number, default: 300 } }; + + connect() { + this.timer = null; + } + + submit(event) { + // Only react to input events (not submit/change). + if (event && event.type !== "input") return; + clearTimeout(this.timer); + if (this.delayValue <= 0) return; + this.timer = setTimeout(() => { + this.element.requestSubmit(); + }, this.delayValue); + } + + disconnect() { + clearTimeout(this.timer); + } +} From 72d56f7a5537aa88b9062a07c475a3799ef5adf0 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 15:59:41 -0300 Subject: [PATCH 40/81] feat(data_table): wire debounce prop on DataTableSearch debounce: 300 (default) | debounce: N (custom) | debounce: false (Enter only). --- .../ruby_ui/data_table/data_table_search.rb | 20 ++++++++++++++-- .../data_table/data_table_search_test.rb | 23 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_search.rb b/app/components/ruby_ui/data_table/data_table_search.rb index 3fdd70f1..a8388e21 100644 --- a/app/components/ruby_ui/data_table/data_table_search.rb +++ b/app/components/ruby_ui/data_table/data_table_search.rb @@ -2,18 +2,19 @@ module RubyUI class DataTableSearch < Base - def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", **attrs) + def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, **attrs) @path = path @name = name @value = value @frame_id = frame_id @placeholder = placeholder + @debounce = debounce super(**attrs) end def view_template form_attrs = {method: "get", action: @path} - form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id + form_attrs[:data] = form_data form(**attrs.merge(form_attrs)) do render RubyUI::Input.new( @@ -28,6 +29,21 @@ def view_template 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 diff --git a/test/components/ruby_ui/data_table/data_table_search_test.rb b/test/components/ruby_ui/data_table/data_table_search_test.rb index 833ecfc6..1f2eee0a 100644 --- a/test/components/ruby_ui/data_table/data_table_search_test.rb +++ b/test/components/ruby_ui/data_table/data_table_search_test.rb @@ -19,4 +19,27 @@ class RubyUI::DataTableSearchTest < ActiveSupport::TestCase out = RubyUI::DataTableSearch.new(path: "/x", frame_id: "employees").call assert_match(/data-turbo-frame="employees"/, out) end + + test "emits debounce controller + delay value + action by default" do + out = RubyUI::DataTableSearch.new(path: "/x").call + assert_match(/data-controller="ruby-ui--data-table-search"/, out) + assert_match(/data-ruby-ui--data-table-search-delay-value="300"/, out) + assert_match(/data-action="input->ruby-ui--data-table-search#submit"/, out) + end + + test "debounce: 500 sets custom delay" do + out = RubyUI::DataTableSearch.new(path: "/x", debounce: 500).call + assert_match(/data-ruby-ui--data-table-search-delay-value="500"/, out) + end + + test "debounce: false disables auto-submit" do + out = RubyUI::DataTableSearch.new(path: "/x", debounce: false).call + assert_no_match(/data-controller="ruby-ui--data-table-search"/, out) + assert_no_match(/data-ruby-ui--data-table-search-delay-value/, out) + end + + test "debounce: 0 disables auto-submit" do + out = RubyUI::DataTableSearch.new(path: "/x", debounce: 0).call + assert_no_match(/data-controller="ruby-ui--data-table-search"/, out) + end end From 5762dda505fb5d9b42f659ee0acf892ee9d4fb61 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:02:24 -0300 Subject: [PATCH 41/81] fix(data_table): restore search focus + cursor after Turbo Frame swap Debounced submit persists input.selectionStart/End to sessionStorage before requestSubmit(); next controller instance re-focuses on connect and restores cursor. Key scoped by form action to support multiple tables per page. --- .../ruby_ui/data_table_search_controller.js | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/app/javascript/controllers/ruby_ui/data_table_search_controller.js index 3716a618..514e1fcc 100644 --- a/app/javascript/controllers/ruby_ui/data_table_search_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_search_controller.js @@ -1,23 +1,69 @@ import { Controller } from "@hotwired/stimulus"; +const FLAG_KEY = "ruby-ui--data-table-search-focus"; + export default class extends Controller { static values = { delay: { type: Number, default: 300 } }; connect() { this.timer = null; + this.restoreFocus(); + } + + disconnect() { + clearTimeout(this.timer); } submit(event) { - // Only react to input events (not submit/change). if (event && event.type !== "input") return; clearTimeout(this.timer); if (this.delayValue <= 0) return; - this.timer = setTimeout(() => { - this.element.requestSubmit(); - }, this.delayValue); + this.timer = setTimeout(() => this.submitNow(), this.delayValue); } - disconnect() { - clearTimeout(this.timer); + submitNow() { + const input = this.input(); + if (input && document.activeElement === input) { + try { + sessionStorage.setItem( + this.flagKey(), + JSON.stringify({ + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd, + }) + ); + } catch (e) {} + } + this.element.requestSubmit(); + } + + restoreFocus() { + let state; + try { + const raw = sessionStorage.getItem(this.flagKey()); + if (!raw) return; + sessionStorage.removeItem(this.flagKey()); + state = JSON.parse(raw); + } catch (e) { + return; + } + const input = this.input(); + if (!input) return; + input.focus(); + const len = input.value.length; + const start = Math.min(state.selectionStart ?? len, len); + const end = Math.min(state.selectionEnd ?? len, len); + try { + input.setSelectionRange(start, end); + } catch (e) {} + } + + input() { + return this.element.querySelector('input[type="search"]'); + } + + flagKey() { + // Scope flag by form action so multiple tables on one page don't collide. + return `${FLAG_KEY}:${this.element.action || "_"}`; } } From 24eb85c13f3a7bd1f873a6b947729cd7670559cc Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:06:29 -0300 Subject: [PATCH 42/81] feat(data_table): DataTableForm id: prop + form= attribute support Add optional `id:` param to DataTableForm so bulk action buttons outside the form can reference it via the native `form=` attribute. --- app/components/ruby_ui/data_table/data_table_form.rb | 7 +++++-- test/components/ruby_ui/data_table/data_table_form_test.rb | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_form.rb b/app/components/ruby_ui/data_table/data_table_form.rb index cb90f390..68c4a26f 100644 --- a/app/components/ruby_ui/data_table/data_table_form.rb +++ b/app/components/ruby_ui/data_table/data_table_form.rb @@ -2,14 +2,17 @@ module RubyUI class DataTableForm < Base - def initialize(action: "", method: "post", **attrs) + def initialize(action: "", method: "post", id: nil, **attrs) @action = action @method = method + @id = id super(**attrs) end def view_template(&block) - form(action: @action, method: @method, **attrs) do + 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 diff --git a/test/components/ruby_ui/data_table/data_table_form_test.rb b/test/components/ruby_ui/data_table/data_table_form_test.rb index 28432007..b19b25a8 100644 --- a/test/components/ruby_ui/data_table/data_table_form_test.rb +++ b/test/components/ruby_ui/data_table/data_table_form_test.rb @@ -15,4 +15,9 @@ class RubyUI::DataTableFormTest < ActiveSupport::TestCase out = RubyUI::DataTableForm.new.call { "INNER" } assert_match(/INNER/, out) end + + test "renders form with id attribute when given" do + out = RubyUI::DataTableForm.new(id: "my_form").call + assert_match(/]*id="my_form"/, out) + end end From 6d02437d3bffd909a11b3570b9f16d0a3184c08c Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:06:33 -0300 Subject: [PATCH 43/81] fix(data_table): keep selection summary always visible Remove the controller logic that was hiding the selection summary when rows are selected. Summary now stays visible at all times; only BulkActions continues to toggle on selection count. --- app/javascript/controllers/ruby_ui/data_table_controller.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 90dbff05..6fe88e81 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -42,9 +42,5 @@ export default class extends Controller { if (this.hasBulkActionsTarget) { this.bulkActionsTarget.classList.toggle("hidden", selected === 0); } - - if (this.hasSelectionSummaryTarget) { - this.selectionSummaryTarget.classList.toggle("hidden", selected > 0); - } } } From 3bccadedff83e511ff90c892de153535e9b4c9e6 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:06:38 -0300 Subject: [PATCH 44/81] feat(data_table): move bulk actions into toolbar (demo layout) Relocate BulkActions from SelectionBar into the toolbar alongside ColumnToggle and PerPageSelect. Buttons reference the form via the native form= attribute. SelectionSummary moves to the footer row next to Pagination. DataTableSelectionBar is no longer used in the demo. --- app/views/docs/data_table_demo/index.rb | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 596fc992..23d2c290 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -2,6 +2,7 @@ class Views::Docs::DataTableDemo::Index < Views::Base FRAME_ID = "employees_list" + FORM_ID = "employees_form" TOGGLABLE_COLUMNS = [ {key: :email, label: "Email"}, @@ -48,10 +49,14 @@ def render_table 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(action: "") do + DataTableForm(id: FORM_ID, action: "") do div(class: "rounded-md border") do Table do TableHeader do @@ -89,23 +94,18 @@ def render_table end end end - - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @employees.size) - DataTableBulkActions do - Button(type: "submit", formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } - Button(type: "submit", formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } - end - end end - DataTablePagination( - page: @page, - per_page: @per_page, - total_count: @total_count, - path: docs_data_table_demo_path, - query: preserved_query - ) + div(class: "flex items-center justify-between gap-4 py-2") 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 From efd890ad14d5dc075ca86a4efb5c440e73340907 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:06:41 -0300 Subject: [PATCH 45/81] docs(data_table): update example heredocs for toolbar bulk actions Update examples 1 and 4 to show BulkActions in the toolbar using the form= attribute pattern, and SelectionSummary + Pagination together in the footer row. --- app/views/docs/data_table.rb | 55 +++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index a17ec0aa..892a0e56 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -29,17 +29,25 @@ def view_template 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) - DataTableColumnToggle(columns: [ - {key: :email, label: "Email"}, - {key: :department, label: "Department"} - ]) - DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page) + 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(action: "") do + DataTableForm(id: FORM_ID, action: "") do div(class: "rounded-md border") do Table do TableHeader do @@ -60,16 +68,12 @@ def view_template end end end - - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @employees.size) - DataTableBulkActions do - Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } - end - end end - DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path) + div(class: "flex items-center justify-between gap-4 py-2") 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 @@ -136,8 +140,19 @@ def view_template render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do <<~RUBY + FORM_ID = "my_form" + DataTable(id: "selection") do - DataTableForm(action: "") do + DataTableToolbar do + div(class: "flex items-center gap-2 ml-auto") do + DataTableBulkActions do + Button(type: "submit", form: FORM_ID, formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } + Button(type: "submit", form: FORM_ID, formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } + end + end + end + + DataTableForm(id: FORM_ID, action: "") do Table do TableHeader do TableRow do @@ -154,14 +169,10 @@ def view_template end end end + end - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @rows.size) - DataTableBulkActions do - Button(type: "submit", formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } - Button(type: "submit", formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } - end - end + div(class: "flex items-center justify-between gap-4 py-2") do + DataTableSelectionSummary(total_on_page: @rows.size) end end RUBY From c4a5241897f16e3420a97fb3fdb0eeb269b128f9 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:10:56 -0300 Subject: [PATCH 46/81] fix(data_table): per-page select chevron icon (avoid overlap) Mirrors NativeSelect's wrapper + NativeSelectIcon pattern so the chevron sits cleanly at the right, replacing the suppressed native arrow from Tailwind's appearance-none reset. --- .../data_table/data_table_per_page_select.rb | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) 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 index 43dd045c..4ba5b034 100644 --- 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 @@ -16,16 +16,23 @@ def view_template form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id form(**attrs.merge(form_attrs)) do - select( - name: @name, - onchange: safe("this.form.requestSubmit()"), - class: "h-9 rounded-md border border-input bg-background px-2 text-sm" - ) 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 } + div(class: "group/native-select relative w-fit") do + select( + name: @name, + onchange: safe("this.form.requestSubmit()"), + class: [ + "border-border bg-transparent text-sm w-full min-w-0 appearance-none rounded-md border py-1 pr-8 pl-2.5 shadow-xs transition-[color,box-shadow] outline-none select-none ring-0 ring-ring/0", + "focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2", + "h-9" + ] + ) 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 + render RubyUI::NativeSelectIcon.new end end end From fbf8a9c663fa39660a6ee9d97f4810e693ba1bc4 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:12:26 -0300 Subject: [PATCH 47/81] fix(layout): widen docs main content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the unused 300px right-rail grid column that split the main area on xl breakpoints. Docs pages now fill the full main column width — data table demos and other wide content render without cramping. --- app/views/layouts/docs_layout.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From e9ed5b29dc154faa01aabe9ba5d7cf8b992ce952 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:13:08 -0300 Subject: [PATCH 48/81] revert(data_table): per-page select uses native browser dropdown arrow Previous custom NativeSelectIcon chevron looked out of place next to the other toolbar controls. Native OS arrow fits better. --- .../data_table/data_table_per_page_select.rb | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) 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 index 4ba5b034..43dd045c 100644 --- 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 @@ -16,23 +16,16 @@ def view_template form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id form(**attrs.merge(form_attrs)) do - div(class: "group/native-select relative w-fit") do - select( - name: @name, - onchange: safe("this.form.requestSubmit()"), - class: [ - "border-border bg-transparent text-sm w-full min-w-0 appearance-none rounded-md border py-1 pr-8 pl-2.5 shadow-xs transition-[color,box-shadow] outline-none select-none ring-0 ring-ring/0", - "focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2", - "h-9" - ] - ) 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 + select( + name: @name, + onchange: safe("this.form.requestSubmit()"), + class: "h-9 rounded-md border border-input bg-background px-2 text-sm" + ) 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 - render RubyUI::NativeSelectIcon.new end end end From 6b704fe52bce93f885070134a0766861aae6baf6 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:15:29 -0300 Subject: [PATCH 49/81] docs(data_table): document bulk action button attributes Example 4 now shows the required attributes (type, form, formaction, formmethod) and optional turbo_confirm. Adds a reference table and a Rails controller snippet showing how to receive params[:ids]. --- app/views/docs/data_table.rb | 72 ++++++++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 892a0e56..d07594ea 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -136,23 +136,25 @@ def view_template # ── Example 4: Selection + bulk actions ─────────────────────────────── Heading(level: 2) { "Selection + bulk actions" } - p(class: "-mt-6") { "Form-first: row checkboxes are , bulk buttons submit via formaction." } + 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 - FORM_ID = "my_form" - DataTable(id: "selection") do DataTableToolbar do - div(class: "flex items-center gap-2 ml-auto") do - DataTableBulkActions do - Button(type: "submit", form: FORM_ID, formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } - Button(type: "submit", form: FORM_ID, formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } - end + div # empty left slot — or place filters here + 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: FORM_ID, action: "") do + DataTableForm(id: "selection_form", action: "") do Table do TableHeader do TableRow do @@ -171,13 +173,59 @@ def view_template end end - div(class: "flex items-center justify-between gap-4 py-2") do - DataTableSelectionSummary(total_on_page: @rows.size) - 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 + + 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 + # ── Example 5: Column visibility ────────────────────────────────────── Heading(level: 2) { "Column visibility" } p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." } From a22acbaf9fe28d97bd87a05082be2c310cc5e611 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:16:14 -0300 Subject: [PATCH 50/81] fix(data_table): restore NativeSelectIcon wrapper for per-page select Consistent chevron with rest of the UI. Native browser arrow looked worse on inspection. --- .../data_table/data_table_per_page_select.rb | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) 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 index 43dd045c..4ba5b034 100644 --- 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 @@ -16,16 +16,23 @@ def view_template form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id form(**attrs.merge(form_attrs)) do - select( - name: @name, - onchange: safe("this.form.requestSubmit()"), - class: "h-9 rounded-md border border-input bg-background px-2 text-sm" - ) 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 } + div(class: "group/native-select relative w-fit") do + select( + name: @name, + onchange: safe("this.form.requestSubmit()"), + class: [ + "border-border bg-transparent text-sm w-full min-w-0 appearance-none rounded-md border py-1 pr-8 pl-2.5 shadow-xs transition-[color,box-shadow] outline-none select-none ring-0 ring-ring/0", + "focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2", + "h-9" + ] + ) 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 + render RubyUI::NativeSelectIcon.new end end end From fe130d5c9c0f94a6c0e585b5f580b0744c4973b5 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:16:56 -0300 Subject: [PATCH 51/81] docs(data_table): menu + page title use 'Data Table' (two words) Matches convention of 'Alert Dialog', 'Date Picker', etc. Component class name stays DataTable (Ruby identifier). --- app/components/shared/components_list.rb | 2 +- app/views/docs/data_table.rb | 2 +- .../plans/2026-04-24-datatable-hotwire-plan.md | 4 ++-- .../specs/2026-04-24-datatable-hotwire-design.md | 12 +++++------- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/components/shared/components_list.rb b/app/components/shared/components_list.rb index 9cbe8bf2..9f2775c1 100644 --- a/app/components/shared/components_list.rb +++ b/app/components/shared/components_list.rb @@ -25,7 +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: "DataTable", path: docs_data_table_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/views/docs/data_table.rb b/app/views/docs/data_table.rb index d07594ea..7f0f67f5 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -19,7 +19,7 @@ def view_template div(class: "mx-auto w-full py-10 space-y-10") do render Docs::Header.new( - title: component, + 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." ) diff --git a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md index b56bfbad..c8138201 100644 --- a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md +++ b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md @@ -4,7 +4,7 @@ > > **Subagent model policy:** All implementation subagents MUST run on `claude-sonnet-4-6` with **low reasoning effort** for speed. Do NOT dispatch implementation tasks to Opus. Planning/review/verification may remain on the default model. -**Goal:** Ship a Hotwire-first, Avo-inspired `DataTable` component family for the Ruby UI docs site, with 12 components, 2 Stimulus controllers, 3 pagination adapters, 6 docs examples, and full render/integration tests. +**Goal:** Ship a Hotwire-first `DataTable` component family for the Ruby UI docs site, with 12 components, 2 Stimulus controllers, 3 pagination adapters, 6 docs examples, and full render/integration tests. **Architecture:** One `` wraps a real `` so row checkboxes submit natively. Search/sort/per-page/page each swap the frame via a plain GET. Row selection + column visibility are client-only ephemera held in two small Stimulus controllers. Reuses all existing `Table*`, `Checkbox`, `Pagination*`, `DropdownMenu`, `Input`, `NativeSelect`, `Badge`, `Button`, and `lucide-rails` primitives. @@ -2097,7 +2097,7 @@ class Views::Docs::DataTable < Views::Base div(class: "mx-auto w-full py-10 space-y-10") do render Docs::Header.new( title: component, - description: "A Hotwire-first, Avo-inspired data table. Every interaction (sort, search, pagination) is a Rails request answered with HTML, swapped via Turbo Frame. Row selection uses form-first submission." + 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." ) # ── Example 1: Complete demo (primary) ───────────────────────────────── diff --git a/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md b/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md index 4f2268cd..e1d009be 100644 --- a/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md +++ b/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md @@ -1,4 +1,4 @@ -# DataTable — Hotwire-first, Avo-inspired Spec +# DataTable — Hotwire-first Spec **Date:** 2026-04-24 **Branch:** `da/datatable-hotwire` @@ -15,9 +15,8 @@ JavaScript is Stimulus, kept minimal and scoped to ephemeral UI state **form-first** pattern: row checkboxes are `` inside a real ``, so bulk actions submit natively without custom fetch logic. -Architecture is inspired by Avo's `ResourceTableComponent`. Composition mirrors -shadcn's data-table demo, but each primitive maps to an existing Ruby UI -`Table*` component rather than a namespaced duplicate. +Composition mirrors shadcn's data-table demo, but each primitive maps to an +existing Ruby UI `Table*` component rather than a namespaced duplicate. ## Scope @@ -47,8 +46,7 @@ Out of scope: ### Reuse over duplication -Cirdes's branch duplicated 8 Table primitives as `DataTableAvo*`. This spec -reuses the existing `Table`, `TableHeader`, `TableBody`, `TableRow`, +Reuses the existing `Table`, `TableHeader`, `TableBody`, `TableRow`, `TableHead`, `TableCell`, `TableFooter`, `TableCaption` components directly. Users compose the table with primitives they already know from the rest of the docs site. @@ -66,7 +64,7 @@ owns the truth. Row selection and column visibility are DOM-local state. A Turbo Frame swap (sort/search/page) destroys and re-renders the frame, which naturally clears -both. This matches Avo's philosophy and is documented as intentional. +both. This is by design and documented as intentional. ### Query param flexibility From bff7d81e5f6f068491da50242da3ae4e6ae2b91e Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:23:16 -0300 Subject: [PATCH 52/81] style(table): TableHead uses text-foreground + whitespace-nowrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches shadcn's data-table convention. Also drops the email cell's text-muted-foreground override in demo — same color as rest of row. --- app/components/ruby_ui/table/table_head.rb | 2 +- app/views/docs/data_table_demo/index.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/ruby_ui/table/table_head.rb b/app/components/ruby_ui/table/table_head.rb index c0c8d0e4..0e742a41 100644 --- a/app/components/ruby_ui/table/table_head.rb +++ b/app/components/ruby_ui/table/table_head.rb @@ -10,7 +10,7 @@ def view_template(&) def default_attrs { - class: "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" + class: "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" } end end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 23d2c290..60141fb1 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -81,7 +81,7 @@ def render_table TableRow do TableCell(class: "w-10") { DataTableRowCheckbox(value: e.id) } TableCell(class: "font-medium") { plain e.name } - TableCell(class: "text-muted-foreground", data: {column: "email"}) { plain e.email } + 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 } From 90a31364eff309f14ad5e27e0b34961b6c362d3a Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:31:29 -0300 Subject: [PATCH 53/81] feat(data_table): add DataTablePaginationBar wrapper Dedicated flex justify-between wrapper so users don't have to write the layout div manually when composing a selection summary + pagination footer. --- .../data_table/data_table_pagination_bar.rb | 15 +++++++++++++++ .../data_table/data_table_pagination_bar_test.rb | 10 ++++++++++ 2 files changed, 25 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_pagination_bar.rb create mode 100644 test/components/ruby_ui/data_table/data_table_pagination_bar_test.rb 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/test/components/ruby_ui/data_table/data_table_pagination_bar_test.rb b/test/components/ruby_ui/data_table/data_table_pagination_bar_test.rb new file mode 100644 index 00000000..769ea1ef --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_pagination_bar_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class RubyUI::DataTablePaginationBarTest < ActiveSupport::TestCase + test "renders flex justify-between layout + children" do + out = RubyUI::DataTablePaginationBar.new.call { "INNER" } + assert_match(/class="[^"]*flex[^"]*"/, out) + assert_match(/class="[^"]*justify-between[^"]*"/, out) + assert_match(/INNER/, out) + end +end From ad375e7f128855f7eff3f7afb2bed60c9f027254 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:31:33 -0300 Subject: [PATCH 54/81] feat(data_table): use DataTablePaginationBar in demo + docs Replace the manual flex-between div in both the live demo view and the Example 1 code snippet with the new DataTablePaginationBar component. --- app/views/docs/data_table.rb | 40 ++++++++++++++++++++++++- app/views/docs/data_table_demo/index.rb | 2 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 7f0f67f5..94bf2138 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -70,7 +70,7 @@ def view_template end end - div(class: "flex items-center justify-between gap-4 py-2") do + 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 @@ -299,6 +299,44 @@ def status_badge(status) end RUBY end + + # ── Example 7: Expandable rows ──────────────────────────────────────── + 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 end end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 60141fb1..6a0347ed 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -96,7 +96,7 @@ def render_table end end - div(class: "flex items-center justify-between gap-4 py-2") do + DataTablePaginationBar do DataTableSelectionSummary(total_on_page: @employees.size) DataTablePagination( page: @page, From 6cf1947c6106c2e1b519fc152a03bce9600592ec Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:31:38 -0300 Subject: [PATCH 55/81] feat(data_table): add expandable row support (accessible) New DataTableExpandToggle component (button with aria-expanded + aria-controls) and matching data_table_row_expand Stimulus controller that toggles a hidden detail row. Keyboard-focusable and screen-reader-friendly. --- .../data_table/data_table_expand_toggle.rb | 44 +++++++++++++++++++ app/javascript/controllers/index.js | 3 ++ .../data_table_row_expand_controller.js | 14 ++++++ .../data_table_expand_toggle_test.rb | 19 ++++++++ 4 files changed, 80 insertions(+) create mode 100644 app/components/ruby_ui/data_table/data_table_expand_toggle.rb create mode 100644 app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js create mode 100644 test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb 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..75f61546 --- /dev/null +++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb @@ -0,0 +1,44 @@ +# 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: { + controller: "ruby-ui--data-table-row-expand", + action: "click->ruby-ui--data-table-row-expand#toggle" + }, + **attrs + ) do + render_icon + end + end + + private + + def render_icon + if view_context.respond_to?(:lucide_icon) + raw(view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 aria-expanded:rotate-90 [button[aria-expanded='true']_&]:rotate-90")) + else + span { plain @expanded ? "▼" : "▶" } + end + end + + def default_attrs + { + class: "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/javascript/controllers/index.js b/app/javascript/controllers/index.js index e68815bd..6e6005cd 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -49,6 +49,9 @@ 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__DataTableRowExpandController from "./ruby_ui/data_table_row_expand_controller" +application.register("ruby-ui--data-table-row-expand", RubyUi__DataTableRowExpandController) + import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller" application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController) diff --git a/app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js b/app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js new file mode 100644 index 00000000..ec94155c --- /dev/null +++ b/app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js @@ -0,0 +1,14 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + toggle(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); + } +} diff --git a/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb new file mode 100644 index 00000000..e8e11f04 --- /dev/null +++ b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb @@ -0,0 +1,19 @@ +require "test_helper" + +class RubyUI::DataTableExpandToggleTest < ActiveSupport::TestCase + test "renders a button with aria attributes + controller" do + out = RubyUI::DataTableExpandToggle.new(controls: "emp-1-detail").call + assert_match(/]*type="button"/, out) + assert_match(/aria-expanded="false"/, out) + assert_match(/aria-controls="emp-1-detail"/, out) + assert_match(/aria-label="Toggle row details"/, out) + assert_match(/data-controller="[^"]*ruby-ui--data-table-row-expand/, out) + assert_match(/data-action="[^"]*click->ruby-ui--data-table-row-expand#toggle/, out) + end + + test "accepts custom label + initial expanded state" do + out = RubyUI::DataTableExpandToggle.new(controls: "x", expanded: true, label: "Toggle").call + assert_match(/aria-expanded="true"/, out) + assert_match(/aria-label="Toggle"/, out) + end +end From fc7f3e3229a9da8085f8dc33c7d5d6abfb0a293d Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:32:38 -0300 Subject: [PATCH 56/81] fix(data_table): Pagination wrapper no longer forces center alignment Base Pagination has mx-auto w-full justify-center (sensible default elsewhere). DataTablePagination overrides with mx-0 w-auto justify-end so it sits flush on the right inside DataTablePaginationBar. --- app/components/ruby_ui/data_table/data_table_pagination.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/ruby_ui/data_table/data_table_pagination.rb b/app/components/ruby_ui/data_table/data_table_pagination.rb index 5c1bc53b..4f91e2fa 100644 --- a/app/components/ruby_ui/data_table/data_table_pagination.rb +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -13,7 +13,7 @@ def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, to end def view_template - render RubyUI::Pagination.new(**attrs) do + render RubyUI::Pagination.new(class: "mx-0 w-auto justify-end", **attrs) do render RubyUI::PaginationContent.new do prev_item number_items From 468dbd81c42388f913b2ba11da273009cfbe5ebd Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:43:13 -0300 Subject: [PATCH 57/81] fix(lint): drop unused component var + prefer double quotes --- app/views/docs/data_table.rb | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 94bf2138..fc0c146e 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -6,8 +6,6 @@ class Views::Docs::DataTable < Views::Base Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true) def view_template - component = "DataTable" - # Stubs so instance_eval'd preview snippets don't raise NameError @rows = [ Row.new(id: 1, name: "Alice", email: "alice@example.com", salary: 90_000, status: "Active"), @@ -193,10 +191,10 @@ def view_template [ ['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"] + ["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 } } From 65a4a21c9cdbbf166f084ea733862055424eb77d Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:56:11 -0300 Subject: [PATCH 58/81] revert(table): restore TableHead muted-foreground default --- app/components/ruby_ui/data_table/data_table_sort_head.rb | 2 +- app/components/ruby_ui/table/table_head.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index ba316b3b..ac175e28 100644 --- a/app/components/ruby_ui/data_table/data_table_sort_head.rb +++ b/app/components/ruby_ui/data_table/data_table_sort_head.rb @@ -15,7 +15,7 @@ def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort end def view_template - render RubyUI::TableHead.new(**attrs) do + 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 diff --git a/app/components/ruby_ui/table/table_head.rb b/app/components/ruby_ui/table/table_head.rb index 0e742a41..c0c8d0e4 100644 --- a/app/components/ruby_ui/table/table_head.rb +++ b/app/components/ruby_ui/table/table_head.rb @@ -10,7 +10,7 @@ def view_template(&) def default_attrs { - class: "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" + class: "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]" } end end From 4632359d9e21a5ab5d07d870da0167622885d0c6 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 16:56:49 -0300 Subject: [PATCH 59/81] fix(data_table): SortHead honors configurable page_param when resetting page --- app/components/ruby_ui/data_table/data_table_sort_head.rb | 5 +++-- .../ruby_ui/data_table/data_table_sort_head_test.rb | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) 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 index ac175e28..6713a6e0 100644 --- a/app/components/ruby_ui/data_table/data_table_sort_head.rb +++ b/app/components/ruby_ui/data_table/data_table_sort_head.rb @@ -2,13 +2,14 @@ module RubyUI class DataTableSortHead < Base - def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort", direction_param: "direction", path: "", query: {}, **attrs) + 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) @@ -31,7 +32,7 @@ def current_direction def next_params next_dir = {nil => "asc", "asc" => "desc", "desc" => nil}[current_direction] - base = @query.except(@sort_param, @direction_param, "page") + 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 diff --git a/test/components/ruby_ui/data_table/data_table_sort_head_test.rb b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb index f8b7023e..bd5a6b61 100644 --- a/test/components/ruby_ui/data_table/data_table_sort_head_test.rb +++ b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb @@ -30,4 +30,10 @@ class RubyUI::DataTableSortHeadTest < ActiveSupport::TestCase assert_match(/sort_by=name/, out) assert_match(/sort_dir=asc/, out) end + + test "custom page_param is dropped from next href when sorting" do + out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", page_param: "p", path: "/x", query: {"p" => "3", "search" => "bob"}).call + assert_no_match(/[?&]p=/, out) + assert_match(/search=bob/, out) + end end From 10823c1c366c0c4320dbbb892b38f31927bad80f Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:01:18 -0300 Subject: [PATCH 60/81] refactor(data_table): drop defensive view_context guards (fail loudly) --- .../ruby_ui/data_table/data_table_column_toggle.rb | 3 +-- .../ruby_ui/data_table/data_table_expand_toggle.rb | 6 +----- app/components/ruby_ui/data_table/data_table_form.rb | 3 +-- .../ruby_ui/data_table/data_table_sort_head.rb | 7 +------ app/views/docs/data_table_demo/index.rb | 3 +-- .../data_table/data_table_column_toggle_test.rb | 4 ++-- .../data_table/data_table_expand_toggle_test.rb | 4 ++-- .../ruby_ui/data_table/data_table_form_test.rb | 8 ++++---- .../ruby_ui/data_table/data_table_sort_head_test.rb | 12 ++++++------ test/test_helper.rb | 10 +++++++++- 10 files changed, 28 insertions(+), 32 deletions(-) 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 index 1d4e2dff..9bf311f8 100644 --- a/app/components/ruby_ui/data_table/data_table_column_toggle.rb +++ b/app/components/ruby_ui/data_table/data_table_column_toggle.rb @@ -13,8 +13,7 @@ def view_template render RubyUI::DropdownMenuTrigger.new do render RubyUI::Button.new(variant: :outline, size: :sm) do plain "Columns" - icon = view_context.respond_to?(:lucide_icon) ? raw(view_context.lucide_icon("chevron-down", class: "w-4 h-4 ml-1")) : nil - icon + raw view_context.lucide_icon("chevron-down", class: "w-4 h-4 ml-1") end end render RubyUI::DropdownMenuContent.new do 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 index 75f61546..3c9d0ebe 100644 --- a/app/components/ruby_ui/data_table/data_table_expand_toggle.rb +++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb @@ -28,11 +28,7 @@ def view_template private def render_icon - if view_context.respond_to?(:lucide_icon) - raw(view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 aria-expanded:rotate-90 [button[aria-expanded='true']_&]:rotate-90")) - else - span { plain @expanded ? "▼" : "▶" } - end + raw view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 aria-expanded:rotate-90 [button[aria-expanded='true']_&]:rotate-90") end def default_attrs diff --git a/app/components/ruby_ui/data_table/data_table_form.rb b/app/components/ruby_ui/data_table/data_table_form.rb index 68c4a26f..cb45e675 100644 --- a/app/components/ruby_ui/data_table/data_table_form.rb +++ b/app/components/ruby_ui/data_table/data_table_form.rb @@ -21,8 +21,7 @@ def view_template(&block) private def csrf_token - return view_context.form_authenticity_token if respond_to?(:view_context) && view_context.respond_to?(:form_authenticity_token) - SecureRandom.hex(32) + view_context.form_authenticity_token end def default_attrs 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 index 6713a6e0..29cedd33 100644 --- a/app/components/ruby_ui/data_table/data_table_sort_head.rb +++ b/app/components/ruby_ui/data_table/data_table_sort_head.rb @@ -49,12 +49,7 @@ def sort_icon end icon_class = current_direction ? "inline-block w-3 h-3" : "inline-block w-3 h-3 opacity-30" - vc = respond_to?(:view_context) ? view_context : nil - if vc&.respond_to?(:lucide_icon) - raw vc.lucide_icon(icon_name, class: icon_class) - else - span(class: icon_class, "data-icon": icon_name) - end + raw view_context.lucide_icon(icon_name, class: icon_class) end end end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 6a0347ed..243802c8 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -113,8 +113,7 @@ def row_actions(employee) DropdownMenu do DropdownMenuTrigger do Button(type: "button", variant: :ghost, size: :icon, aria_label: "Open menu") do - icon = view_context.respond_to?(:lucide_icon) ? raw(view_context.lucide_icon("ellipsis-vertical", class: "h-4 w-4")) : nil - icon + raw view_context.lucide_icon("ellipsis-vertical", class: "h-4 w-4") end end DropdownMenuContent do diff --git a/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb index 69ed84e3..4dc433a9 100644 --- a/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb +++ b/test/components/ruby_ui/data_table/data_table_column_toggle_test.rb @@ -2,10 +2,10 @@ class RubyUI::DataTableColumnToggleTest < ActiveSupport::TestCase test "renders dropdown with checkbox per column" do - out = RubyUI::DataTableColumnToggle.new(columns: [ + out = render_component(RubyUI::DataTableColumnToggle.new(columns: [ {key: :email, label: "Email"}, {key: :salary, label: "Salary"} - ]).call + ])) assert_match(/Columns/, out) assert_match(/data-controller="[^"]*ruby-ui--data-table-column-visibility/, out) assert_match(/data-column-key="email"/, out) diff --git a/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb index e8e11f04..cafac3ba 100644 --- a/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb +++ b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb @@ -2,7 +2,7 @@ class RubyUI::DataTableExpandToggleTest < ActiveSupport::TestCase test "renders a button with aria attributes + controller" do - out = RubyUI::DataTableExpandToggle.new(controls: "emp-1-detail").call + out = render_component(RubyUI::DataTableExpandToggle.new(controls: "emp-1-detail")) assert_match(/]*type="button"/, out) assert_match(/aria-expanded="false"/, out) assert_match(/aria-controls="emp-1-detail"/, out) @@ -12,7 +12,7 @@ class RubyUI::DataTableExpandToggleTest < ActiveSupport::TestCase end test "accepts custom label + initial expanded state" do - out = RubyUI::DataTableExpandToggle.new(controls: "x", expanded: true, label: "Toggle").call + out = render_component(RubyUI::DataTableExpandToggle.new(controls: "x", expanded: true, label: "Toggle")) assert_match(/aria-expanded="true"/, out) assert_match(/aria-label="Toggle"/, out) end diff --git a/test/components/ruby_ui/data_table/data_table_form_test.rb b/test/components/ruby_ui/data_table/data_table_form_test.rb index b19b25a8..bb64f249 100644 --- a/test/components/ruby_ui/data_table/data_table_form_test.rb +++ b/test/components/ruby_ui/data_table/data_table_form_test.rb @@ -2,22 +2,22 @@ class RubyUI::DataTableFormTest < ActiveSupport::TestCase test "renders form with method=post and action" do - out = RubyUI::DataTableForm.new(action: "/x").call + out = render_component(RubyUI::DataTableForm.new(action: "/x")) assert_match(/]*action="\/x"[^>]*method="post"|]*method="post"[^>]*action="\/x"/, out) end test "renders hidden authenticity_token" do - out = RubyUI::DataTableForm.new.call + out = render_component(RubyUI::DataTableForm.new) assert_match(/]*type="hidden"[^>]*name="authenticity_token"[^>]*value="[^"]+"/, out) end test "yields children" do - out = RubyUI::DataTableForm.new.call { "INNER" } + out = render_component(RubyUI::DataTableForm.new) { "INNER" } assert_match(/INNER/, out) end test "renders form with id attribute when given" do - out = RubyUI::DataTableForm.new(id: "my_form").call + out = render_component(RubyUI::DataTableForm.new(id: "my_form")) assert_match(/]*id="my_form"/, out) end end diff --git a/test/components/ruby_ui/data_table/data_table_sort_head_test.rb b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb index bd5a6b61..aa27a128 100644 --- a/test/components/ruby_ui/data_table/data_table_sort_head_test.rb +++ b/test/components/ruby_ui/data_table/data_table_sort_head_test.rb @@ -4,35 +4,35 @@ class RubyUI::DataTableSortHeadTest < ActiveSupport::TestCase test "renders a with a sort link cycling nil -> asc" do - out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {}).call + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {})) assert_match(/ next href is desc" do - out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "asc", path: "/x", query: {}).call + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "asc", path: "/x", query: {})) assert_match(/direction=desc/, out) end test "current desc -> next href clears sort (no params)" do - out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "desc", path: "/x", query: {}).call + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "desc", path: "/x", query: {})) assert_match(/href="\/x"/, out) end test "preserves other query params" do - out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {"search" => "alice"}).call + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {"search" => "alice"})) assert_match(/search=alice/, out) end test "renames sort/direction params" do - out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort_param: "sort_by", direction_param: "sort_dir", path: "/x", query: {}).call + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort_param: "sort_by", direction_param: "sort_dir", path: "/x", query: {})) assert_match(/sort_by=name/, out) assert_match(/sort_dir=asc/, out) end test "custom page_param is dropped from next href when sorting" do - out = RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", page_param: "p", path: "/x", query: {"p" => "3", "search" => "bob"}).call + out = render_component(RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", page_param: "p", path: "/x", query: {"p" => "3", "search" => "bob"})) assert_no_match(/[?&]p=/, out) assert_match(/search=bob/, out) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1f519045..efde3596 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,7 +9,15 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - # Add more helper methods to be used by all tests here... + # Renders a Phlex component with a Rails view context so helpers like + # `view_context.lucide_icon` and `view_context.form_authenticity_token` + # are available. Components that call these must use this helper in tests. + def render_component(component, &block) + controller = ApplicationController.new + controller.request = ActionDispatch::TestRequest.create + vc = controller.view_context + component.call(context: {rails_view_context: vc}, &block) + end end class ActionDispatch::IntegrationTest From 316545e4523c94a830e58ac432cbd23504a5cb69 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:02:27 -0300 Subject: [PATCH 61/81] refactor(data_table): PerPageSelect uses RubyUI::NativeSelect --- .../data_table/data_table_per_page_select.rb | 21 +++++-------------- .../data_table_per_page_select_test.rb | 2 +- 2 files changed, 6 insertions(+), 17 deletions(-) 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 index 4ba5b034..d3c88f8b 100644 --- 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 @@ -16,23 +16,12 @@ def view_template form_attrs[:data] = {turbo_frame: @frame_id} if @frame_id form(**attrs.merge(form_attrs)) do - div(class: "group/native-select relative w-fit") do - select( - name: @name, - onchange: safe("this.form.requestSubmit()"), - class: [ - "border-border bg-transparent text-sm w-full min-w-0 appearance-none rounded-md border py-1 pr-8 pl-2.5 shadow-xs transition-[color,box-shadow] outline-none select-none ring-0 ring-ring/0", - "focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2", - "h-9" - ] - ) 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 + 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 - render RubyUI::NativeSelectIcon.new end end end diff --git a/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb b/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb index 7ff29d5c..53cd022a 100644 --- a/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb +++ b/test/components/ruby_ui/data_table/data_table_per_page_select_test.rb @@ -4,7 +4,7 @@ class RubyUI::DataTablePerPageSelectTest < ActiveSupport::TestCase test "renders GET form with select and options" do out = RubyUI::DataTablePerPageSelect.new(path: "/x", value: 25, options: [5, 10, 25, 50]).call assert_match(/]*(method="get"[^>]*action="\/x"|action="\/x"[^>]*method="get")/, out) - assert_match(/]*name="per_page"/, out) + assert_match(/name="per_page"/, out) assert_match(/value="25"[^>]*selected|selected[^>]*value="25"/, out) assert_match(/onchange="this\.form\.requestSubmit\(\)"/, out) end From a73c6db332d2de414fe9b6f34167929602984a06 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:03:41 -0300 Subject: [PATCH 62/81] refactor(data_table): row + select-all checkboxes reuse RubyUI::Checkbox --- app/components/ruby_ui/data_table/data_table_row_checkbox.rb | 4 +--- .../ruby_ui/data_table/data_table_select_all_checkbox.rb | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) 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 index a05d2e1a..69f9aa0b 100644 --- a/app/components/ruby_ui/data_table/data_table_row_checkbox.rb +++ b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb @@ -9,18 +9,16 @@ def initialize(value:, name: "ids[]", **attrs) end def view_template - input(**attrs) + render RubyUI::Checkbox.new(**attrs) end private def default_attrs { - type: "checkbox", name: @name, value: @value, aria_label: "Select row #{@value}", - class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", data: { "ruby-ui--data-table-target": "rowCheckbox", action: "change->ruby-ui--data-table#toggleRow" 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 index c807042b..d1478f9e 100644 --- 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 @@ -3,16 +3,14 @@ module RubyUI class DataTableSelectAllCheckbox < Base def view_template - input(**attrs) + render RubyUI::Checkbox.new(**attrs) end private def default_attrs { - type: "checkbox", aria_label: "Select all", - class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", data: { "ruby-ui--data-table-target": "selectAll", action: "change->ruby-ui--data-table#toggleAll" From b0b1b61355b31ab9152eeabbffe8e018efb51229 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:04:20 -0300 Subject: [PATCH 63/81] chore(data_table): remove unused DataTableSelectionBar --- .../data_table/data_table_selection_bar.rb | 18 ------------------ .../data_table_selection_bar_test.rb | 10 ---------- 2 files changed, 28 deletions(-) delete mode 100644 app/components/ruby_ui/data_table/data_table_selection_bar.rb delete mode 100644 test/components/ruby_ui/data_table/data_table_selection_bar_test.rb diff --git a/app/components/ruby_ui/data_table/data_table_selection_bar.rb b/app/components/ruby_ui/data_table/data_table_selection_bar.rb deleted file mode 100644 index aa72a7fa..00000000 --- a/app/components/ruby_ui/data_table/data_table_selection_bar.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module RubyUI - class DataTableSelectionBar < Base - def view_template(&) - div(**attrs, &) - end - - private - - def default_attrs - { - class: "flex items-center justify-between gap-4 py-2", - data: {"ruby-ui--data-table-target": "selectionBar"} - } - end - end -end diff --git a/test/components/ruby_ui/data_table/data_table_selection_bar_test.rb b/test/components/ruby_ui/data_table/data_table_selection_bar_test.rb deleted file mode 100644 index 0a363c76..00000000 --- a/test/components/ruby_ui/data_table/data_table_selection_bar_test.rb +++ /dev/null @@ -1,10 +0,0 @@ -require "test_helper" - -class RubyUI::DataTableSelectionBarTest < ActiveSupport::TestCase - test "renders with selectionBar target + flex layout + children" do - out = RubyUI::DataTableSelectionBar.new.call { "INNER" } - assert_match(/data-ruby-ui--data-table-target="selectionBar"/, out) - assert_match(/class="[^"]*flex[^"]*"/, out) - assert_match(/INNER/, out) - end -end From 9b397d3d79fadc2e34f84e7a500473c41a6bdcc5 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:05:23 -0300 Subject: [PATCH 64/81] feat(data_table): Search preserves other query params via hidden inputs --- .../ruby_ui/data_table/data_table_search.rb | 8 +++++++- app/views/docs/data_table.rb | 3 ++- app/views/docs/data_table_demo/index.rb | 3 ++- .../data_table/data_table_search_test.rb | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_search.rb b/app/components/ruby_ui/data_table/data_table_search.rb index a8388e21..672aa053 100644 --- a/app/components/ruby_ui/data_table/data_table_search.rb +++ b/app/components/ruby_ui/data_table/data_table_search.rb @@ -2,13 +2,14 @@ module RubyUI class DataTableSearch < Base - def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, **attrs) + 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 @@ -24,6 +25,11 @@ def view_template placeholder: @placeholder, autocomplete: "off" ) + @preserved_params.each do |k, v| + next if v.blank? + next if k.to_s == @name + input(type: "hidden", name: k.to_s, value: v.to_s) + end end end diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index fc0c146e..aca76d56 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -113,7 +113,8 @@ def view_template <<~RUBY DataTable(id: "server") do DataTableToolbar do - DataTableSearch(path: my_path) + # Pass preserved_params so sort/direction survive a new search. + DataTableSearch(path: my_path, preserved_params: {"sort" => @sort, "direction" => @direction}.compact_blank) end Table do diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 243802c8..70f40451 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -40,7 +40,8 @@ def render_table path: docs_data_table_demo_path, frame_id: FRAME_ID, value: @search, - placeholder: "Filter emails..." + placeholder: "Filter emails...", + preserved_params: preserved_query.except("search") ) div(class: "flex items-center gap-2") do DataTableColumnToggle(columns: TOGGLABLE_COLUMNS) diff --git a/test/components/ruby_ui/data_table/data_table_search_test.rb b/test/components/ruby_ui/data_table/data_table_search_test.rb index 1f2eee0a..7474e7a6 100644 --- a/test/components/ruby_ui/data_table/data_table_search_test.rb +++ b/test/components/ruby_ui/data_table/data_table_search_test.rb @@ -42,4 +42,23 @@ class RubyUI::DataTableSearchTest < ActiveSupport::TestCase out = RubyUI::DataTableSearch.new(path: "/x", debounce: 0).call assert_no_match(/data-controller="ruby-ui--data-table-search"/, out) end + + test "preserved_params emits hidden inputs for each key" do + out = RubyUI::DataTableSearch.new(path: "/x", name: "search", preserved_params: {"sort" => "name", "direction" => "asc", "per_page" => "10"}).call + assert_match(/]*type="hidden"[^>]*name="sort"[^>]*value="name"/, out) + assert_match(/]*type="hidden"[^>]*name="direction"[^>]*value="asc"/, out) + assert_match(/]*type="hidden"[^>]*name="per_page"[^>]*value="10"/, out) + end + + test "preserved_params skips blank values" do + out = RubyUI::DataTableSearch.new(path: "/x", preserved_params: {"sort" => "", "direction" => nil}).call + assert_no_match(/name="sort"/, out) + assert_no_match(/name="direction"/, out) + end + + test "preserved_params skips the search param itself" do + out = RubyUI::DataTableSearch.new(path: "/x", name: "q", preserved_params: {"q" => "alice", "sort" => "name"}).call + assert_no_match(/]*type="hidden"[^>]*name="q"/, out) + assert_match(/name="sort"/, out) + end end From a846235e3d859a2672a852a81f30a7fb30392fea Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:06:32 -0300 Subject: [PATCH 65/81] refactor(data_table): delegate expand toggle to root controller --- .../ruby_ui/data_table/data_table_expand_toggle.rb | 3 +-- app/javascript/controllers/index.js | 2 -- .../controllers/ruby_ui/data_table_controller.js | 11 +++++++++++ .../ruby_ui/data_table_row_expand_controller.js | 14 -------------- .../data_table/data_table_expand_toggle_test.rb | 6 +++--- 5 files changed, 15 insertions(+), 21 deletions(-) delete mode 100644 app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js 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 index 3c9d0ebe..6d72a9bf 100644 --- a/app/components/ruby_ui/data_table/data_table_expand_toggle.rb +++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb @@ -16,8 +16,7 @@ def view_template aria_controls: @controls, aria_label: @label, data: { - controller: "ruby-ui--data-table-row-expand", - action: "click->ruby-ui--data-table-row-expand#toggle" + action: "click->ruby-ui--data-table#toggleRowDetail" }, **attrs ) do diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 6e6005cd..0c2e8e14 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -49,8 +49,6 @@ 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__DataTableRowExpandController from "./ruby_ui/data_table_row_expand_controller" -application.register("ruby-ui--data-table-row-expand", RubyUi__DataTableRowExpandController) import RubyUi__DataTableSearchController from "./ruby_ui/data_table_search_controller" application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchController) diff --git a/app/javascript/controllers/ruby_ui/data_table_controller.js b/app/javascript/controllers/ruby_ui/data_table_controller.js index 6fe88e81..1ffb8fb2 100644 --- a/app/javascript/controllers/ruby_ui/data_table_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_controller.js @@ -26,6 +26,17 @@ export default class extends Controller { 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; diff --git a/app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js b/app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js deleted file mode 100644 index ec94155c..00000000 --- a/app/javascript/controllers/ruby_ui/data_table_row_expand_controller.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -export default class extends Controller { - toggle(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); - } -} diff --git a/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb index cafac3ba..f6cedb30 100644 --- a/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb +++ b/test/components/ruby_ui/data_table/data_table_expand_toggle_test.rb @@ -1,14 +1,14 @@ require "test_helper" class RubyUI::DataTableExpandToggleTest < ActiveSupport::TestCase - test "renders a button with aria attributes + controller" do + test "renders a button with aria attributes and delegated action" do out = render_component(RubyUI::DataTableExpandToggle.new(controls: "emp-1-detail")) assert_match(/]*type="button"/, out) assert_match(/aria-expanded="false"/, out) assert_match(/aria-controls="emp-1-detail"/, out) assert_match(/aria-label="Toggle row details"/, out) - assert_match(/data-controller="[^"]*ruby-ui--data-table-row-expand/, out) - assert_match(/data-action="[^"]*click->ruby-ui--data-table-row-expand#toggle/, out) + assert_match(/data-action="[^"]*click->ruby-ui--data-table#toggleRowDetail/, out) + assert_no_match(/data-controller="ruby-ui--data-table-row-expand"/, out) end test "accepts custom label + initial expanded state" do From fa2146204cd34f6b981bf8af5150b6a19556bd6e Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:07:09 -0300 Subject: [PATCH 66/81] refactor(data_table): search focus restore via turbo:before-frame-render --- .../ruby_ui/data_table_search_controller.js | 58 ++++++++----------- 1 file changed, 24 insertions(+), 34 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/app/javascript/controllers/ruby_ui/data_table_search_controller.js index 514e1fcc..daf02773 100644 --- a/app/javascript/controllers/ruby_ui/data_table_search_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_search_controller.js @@ -1,69 +1,59 @@ import { Controller } from "@hotwired/stimulus"; -const FLAG_KEY = "ruby-ui--data-table-search-focus"; - export default class extends Controller { static values = { delay: { type: Number, default: 300 } }; connect() { this.timer = null; - this.restoreFocus(); + this.restoreState = null; + this.beforeFrameRender = this.captureBeforeRender.bind(this); + this.afterFrameRender = this.applyAfterRender.bind(this); + document.addEventListener("turbo:before-frame-render", this.beforeFrameRender); + document.addEventListener("turbo:frame-render", this.afterFrameRender); } disconnect() { clearTimeout(this.timer); + document.removeEventListener("turbo:before-frame-render", this.beforeFrameRender); + document.removeEventListener("turbo:frame-render", this.afterFrameRender); } submit(event) { if (event && event.type !== "input") return; clearTimeout(this.timer); if (this.delayValue <= 0) return; - this.timer = setTimeout(() => this.submitNow(), this.delayValue); + this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue); } - submitNow() { + captureBeforeRender() { const input = this.input(); - if (input && document.activeElement === input) { - try { - sessionStorage.setItem( - this.flagKey(), - JSON.stringify({ - selectionStart: input.selectionStart, - selectionEnd: input.selectionEnd, - }) - ); - } catch (e) {} + if (!input || document.activeElement !== input) { + this.restoreState = null; + return; } - this.element.requestSubmit(); + this.restoreState = { + selectionStart: input.selectionStart, + selectionEnd: input.selectionEnd + }; } - restoreFocus() { - let state; - try { - const raw = sessionStorage.getItem(this.flagKey()); - if (!raw) return; - sessionStorage.removeItem(this.flagKey()); - state = JSON.parse(raw); - } catch (e) { - return; - } + applyAfterRender() { + if (!this.restoreState) return; + const state = this.restoreState; + this.restoreState = null; const input = this.input(); if (!input) return; input.focus(); const len = input.value.length; - const start = Math.min(state.selectionStart ?? len, len); - const end = Math.min(state.selectionEnd ?? len, len); try { - input.setSelectionRange(start, end); + input.setSelectionRange( + Math.min(state.selectionStart ?? len, len), + Math.min(state.selectionEnd ?? len, len) + ); } catch (e) {} } input() { return this.element.querySelector('input[type="search"]'); } - - flagKey() { - // Scope flag by form action so multiple tables on one page don't collide. - return `${FLAG_KEY}:${this.element.action || "_"}`; - } } From 92d31c583b0b21be7eeea99e7fcdaf2443b9a778 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:07:49 -0300 Subject: [PATCH 67/81] docs(data_table): note button_to alternative and column toggle reset --- app/views/docs/data_table.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index aca76d56..f291830f 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -206,6 +206,8 @@ def view_template 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):" } @@ -228,6 +230,7 @@ def bulk_export # ── Example 5: Column visibility ────────────────────────────────────── 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 From 8df75abb10baef1482bedb147a48dbc14d1f076a Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:09:46 -0300 Subject: [PATCH 68/81] chore: remove docs/superpowers spec + plan artifacts --- .../2026-04-24-datatable-hotwire-plan.md | 2458 ----------------- .../2026-04-24-datatable-hotwire-design.md | 458 --- 2 files changed, 2916 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md delete mode 100644 docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md diff --git a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md b/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md deleted file mode 100644 index c8138201..00000000 --- a/docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md +++ /dev/null @@ -1,2458 +0,0 @@ -# DataTable Hotwire-first Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -> -> **Subagent model policy:** All implementation subagents MUST run on `claude-sonnet-4-6` with **low reasoning effort** for speed. Do NOT dispatch implementation tasks to Opus. Planning/review/verification may remain on the default model. - -**Goal:** Ship a Hotwire-first `DataTable` component family for the Ruby UI docs site, with 12 components, 2 Stimulus controllers, 3 pagination adapters, 6 docs examples, and full render/integration tests. - -**Architecture:** One `` wraps a real `` so row checkboxes submit natively. Search/sort/per-page/page each swap the frame via a plain GET. Row selection + column visibility are client-only ephemera held in two small Stimulus controllers. Reuses all existing `Table*`, `Checkbox`, `Pagination*`, `DropdownMenu`, `Input`, `NativeSelect`, `Badge`, `Button`, and `lucide-rails` primitives. - -**Tech Stack:** Rails 8.1, Phlex, Turbo, Stimulus, Tailwind 4, `lucide-rails`, Minitest, Phlex kit helpers. - ---- - -## Workflow requirements - -- **Branch:** `da/datatable-hotwire` (already created from `main`). -- **Commits:** One commit per task when the task results in a meaningful change. Never batch at end. Use HEREDOC commit messages per repo convention. -- **Subagents:** MUST be `claude-sonnet-4-6`, low effort. Reject Opus for implementation. -- **Environment:** All `bundle`, `bin/rails`, `pnpm` commands run inside the devcontainer. Use the helper `dx` alias defined in Task 1. - -## Reference index - -- Spec: `docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md` -- Existing Table primitives: `app/components/ruby_ui/table/` -- Existing Checkbox: `app/components/ruby_ui/checkbox/checkbox.rb` -- Existing Pagination: `app/components/ruby_ui/pagination/` -- Existing DropdownMenu: `app/components/ruby_ui/dropdown_menu/` -- Existing NativeSelect: `app/components/ruby_ui/native_select.rb` (plus `native_select_option.rb` if present) -- Existing Base: `app/components/ruby_ui/base.rb` -- Existing docs visual example: `app/components/docs/visual_code_example.rb` -- Existing docs header: `app/components/docs/header.rb` -- Sidebar menu: `app/components/shared/menu.rb` -- Routes: `config/routes.rb` -- Controllers index (Stimulus): `app/javascript/controllers/index.js` - ---- - -## Task 1: Environment prep - -**Files:** none modified. - -- [ ] **Step 1.1: Verify branch** - -Run: -```bash -git status -git log -1 --oneline -``` -Expected: branch `da/datatable-hotwire`, head commit is the spec. - -- [ ] **Step 1.2: Define `dx` helper** - -Every later task's `Run:` lines assume this helper. Paste this into your shell: - -```bash -dx() { - docker exec rubyui-web-rails-app-1 bash -c ' - export PATH="/home/vscode/.local/share/mise/installs/node/25.8.2/bin:/home/vscode/.local/bin:/home/vscode/.local/share/mise/installs/ruby/3.4.7/bin:$PATH" - export SECRET_KEY_BASE=abc123 - export BUNDLE_PATH=/workspaces/web/vendor/bundle - cd /workspaces/web - '"$*" -} -``` - -**Baseline confirmed:** 61 runs, 0 failures, 0 errors before starting. - -- [ ] **Step 1.3: Start devcontainer (if not running)** - -```bash -cd /home/didi/dev/linkana/web/.devcontainer && docker compose up -d && cd /home/didi/dev/linkana/web -``` -Expected: container `rubyui-web-rails-app-1` running. - -- [ ] **Step 1.4: Baseline test run** - -```bash -dx bin/rails test -``` -Expected: existing suite green. Record failure count if any exist before starting. - ---- - -## Task 2: Add routes - -**Files:** -- Modify: `config/routes.rb` - -- [ ] **Step 2.1: Add routes inside the existing `scope "docs"` block** - -Locate the closing `end` of the `scope "docs" do ... end` block in `config/routes.rb` (around the final components entry). Just before that `end`, add: - -```ruby - # 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 -``` - -- [ ] **Step 2.2: Verify routes** - -Run: -```bash -dx bin/rails routes -g data_table -``` -Expected: four routes listed (`docs_data_table`, `docs_data_table_demo`, `docs_data_table_demo_bulk_delete`, `docs_data_table_demo_bulk_export`). - -- [ ] **Step 2.3: Commit** - -```bash -git add config/routes.rb -git commit -m "$(cat <<'EOF' -feat(routes): data_table docs and demo endpoints - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Add `DocsController#data_table` action - -**Files:** -- Modify: `app/controllers/docs_controller.rb` - -- [ ] **Step 3.1: Add action** - -Find the end of the `class DocsController` (last action before `end`). Add, in alphabetical-ish neighborhood near `table`: - -```ruby - def data_table - render Views::Docs::DataTable.new - end -``` - -- [ ] **Step 3.2: Commit** - -```bash -git add app/controllers/docs_controller.rb -git commit -m "$(cat <<'EOF' -feat(docs): add data_table action to DocsController - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: Add DataTable to sidebar menu - -**Files:** -- Modify: `app/components/shared/menu.rb` - -Components list for the sidebar is resolved via `ComponentsList` concern. Inspect it first; if it pulls from `app/components/ruby_ui/` directory names, nothing extra to add — directory `data_table/` alone will register. If it pulls from a hand-maintained array, add `{name: "DataTable", path: docs_data_table_path}` in alphabetical position. - -- [ ] **Step 4.1: Find the component list source** - -Run: -```bash -grep -rn "ComponentsList" app/components app/helpers app/controllers | head -10 -``` - -- [ ] **Step 4.2: Add entry** - -If `ComponentsList` returns static hashes, add the data_table entry. If it scans directories, skip. Record the decision in the commit body. - -- [ ] **Step 4.3: Commit (if changes)** - -```bash -git add -A -git commit -m "$(cat <<'EOF' -feat(docs-nav): add DataTable sidebar entry - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Demo data module - -**Files:** -- Create: `app/controllers/docs/data_table_demo_data.rb` - -- [ ] **Step 5.1: Create the file** - -```ruby -# 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: 71_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} - # ... continue through id: 100 using the full list from Cirdes's commit - # (see git show 36a61e8 -- app/controllers/docs/data_table_demo_data.rb for the complete array) - ].map { |e| Data.define(*e.keys).new(**e) }.freeze - end -end -``` - -Copy the full 100-row list from `git show 36a61e8 -- app/controllers/docs/data_table_demo_data.rb`. Do not paraphrase. - -- [ ] **Step 5.2: Commit** - -```bash -git add app/controllers/docs/data_table_demo_data.rb -git commit -m "$(cat <<'EOF' -feat(docs): add DataTableDemoData module (100 employees) - -Reused from Cirdes's branch verbatim for demo fixture parity. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 6: Pagination adapter — `Manual` - -**Files:** -- Create: `app/components/ruby_ui/data_table_pagination/manual.rb` -- Create: `test/components/ruby_ui/data_table_pagination/manual_test.rb` - -- [ ] **Step 6.1: Write failing test** - -```ruby -# test/components/ruby_ui/data_table_pagination/manual_test.rb -require "test_helper" - -class RubyUI::DataTable::Pagination::ManualTest < ActiveSupport::TestCase - test "computes total_pages from total_count and per_page" do - adapter = RubyUI::DataTable::Pagination::Manual.new(page: 2, per_page: 10, total_count: 25) - assert_equal 2, adapter.current_page - assert_equal 10, adapter.per_page - assert_equal 25, adapter.total_count - assert_equal 3, adapter.total_pages - end - - test "total_pages is at least 1 for empty total" do - adapter = RubyUI::DataTable::Pagination::Manual.new(page: 1, per_page: 10, total_count: 0) - assert_equal 1, adapter.total_pages - end - - test "coerces integer inputs" do - adapter = RubyUI::DataTable::Pagination::Manual.new(page: "3", per_page: "5", total_count: "12") - assert_equal 3, adapter.current_page - assert_equal 3, adapter.total_pages - end -end -``` - -- [ ] **Step 6.2: Run test, expect fail** - -```bash -dx bin/rails test test/components/ruby_ui/data_table_pagination/manual_test.rb -``` -Expected: NameError / load error (class missing). - -- [ ] **Step 6.3: Implement** - -```ruby -# app/components/ruby_ui/data_table_pagination/manual.rb -# frozen_string_literal: true - -module RubyUI - module DataTable - module Pagination - class Manual - 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 - end -end -``` - -**Note:** `RubyUI::DataTable` will later conflict with the `RubyUI::DataTable` component class. Resolve by defining adapters under `RubyUI::DataTableAdapters::Pagination::Manual` OR leaving the root `DataTable` component as `RubyUI::DataTable` and putting adapters under `RubyUI::DataTablePagination::Manual`. **Decision: use `RubyUI::DataTablePagination` namespace for adapters** (class `RubyUI::DataTable` = component, module `RubyUI::DataTablePagination` = adapter namespace). Update the test class and file accordingly: - -```ruby -# Revised path + class -# File: app/components/ruby_ui/data_table_pagination/manual.rb -module RubyUI - module DataTablePagination - class Manual - ... - end - end -end - -# Test class: RubyUI::DataTablePagination::ManualTest -``` - -Fix both files to use `RubyUI::DataTablePagination::Manual`. - -- [ ] **Step 6.4: Run test, expect pass** - -```bash -dx bin/rails test test/components/ruby_ui/data_table_pagination/manual_test.rb -``` -Expected: 3 runs, 0 failures. - -- [ ] **Step 6.5: Commit** - -```bash -git add app/components/ruby_ui/data_table_pagination/manual.rb \ - test/components/ruby_ui/data_table_pagination/manual_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add Manual pagination adapter - -Normalizes page/per_page/total_count inputs; total_pages >= 1. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 7: Pagination adapter — `Pagy` - -**Files:** -- Create: `app/components/ruby_ui/data_table_pagination/pagy.rb` -- Create: `test/components/ruby_ui/data_table_pagination/pagy_test.rb` - -- [ ] **Step 7.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTablePagination::PagyTest < ActiveSupport::TestCase - PagyDouble = Data.define(:page, :pages, :count, :items) - - test "reads page, pages, count" do - pagy = PagyDouble.new(page: 2, pages: 5, count: 47, items: 10) - adapter = RubyUI::DataTablePagination::Pagy.new(pagy) - assert_equal 2, adapter.current_page - assert_equal 5, adapter.total_pages - assert_equal 47, adapter.total_count - assert_equal 10, adapter.per_page - end -end -``` - -- [ ] **Step 7.2: Run, expect fail** - -```bash -dx bin/rails test test/components/ruby_ui/data_table_pagination/pagy_test.rb -``` - -- [ ] **Step 7.3: Implement** - -```ruby -# app/components/ruby_ui/data_table_pagination/pagy.rb -# frozen_string_literal: true - -module RubyUI - module DataTablePagination - class Pagy - 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 -end -``` - -- [ ] **Step 7.4: Run, expect pass** - -- [ ] **Step 7.5: Commit** - -```bash -git add app/components/ruby_ui/data_table_pagination/pagy.rb \ - test/components/ruby_ui/data_table_pagination/pagy_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add Pagy pagination adapter - -Duck-typed wrapper — does not add pagy gem as a dependency. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 8: Pagination adapter — `Kaminari` - -**Files:** -- Create: `app/components/ruby_ui/data_table_pagination/kaminari.rb` -- Create: `test/components/ruby_ui/data_table_pagination/kaminari_test.rb` - -- [ ] **Step 8.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTablePagination::KaminariTest < ActiveSupport::TestCase - CollectionDouble = Data.define(:current_page, :total_pages, :total_count, :limit_value) - - test "reads current_page, total_pages, total_count, limit_value" do - coll = CollectionDouble.new(current_page: 3, total_pages: 7, total_count: 61, limit_value: 10) - adapter = RubyUI::DataTablePagination::Kaminari.new(coll) - assert_equal 3, adapter.current_page - assert_equal 7, adapter.total_pages - assert_equal 61, adapter.total_count - assert_equal 10, adapter.per_page - end -end -``` - -- [ ] **Step 8.2: Run, expect fail** - -- [ ] **Step 8.3: Implement** - -```ruby -# app/components/ruby_ui/data_table_pagination/kaminari.rb -# frozen_string_literal: true - -module RubyUI - module DataTablePagination - class Kaminari - 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 -end -``` - -- [ ] **Step 8.4: Run, expect pass** - -- [ ] **Step 8.5: Commit** - -```bash -git add app/components/ruby_ui/data_table_pagination/kaminari.rb \ - test/components/ruby_ui/data_table_pagination/kaminari_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add Kaminari pagination adapter - -Duck-typed wrapper — no gem dependency added. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 9: Component — `DataTable` (root) - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table.rb` -- Create: `test/components/ruby_ui/data_table/data_table_test.rb` - -- [ ] **Step 9.1: Failing test** - -```ruby -# test/components/ruby_ui/data_table/data_table_test.rb -require "test_helper" - -class RubyUI::DataTableTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders a turbo-frame with given id" do - output = render RubyUI::DataTable.new(id: "employees") - assert_match %r{]*id="employees"[^>]*target="_top"}, output - end - - test "sets data-controller on inner container" do - output = render RubyUI::DataTable.new(id: "x") - assert_match %r{data-controller="ruby-ui--data-table"}, output - end - - test "renders children inside form" do - output = render(RubyUI::DataTable.new(id: "x")) { "INNER" } - assert_match(/INNER/, output) - assert_match(/ wrapping a with the ruby-ui--data-table -Stimulus controller. Form supports form-first bulk actions via button -formaction. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 10: Component — `DataTableToolbar` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_toolbar.rb` -- Create: `test/components/ruby_ui/data_table/data_table_toolbar_test.rb` - -- [ ] **Step 10.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableToolbarTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders div with flex layout and children" do - out = render(RubyUI::DataTableToolbar.new) { "INNER" } - assert_match(/]*class="[^"]*flex[^"]*"/, out) - assert_match(/INNER/, out) - end -end -``` - -- [ ] **Step 10.2: Run, expect fail** - -- [ ] **Step 10.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_toolbar.rb -# 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 -``` - -- [ ] **Step 10.4: Run, expect pass** - -- [ ] **Step 10.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_toolbar.rb \ - test/components/ruby_ui/data_table/data_table_toolbar_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableToolbar layout slot - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 11: Component — `DataTableSearch` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_search.rb` -- Create: `test/components/ruby_ui/data_table/data_table_search_test.rb` - -- [ ] **Step 11.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableSearchTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders GET form with search input" do - out = render RubyUI::DataTableSearch.new(path: "/x", value: "alice", name: "search") - assert_match(/]*method="get"[^>]*action="\/x"/, out) - assert_match(/]*name="search"[^>]*value="alice"/, out) - end - - test "renames param via name:" do - out = render RubyUI::DataTableSearch.new(path: "/x", name: "q") - assert_match(/name="q"/, out) - end - - test "emits data-turbo-frame when frame_id given" do - out = render RubyUI::DataTableSearch.new(path: "/x", frame_id: "employees") - assert_match(/data-turbo-frame="employees"/, out) - end -end -``` - -- [ ] **Step 11.2: Run, expect fail** - -- [ ] **Step 11.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_search.rb -# frozen_string_literal: true - -module RubyUI - class DataTableSearch < Base - def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", **attrs) - @path = path - @name = name - @value = value - @frame_id = frame_id - @placeholder = placeholder - 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::Input.new( - type: :search, - name: @name, - value: @value, - placeholder: @placeholder, - autocomplete: "off" - ) - end - end - - private - - def default_attrs - {class: "max-w-sm flex-1"} - end - end -end -``` - -- [ ] **Step 11.4: Run, expect pass** - -- [ ] **Step 11.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_search.rb \ - test/components/ruby_ui/data_table/data_table_search_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableSearch (Turbo-Frame GET form) - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 12: Component — `DataTablePerPageSelect` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_per_page_select.rb` -- Create: `test/components/ruby_ui/data_table/data_table_per_page_select_test.rb` - -- [ ] **Step 12.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTablePerPageSelectTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders GET form with NativeSelect and options" do - out = render RubyUI::DataTablePerPageSelect.new(path: "/x", value: 25, options: [5, 10, 25, 50]) - assert_match(/]*method="get"[^>]*action="\/x"/, out) - assert_match(/]*name="per_page"/, out) - assert_match(/]*value="25"[^>]*selected/, out) - assert_match(/onchange="this\.form\.requestSubmit\(\)"/, out) - end - - test "renames param via name:" do - out = render RubyUI::DataTablePerPageSelect.new(path: "/x", name: "size") - assert_match(/name="size"/, out) - end -end -``` - -- [ ] **Step 12.2: Run, expect fail** - -- [ ] **Step 12.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_per_page_select.rb -# 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 - select( - name: @name, - onchange: "this.form.requestSubmit()", - class: "h-9 rounded-md border border-input bg-background px-2 text-sm" - ) 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 -``` - -**Note:** The existing `RubyUI::NativeSelect` renders a `` here to keep the `onchange="this.form.requestSubmit()"` attribute easy to emit. If `NativeSelect` accepts arbitrary HTML attributes via `**attrs` passthrough, swap to `render RubyUI::NativeSelect.new(name:, onchange:, ...)` — verify by reading `native_select.rb` and pick whichever preserves native select styling consistency. Decision during implementation. - -- [ ] **Step 12.4: Run, expect pass** - -- [ ] **Step 12.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_per_page_select.rb \ - test/components/ruby_ui/data_table/data_table_per_page_select_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTablePerPageSelect (auto-submitting select) - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 13: Component — `DataTableSortHead` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_sort_head.rb` -- Create: `test/components/ruby_ui/data_table/data_table_sort_head_test.rb` - -- [ ] **Step 13.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableSortHeadTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders a with a sort link cycling nil -> asc" do - out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {}) - assert_match(/ next href is desc" do - out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "asc", path: "/x", query: {}) - assert_match(/direction=desc/, out) - end - - test "current desc -> next href clears sort (no params)" do - out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort: "name", direction: "desc", path: "/x", query: {}) - # No sort/direction params — just /x - assert_match(/href="\/x"/, out) - end - - test "preserves other query params" do - out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", path: "/x", query: {"search" => "alice"}) - assert_match(/search=alice/, out) - end - - test "renames sort/direction params" do - out = render RubyUI::DataTableSortHead.new(column_key: :name, label: "Name", sort_param: "sort_by", direction_param: "sort_dir", path: "/x", query: {}) - assert_match(/sort_by=name/, out) - assert_match(/sort_dir=asc/, out) - end -end -``` - -- [ ] **Step 13.2: Run, expect fail** - -- [ ] **Step 13.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_sort_head.rb -# frozen_string_literal: true - -module RubyUI - class DataTableSortHead < Base - def initialize(column_key:, label:, sort: nil, direction: nil, sort_param: "sort", direction_param: "direction", path: "", query: {}, **attrs) - @column_key = column_key - @label = label - @sort = sort - @direction = direction - @sort_param = sort_param - @direction_param = direction_param - @path = path - @query = query.to_h.transform_keys(&:to_s) - super(**attrs) - end - - def view_template - render RubyUI::TableHead.new(**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") - next_dir ? base.merge(@sort_param => @column_key.to_s, @direction_param => next_dir) : base - end - - def sort_href - qs = next_params.to_query - qs.empty? ? @path : "#{@path}?#{qs}" - end - - def sort_icon - case current_direction - when "asc" then helpers.lucide_icon("chevron-up", class: "inline-block w-3 h-3") - when "desc" then helpers.lucide_icon("chevron-down", class: "inline-block w-3 h-3") - else helpers.lucide_icon("chevrons-up-down", class: "inline-block w-3 h-3 opacity-30") - end - end - end -end -``` - -- [ ] **Step 13.4: Run, expect pass** - -If `helpers.lucide_icon` is unavailable inside Phlex components, use the module accessor (`Rails.application.routes.url_helpers` pattern doesn't apply here). Verify by reading an existing component that uses `lucide_icon` (e.g. grep `app/components` for `lucide_icon`). Adjust call site. - -- [ ] **Step 13.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_sort_head.rb \ - test/components/ruby_ui/data_table/data_table_sort_head_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableSortHead with asc/desc/none cycle - -Uses lucide-rails file-based SVG icons. Configurable sort/direction -param names. Preserves existing query params. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 14: Component — `DataTableRowCheckbox` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_row_checkbox.rb` -- Create: `test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb` - -- [ ] **Step 14.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableRowCheckboxTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders " do - out = render RubyUI::DataTableRowCheckbox.new(value: 42) - assert_match(/]*type="checkbox"/, out) - assert_match(/name="ids\[\]"/, out) - assert_match(/value="42"/, out) - end - - test "accepts custom name" do - out = render RubyUI::DataTableRowCheckbox.new(value: 1, name: "selected[]") - assert_match(/name="selected\[\]"/, out) - end - - test "carries Stimulus target + action" do - out = render RubyUI::DataTableRowCheckbox.new(value: 1) - assert_match(/data-ruby-ui--data-table-target="rowCheckbox"/, out) - assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleRow/, out) - end - - test "ARIA label contains the value" do - out = render RubyUI::DataTableRowCheckbox.new(value: 7) - assert_match(/aria-label="Select row 7"/, out) - end -end -``` - -- [ ] **Step 14.2: Run, expect fail** - -- [ ] **Step 14.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_row_checkbox.rb -# frozen_string_literal: true - -module RubyUI - class DataTableRowCheckbox < Base - def initialize(value:, name: "ids[]", **attrs) - @value = value - @name = name - super(**attrs) - end - - def view_template - input( - type: "checkbox", - name: @name, - value: @value, - aria_label: "Select row #{@value}", - class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", - data: { - "ruby-ui--data-table-target": "rowCheckbox", - action: "change->ruby-ui--data-table#toggleRow" - }, - **attrs - ) - end - end -end -``` - -- [ ] **Step 14.4: Run, expect pass** - -- [ ] **Step 14.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_row_checkbox.rb \ - test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableRowCheckbox (form-first selection) - -Native so bulk actions submit via -without custom fetch. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 15: Component — `DataTableSelectAllCheckbox` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb` -- Create: `test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb` - -- [ ] **Step 15.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableSelectAllCheckboxTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "carries selectAll target + toggleAll action + aria-label" do - out = render RubyUI::DataTableSelectAllCheckbox.new - assert_match(/]*type="checkbox"/, out) - assert_match(/data-ruby-ui--data-table-target="selectAll"/, out) - assert_match(/data-action="[^"]*change->ruby-ui--data-table#toggleAll/, out) - assert_match(/aria-label="Select all"/, out) - end -end -``` - -- [ ] **Step 15.2: Run, expect fail** - -- [ ] **Step 15.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb -# frozen_string_literal: true - -module RubyUI - class DataTableSelectAllCheckbox < Base - def view_template - input( - type: "checkbox", - aria_label: "Select all", - class: "peer h-4 w-4 shrink-0 rounded-sm border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", - data: { - "ruby-ui--data-table-target": "selectAll", - action: "change->ruby-ui--data-table#toggleAll" - }, - **attrs - ) - end - end -end -``` - -- [ ] **Step 15.4: Run, expect pass** - -- [ ] **Step 15.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_select_all_checkbox.rb \ - test/components/ruby_ui/data_table/data_table_select_all_checkbox_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableSelectAllCheckbox - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 16: Component — `DataTableSelectionSummary` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_selection_summary.rb` -- Create: `test/components/ruby_ui/data_table/data_table_selection_summary_test.rb` - -- [ ] **Step 16.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableSelectionSummaryTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders '0 of N row(s) selected.' with target" do - out = render RubyUI::DataTableSelectionSummary.new(total_on_page: 10) - assert_match(/0 of 10 row\(s\) selected\./, out) - assert_match(/data-ruby-ui--data-table-target="selectionSummary"/, out) - end -end -``` - -- [ ] **Step 16.2: Run, expect fail** - -- [ ] **Step 16.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_selection_summary.rb -# 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 -``` - -- [ ] **Step 16.4: Run, expect pass** - -- [ ] **Step 16.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_selection_summary.rb \ - test/components/ruby_ui/data_table/data_table_selection_summary_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableSelectionSummary - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 17: Component — `DataTableBulkActions` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_bulk_actions.rb` -- Create: `test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb` - -- [ ] **Step 17.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableBulkActionsTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "starts hidden with bulkActions target + renders children" do - out = render(RubyUI::DataTableBulkActions.new) { "BUTTONS" } - assert_match(/class="[^"]*hidden[^"]*"/, out) - assert_match(/data-ruby-ui--data-table-target="bulkActions"/, out) - assert_match(/BUTTONS/, out) - end -end -``` - -- [ ] **Step 17.2: Run, expect fail** - -- [ ] **Step 17.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_bulk_actions.rb -# 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 -``` - -- [ ] **Step 17.4: Run, expect pass** - -- [ ] **Step 17.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_bulk_actions.rb \ - test/components/ruby_ui/data_table/data_table_bulk_actions_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableBulkActions (hidden until selection>0) - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 18: Component — `DataTableSelectionBar` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_selection_bar.rb` -- Create: `test/components/ruby_ui/data_table/data_table_selection_bar_test.rb` - -- [ ] **Step 18.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableSelectionBarTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders with selectionBar target + flex layout + children" do - out = render(RubyUI::DataTableSelectionBar.new) { "INNER" } - assert_match(/data-ruby-ui--data-table-target="selectionBar"/, out) - assert_match(/class="[^"]*flex[^"]*"/, out) - assert_match(/INNER/, out) - end -end -``` - -- [ ] **Step 18.2: Run, expect fail** - -- [ ] **Step 18.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_selection_bar.rb -# frozen_string_literal: true - -module RubyUI - class DataTableSelectionBar < Base - def view_template(&) - div(**attrs, &) - end - - private - - def default_attrs - { - class: "flex items-center justify-between gap-4 py-2", - data: {"ruby-ui--data-table-target": "selectionBar"} - } - end - end -end -``` - -- [ ] **Step 18.4: Run, expect pass** - -- [ ] **Step 18.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_selection_bar.rb \ - test/components/ruby_ui/data_table/data_table_selection_bar_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableSelectionBar - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 19: Component — `DataTableColumnToggle` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_column_toggle.rb` -- Create: `test/components/ruby_ui/data_table/data_table_column_toggle_test.rb` - -- [ ] **Step 19.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTableColumnToggleTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "renders dropdown menu with checkbox per column" do - out = render RubyUI::DataTableColumnToggle.new(columns: [ - {key: :email, label: "Email"}, - {key: :salary, label: "Salary"} - ]) - assert_match(/Columns/, out) - assert_match(/data-controller="[^"]*ruby-ui--data-table-column-visibility/, out) - assert_match(/data-column-key="email"/, out) - assert_match(/data-column-key="salary"/, out) - assert_match(/Email/, out) - assert_match(/Salary/, out) - end -end -``` - -- [ ] **Step 19.2: Run, expect fail** - -- [ ] **Step 19.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_column_toggle.rb -# 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" - helpers.lucide_icon("chevron-down", class: "w-4 h-4 ml-1") - 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 -``` - -- [ ] **Step 19.4: Run, expect pass** - -- [ ] **Step 19.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_column_toggle.rb \ - test/components/ruby_ui/data_table/data_table_column_toggle_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTableColumnToggle - -Reuses DropdownMenu; own Stimulus controller for column visibility. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 20: Component — `DataTablePagination` - -**Files:** -- Create: `app/components/ruby_ui/data_table/data_table_pagination.rb` -- Create: `test/components/ruby_ui/data_table/data_table_pagination_test.rb` - -- [ ] **Step 20.1: Failing test** - -```ruby -require "test_helper" - -class RubyUI::DataTablePaginationTest < ActiveSupport::TestCase - include Phlex::Testing::Rails::ViewHelper - - test "accepts manual keyword shortcut" do - out = render RubyUI::DataTablePagination.new(page: 2, per_page: 10, total_count: 25, path: "/x", query: {}) - assert_match(/href="\/x\?page=1"/, out) # Previous - assert_match(/href="\/x\?page=3"/, out) # Next/numbered 3 - end - - test "accepts pagy keyword shortcut (duck-typed double)" do - pagy_double = Data.define(:page, :pages, :count, :items).new(page: 1, pages: 2, count: 15, items: 10) - out = render RubyUI::DataTablePagination.new(pagy: pagy_double, path: "/x", query: {}) - assert_match(/href="\/x\?page=2"/, out) - end - - test "with: accepts custom adapter" do - custom = Data.define(:current_page, :total_pages, :total_count, :per_page).new(1, 3, 20, 10) - out = render RubyUI::DataTablePagination.new(with: custom, path: "/x", query: {}) - assert_match(/href="\/x\?page=2"/, out) - end - - test "renames page param" do - out = render RubyUI::DataTablePagination.new(page: 1, per_page: 10, total_count: 30, path: "/x", query: {}, page_param: "p") - assert_match(/p=2/, out) - end - - test "raises when no adapter and no manual args" do - assert_raises(ArgumentError) { RubyUI::DataTablePagination.new(path: "/x", query: {}) } - end -end -``` - -- [ ] **Step 20.2: Run, expect fail** - -- [ ] **Step 20.3: Implement** - -```ruby -# app/components/ruby_ui/data_table/data_table_pagination.rb -# frozen_string_literal: true - -module RubyUI - class DataTablePagination < Base - WINDOW = 1 - - def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, **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) - super(**attrs) - end - - def view_template - render RubyUI::Pagination.new(**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::DataTablePagination::Pagy.new(pagy) if pagy - return RubyUI::DataTablePagination::Kaminari.new(kaminari) if kaminari - if page && per_page && total_count - return RubyUI::DataTablePagination::Manual.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 - end - - def total - @adapter.total_pages - end - - def page_href(p) - qs = @query.merge(@page_param => p.to_s).to_query - qs.empty? ? @path : "#{@path}?#{qs}" - end - - def prev_item - li do - if current <= 1 - span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Previous" } - else - render RubyUI::PaginationItem.new(href: page_href(current - 1)) { plain "Previous" } - end - end - end - - def next_item - li do - if current >= total - span(class: "opacity-50 pointer-events-none px-3 h-9 inline-flex items-center text-sm") { plain "Next" } - else - render RubyUI::PaginationItem.new(href: page_href(current + 1)) { plain "Next" } - end - 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 -``` - -- [ ] **Step 20.4: Run, expect pass** - -- [ ] **Step 20.5: Commit** - -```bash -git add app/components/ruby_ui/data_table/data_table_pagination.rb \ - test/components/ruby_ui/data_table/data_table_pagination_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add DataTablePagination with adapter support - -Accepts with: / pagy: / kaminari: / manual args. Numbered pagination -reuses existing Pagination primitives. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 21: Stimulus — `data_table_controller.js` - -**Files:** -- Create: `app/javascript/controllers/ruby_ui/data_table_controller.js` - -- [ ] **Step 21.1: Create file** - -```javascript -// 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(); - } - - 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); - } - - if (this.hasSelectionSummaryTarget) { - this.selectionSummaryTarget.classList.toggle("hidden", selected > 0); - } - } -} -``` - -- [ ] **Step 21.2: Commit** - -```bash -git add app/javascript/controllers/ruby_ui/data_table_controller.js -git commit -m "$(cat <<'EOF' -feat(data_table): add data-table Stimulus controller - -Handles select-all, per-row toggle, selection summary text, bulk -actions visibility. State resets on Turbo Frame swap by design. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 22: Stimulus — `data_table_column_visibility_controller.js` - -**Files:** -- Create: `app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js` - -- [ ] **Step 22.1: Create file** - -```javascript -// 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)); - } -} -``` - -- [ ] **Step 22.2: Commit** - -```bash -git add app/javascript/controllers/ruby_ui/data_table_column_visibility_controller.js -git commit -m "$(cat <<'EOF' -feat(data_table): add data-table-column-visibility Stimulus controller - -Scoped DOM query via closest() to the sibling DataTable root. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 23: Register Stimulus controllers - -**Files:** -- Modify: `app/javascript/controllers/index.js` - -- [ ] **Step 23.1: Append registrations** - -Add, grouped with the other ruby-ui imports (alphabetical insertion between `Dialog` and `DropdownMenu`): - -```javascript -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) -``` - -- [ ] **Step 23.2: Rebuild JS** - -```bash -dx pnpm build -``` -Expected: clean build, no errors. - -- [ ] **Step 23.3: Commit** - -```bash -git add app/javascript/controllers/index.js -git commit -m "$(cat <<'EOF' -feat(data_table): register data-table Stimulus controllers - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 24: Demo controller — `index` action - -**Files:** -- Create: `app/controllers/docs/data_table_demo_controller.rb` -- Create: `test/controllers/docs/data_table_demo_controller_test.rb` - -- [ ] **Step 24.1: Failing test** - -```ruby -# test/controllers/docs/data_table_demo_controller_test.rb -require "test_helper" - -class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest - test "GET index returns 200" do - get docs_data_table_demo_url - assert_response :success - end - - test "GET index with ?search= filters employees" do - get docs_data_table_demo_url(search: "alice") - assert_response :success - assert_match(/Alice Johnson/, response.body) - assert_no_match(/Bob Smith/, response.body) - end - - test "GET index with ?sort=name&direction=desc sorts" do - get docs_data_table_demo_url(sort: "name", direction: "desc", per_page: 100) - # Violet comes before Alice when reverse-alphabetical - alice_at = response.body.index("Alice Johnson") - violet_at = response.body.index("Violet Fisher") - assert violet_at < alice_at, "Violet should appear before Alice when sorted desc" - end - - test "GET index with ?sort=salary sorts numerically" do - get docs_data_table_demo_url(sort: "salary", direction: "asc", per_page: 5) - # Smallest salary should be first on page 1 - # Grace Lee (71_000) is among the lowest - assert_match(/Grace Lee/, response.body) - end - - test "GET index paginates" do - get docs_data_table_demo_url(page: 2, per_page: 5) - assert_response :success - end - - test "POST bulk_delete with ids[] redirects + flashes" do - post docs_data_table_demo_bulk_delete_url, params: {ids: ["1", "2"]} - assert_redirected_to docs_data_table_demo_path - follow_redirect! - assert_match(/Would delete: 1, 2/, response.body) - end - - test "POST bulk_export with ids[] redirects + flashes" do - post docs_data_table_demo_bulk_export_url, params: {ids: ["3"]} - assert_redirected_to docs_data_table_demo_path - end -end -``` - -- [ ] **Step 24.2: Run, expect fail** - -```bash -dx bin/rails test test/controllers/docs/data_table_demo_controller_test.rb -``` - -- [ ] **Step 24.3: Implement controller** - -```ruby -# app/controllers/docs/data_table_demo_controller.rb -# 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 - begin - 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" - rescue NoMethodError - # Unknown column — ignore sort - 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 -``` - -- [ ] **Step 24.4: Run test — some will still fail until Demo view exists** - -The bulk_delete/bulk_export tests will pass after this step. Index tests require the view (Task 25). Expected: index-tied tests fail with view missing error; bulk action tests pass. - -- [ ] **Step 24.5: Commit** - -```bash -git add app/controllers/docs/data_table_demo_controller.rb \ - test/controllers/docs/data_table_demo_controller_test.rb -git commit -m "$(cat <<'EOF' -feat(data_table): add demo controller with search/sort/paginate + bulk stubs - -Index tests will go green once the demo view is wired in Task 25. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 25: Demo view — complete example - -**Files:** -- Create: `app/views/docs/data_table_demo/index.rb` - -- [ ] **Step 25.1: Create file** - -```ruby -# app/views/docs/data_table_demo/index.rb -# frozen_string_literal: true - -class Views::Docs::DataTableDemo::Index < Views::Base - FRAME_ID = "employees_list" - - 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..." - ) - 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 - ) - end - end - - 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) } - TableCell(class: "font-medium") { plain e.name } - TableCell(class: "text-muted-foreground", 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 format_currency(e.salary) } - TableCell(class: "w-12 text-right") { row_actions(e) } - end - end - end - end - end - end - - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @employees.size) - DataTableBulkActions do - Button(type: "submit", formaction: docs_data_table_demo_bulk_delete_path, formmethod: "post", variant: :destructive, size: :sm) { "Delete" } - Button(type: "submit", formaction: docs_data_table_demo_bulk_export_path, formmethod: "post", variant: :outline, size: :sm) { "Export" } - end - end - - DataTablePagination( - page: @page, - per_page: @per_page, - total_count: @total_count, - path: docs_data_table_demo_path, - query: preserved_query - ) - end - end - - def row_actions(employee) - DropdownMenu do - DropdownMenuTrigger do - Button(type: "button", variant: :ghost, size: :icon, aria_label: "Open menu") do - helpers.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 - - def format_currency(n) - "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" - end -end -``` - -- [ ] **Step 25.2: Run demo controller tests** - -```bash -dx bin/rails test test/controllers/docs/data_table_demo_controller_test.rb -``` -Expected: all 7 tests pass. - -- [ ] **Step 25.3: Commit** - -```bash -git add app/views/docs/data_table_demo/index.rb -git commit -m "$(cat <<'EOF' -feat(data_table): wire complete demo view - -Uses DataTable + existing Table primitives + Stimulus controllers. -All integration tests now pass. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 26: Docs page — scaffold with 6 examples - -**Files:** -- Create: `app/views/docs/data_table.rb` - -- [ ] **Step 26.1: Create file** - -```ruby -# app/views/docs/data_table.rb -# frozen_string_literal: true - -class Views::Docs::DataTable < Views::Base - def view_template - component = "DataTable" - - div(class: "mx-auto w-full py-10 space-y-10") do - render Docs::Header.new( - title: component, - 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." - ) - - # ── Example 1: Complete demo (primary) ───────────────────────────────── - 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_path, context: self) do - <<~'RUBY' - DataTable(id: "employees_list") do - DataTableToolbar do - DataTableSearch(path: docs_data_table_demo_path, frame_id: "employees_list", value: @search) - DataTableColumnToggle(columns: [ - {key: :email, label: "Email"}, - {key: :department, label: "Department"} - ]) - DataTablePerPageSelect(path: docs_data_table_demo_path, value: @per_page) - end - - 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 - - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @employees.size) - DataTableBulkActions do - Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } - end - end - - DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, path: docs_data_table_demo_path) - end - RUBY - end - - # ── Example 2: Basic static table ───────────────────────────────────── - Heading(level: 2) { "Basic static table" } - p(class: "-mt-6") { "Composition only — no interactivity." } - - render Docs::VisualCodeExample.new(title: "Basic static table", context: self) do - <<~'RUBY' - DataTable(id: "basic") do - Table do - TableHeader do - TableRow do - TableHead { "Name" } - TableHead { "Role" } - end - end - TableBody do - TableRow do - TableCell { "Alice" } - TableCell { "Engineer" } - end - TableRow do - TableCell { "Bob" } - TableCell { "Designer" } - end - end - end - end - RUBY - end - - # ── Example 3: Server-driven (search + sort + pagination) ───────────── - 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) - 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 - - # ── Example 4: Selection + bulk actions ─────────────────────────────── - Heading(level: 2) { "Selection + bulk actions" } - p(class: "-mt-6") { "Form-first: row checkboxes are , bulk buttons submit via formaction." } - - render Docs::VisualCodeExample.new(title: "Selection + bulk actions", context: self) do - <<~'RUBY' - DataTable(id: "selection") 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 - - DataTableSelectionBar do - DataTableSelectionSummary(total_on_page: @rows.size) - DataTableBulkActions do - Button(type: "submit", formaction: bulk_delete_path, formmethod: "post", variant: :destructive) { "Delete" } - Button(type: "submit", formaction: bulk_export_path, formmethod: "post", variant: :outline) { "Export" } - end - end - end - RUBY - end - - # ── Example 5: Column visibility ────────────────────────────────────── - Heading(level: 2) { "Column visibility" } - p(class: "-mt-6") { "Client-side toggle. Hidden columns get `hidden` class via data-column attribute matching." } - - 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 - - # ── Example 6: Custom cell renderers ────────────────────────────────── - 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 format_currency(n) - "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" - end - - 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 format_currency(r.salary) } - end - end - end - end - end - RUBY - end - end - end -end -``` - -- [ ] **Step 26.2: Verify the docs page renders** - -```bash -dx bin/rails runner "puts Views::Docs::DataTable.new.call[0..200]" -``` -Expected: HTML string starting with ` -EOF -)" -``` - ---- - -## Task 27: Full test suite + lint - -- [ ] **Step 27.1: Run full test suite** - -```bash -dx bin/rails test -``` -Expected: all tests pass; no new failures vs baseline (Task 1 step 1.4). - -- [ ] **Step 27.2: Run StandardRB lint (if present in web)** - -Check: `grep standard Gemfile` — if present, run: -```bash -dx bundle exec standardrb -``` -Expected: clean. Fix any violations; commit per file. - -- [ ] **Step 27.3: If lint fixes needed, commit** - -```bash -git add -A -git commit -m "$(cat <<'EOF' -style(data_table): fix lint - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 28: Manual smoke test in browser - -- [ ] **Step 28.1: Ensure Rails server running** - -```bash -docker exec rubyui-web-rails-app-1 bash -c 'pgrep -fa "rails server" || true' -``` -If not running: -```bash -dx "nohup bin/rails server -b 0.0.0.0 -p 3000 > /tmp/rails.log 2>&1 &" -sleep 3 -dx 'curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/docs/data_table' -``` -Expected: `200`. - -- [ ] **Step 28.2: Visit in browser** - -Host: `http://localhost:3001/docs/data_table` - -Verify (checklist for the implementing agent to report back on): - -- [ ] Docs page renders with 6 example sections -- [ ] Example 1 iframe loads the complete demo -- [ ] Search input filters as form is submitted -- [ ] Sort header cycles asc/desc/none with correct icon -- [ ] Per-page select auto-submits -- [ ] Numbered pagination links work -- [ ] Row checkbox toggles → summary text updates -- [ ] Select-all → indeterminate + full state correct -- [ ] Bulk actions bar appears only when selection > 0 -- [ ] Clicking "Delete" submits form → redirects with flash -- [ ] Column toggle hides/shows matching columns -- [ ] Row actions dropdown opens - -Report results in the PR body. - -- [ ] **Step 28.3: Push branch** - -```bash -git push -u origin da/datatable-hotwire -``` - -- [ ] **Step 28.4: Open PR** (only with explicit user approval) - -```bash -gh pr create --title "feat(data_table): Hotwire-first DataTable component family" --body "$(cat <<'EOF' -## Summary -- Adds `DataTable` and 11 supporting components at `app/components/ruby_ui/data_table/` -- Form-first bulk actions (row checkboxes are ``) -- Two small Stimulus controllers (selection coordinator + column visibility) -- Three pagination adapters: Manual, Pagy, Kaminari (duck-typed, no gem deps) -- Six documentation examples at `/docs/data_table` -- Demo controller + view at `/docs/data_table_demo` powering the primary example -- 12 component render tests + 3 adapter tests + controller integration test - -## Test plan -- [ ] All tests green: `dx bin/rails test` -- [ ] Lint clean: `dx bundle exec standardrb` -- [ ] Manual smoke — see Task 28.2 checklist in `docs/superpowers/plans/2026-04-24-datatable-hotwire-plan.md` -- [ ] Spec: `docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md` - -🤖 Generated with [Claude Code](https://claude.com/claude-code) -EOF -)" -``` - ---- - -## Self-review checklist (run before dispatching tasks) - -- [ ] All 12 components have tasks (Tasks 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20) ✔ -- [ ] Both Stimulus controllers have tasks (21, 22) ✔ -- [ ] Three pagination adapters have tasks (6, 7, 8) ✔ -- [ ] Demo controller + tests: Task 24 ✔ -- [ ] Demo view: Task 25 ✔ -- [ ] Docs view (6 examples): Task 26 ✔ -- [ ] Routes: Task 2 ✔; DocsController action: Task 3 ✔; menu: Task 4 ✔ -- [ ] Controllers index.js registration: Task 23 ✔ -- [ ] Lint + full test run: Task 27 ✔ -- [ ] Browser smoke: Task 28 ✔ -- [ ] Namespacing consistent: `RubyUI::DataTable` = component, `RubyUI::DataTablePagination` = adapters module ✔ -- [ ] Each task has a commit step ✔ -- [ ] No placeholders ("TODO", "implement later") — all code blocks present ✔ -- [ ] Subagent model enforced in header ✔ diff --git a/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md b/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md deleted file mode 100644 index e1d009be..00000000 --- a/docs/superpowers/specs/2026-04-24-datatable-hotwire-design.md +++ /dev/null @@ -1,458 +0,0 @@ -# DataTable — Hotwire-first Spec - -**Date:** 2026-04-24 -**Branch:** `da/datatable-hotwire` -**Author:** Djalma Araujo -**Status:** Design (awaiting user sign-off) - -## Purpose - -Add a `DataTable` component family to the Ruby UI website. Every interaction -(sort, search, pagination, per-page, filter) is a plain Rails request, answered -with HTML, swapped via ``. No external JS library. Client-only -JavaScript is Stimulus, kept minimal and scoped to ephemeral UI state -(selection, column visibility, dropdown open). Row selection uses a -**form-first** pattern: row checkboxes are `` inside a real -``, so bulk actions submit natively without custom fetch logic. - -Composition mirrors shadcn's data-table demo, but each primitive maps to an -existing Ruby UI `Table*` component rather than a namespaced duplicate. - -## Scope - -In scope: - -- 12 new components under `app/components/ruby_ui/data_table/` -- 2 Stimulus controllers -- 3 pagination adapters (manual, pagy, kaminari) -- 6 documentation examples (first = complete demo) -- Component render tests + pagination adapter tests + demo controller - integration test -- Docs demo controller with in-memory data filtering/sorting/pagination -- Stub bulk-action endpoints (flash + redirect, no persistence) - -Out of scope: - -- Multi-column sort -- Column-level filters (only global search) -- Server-persisted selection (selection is per-page, client-only by design) -- Keyboard navigation beyond native ``/`` behavior -- CSV/export implementation (demo stub only) -- Sticky header -- Density toggle -- System tests (Selenium is incompatible with the current devcontainer) - -## Design decisions - -### Reuse over duplication - -Reuses the existing `Table`, `TableHeader`, `TableBody`, `TableRow`, -`TableHead`, `TableCell`, `TableFooter`, `TableCaption` components directly. -Users compose the table with primitives they already know from the rest of the -docs site. - -### Form-first selection - -The `` contents sit inside a `` element. Row checkboxes are -plain ``. Bulk action -buttons submit the form with `type="submit" formaction="/foo" -formmethod="post"` so each action routes independently. Rails receives -`params[:ids]` natively. No custom JavaScript in the submit path. The server -owns the truth. - -### Selection ephemerality - -Row selection and column visibility are DOM-local state. A Turbo Frame swap -(sort/search/page) destroys and re-renders the frame, which naturally clears -both. This is by design and documented as intentional. - -### Query param flexibility - -Every component that emits or reads a query param accepts a prop for the -param name, with a sensible default: - -| Component | Prop | Default | -|---|---|---| -| `DataTableSearch` | `name` | `"search"` | -| `DataTableSortHead` | `sort_param`, `direction_param` | `"sort"`, `"direction"` | -| `DataTablePerPageSelect` | `name` | `"per_page"` | -| `DataTablePagination` | `page_param` | `"page"` | - -Users can map to existing conventions (`q`, `sort_by`, `sort_dir`, `p`, -`size`) without touching internals. There is no global config. Each -component declares its own param name. - -### Pagination adapters - -`DataTablePagination` accepts a `with:` argument pointing to any object that -implements: - -```ruby -current_page -> Integer # 1-based -total_pages -> Integer -total_count -> Integer | nil -``` - -Three built-in adapters under `RubyUI::DataTable::Pagination::*`: - -- `Manual.new(page:, per_page:, total_count:)` — arithmetic, no gem. -- `Pagy.new(pagy)` — reads `.page`, `.pages`, `.count`. -- `Kaminari.new(collection)` — reads `.current_page`, `.total_pages`, - `.total_count`. - -Keyword shortcuts auto-wrap: - -- `DataTablePagination(pagy: @pagy, ...)` -- `DataTablePagination(kaminari: @records, ...)` -- `DataTablePagination(page:, per_page:, total_count:, ...)` (manual) - -Custom adapters: user writes a class with three methods, passes via `with:`. -No monkey-patching. - -### Defaults over required props - -Only `id:` on the root `DataTable`, `column_key:`/`label:` on `SortHead`, -`value:` on `RowCheckbox`, `columns:` on `ColumnToggle`, and a pagination -source are strictly required. All other props (`path:`, `frame_id:`, `query:`, -`value:`, `sort:`, `direction:`, `name:`, `param:`, `placeholder:`, -`options:`) default sensibly: - -- `path:` — `helpers.url_for(only_path: true)` (current path) -- `frame_id:` — omitted; Turbo auto-scopes form submissions to the enclosing - `` -- `query:` — `request.query_parameters` (preserves other params) -- `value:` / `sort:` / `direction:` — read from `params[name_or_param]` - -Minimum usage emerges: - -```ruby -DataTable(id: "employees") do - DataTableToolbar do - DataTableSearch - DataTableColumnToggle(columns: TOGGLABLE) - DataTablePerPageSelect - end - Table do - TableHeader do - TableRow do - TableHead { DataTableSelectAllCheckbox } - DataTableSortHead(column_key: :name, label: "Name") - TableHead { "Status" } - end - end - TableBody do - @employees.each do |e| - TableRow do - TableCell { DataTableRowCheckbox(value: e.id) } - TableCell { e.name } - TableCell { Badge { e.status } } - end - end - end - end - DataTableSelectionBar do - DataTableSelectionSummary - DataTableBulkActions do - Button(type: "submit", formaction: "/bulk_delete", formmethod: "post") { "Delete" } - end - end - DataTablePagination(pagy: @pagy) -end -``` - -### Icons as files, never inline - -All SVG icons come from the `lucide-rails` helper (`lucide_icon`) which -renders asset-pipeline SVG files. Mapping: - -| Use | Icon | -|---|---| -| Sort asc | `:chevron_up` | -| Sort desc | `:chevron_down` | -| Sort unsorted | `:chevrons_up_down` | -| Columns button caret | `:chevron_down` | -| Row actions trigger | `:ellipsis_vertical` | -| Search decoration | `:search` | - -If an icon is not in lucide, it is committed as a file under -`app/assets/images/ruby_ui/data_table/.svg` and rendered via the Rails -asset helper — never inlined in Ruby. - -## Components - -Path: `app/components/ruby_ui/data_table/` - -``` -data_table.rb root — + + controller -data_table_toolbar.rb flex layout slot -data_table_search.rb with Input -data_table_per_page_select.rb with NativeSelect (auto-submit) -data_table_column_toggle.rb DropdownMenu + Checkbox list -data_table_sort_head.rb wraps TableHead, renders -data_table_row_checkbox.rb wraps Checkbox, -data_table_select_all_checkbox.rb wraps Checkbox, Stimulus select-all target -data_table_selection_bar.rb container for summary + bulk actions -data_table_selection_summary.rb "X of N selected" text -data_table_bulk_actions.rb hidden-by-default slot for submit buttons -data_table_pagination.rb numbered pagination, adapter-backed - -pagination/manual.rb arithmetic adapter -pagination/pagy.rb Pagy duck-type adapter -pagination/kaminari.rb Kaminari duck-type adapter -``` - -### API surface (condensed) - -```ruby -DataTable(id:, path: nil, **attrs) -DataTableToolbar(**attrs) -DataTableSearch(name: "search", path: nil, frame_id: nil, value: nil, placeholder: "Search...", **attrs) -DataTablePerPageSelect(name: "per_page", path: nil, frame_id: nil, value: nil, options: [5, 10, 25, 50], **attrs) -DataTableColumnToggle(columns:, **attrs) -DataTableSortHead(column_key:, label:, sort_param: "sort", direction_param: "direction", - sort: nil, direction: nil, path: nil, query: nil, **attrs) -DataTableRowCheckbox(value:, name: "ids[]", **attrs) -DataTableSelectAllCheckbox(**attrs) -DataTableSelectionBar(**attrs) -DataTableSelectionSummary(total_on_page: nil, **attrs) -DataTableBulkActions(**attrs) -DataTablePagination(with: nil, pagy: nil, kaminari: nil, - page: nil, per_page: nil, total_count: nil, - page_param: "page", path: nil, query: nil, **attrs) -``` - -## Stimulus controllers - -Path: `app/javascript/controllers/ruby_ui/` - -### `data_table_controller.js` - -Attached to the root `DataTable` element. Targets: - -- `selectAll` — the select-all checkbox -- `rowCheckbox` — each row checkbox (many) -- `selectionSummary` — the "X of N selected" text node -- `selectionBar` — outer container (holds summary + bulk actions) -- `bulkActions` — the bulk actions slot - -Actions: - -- `toggleAll` — check/uncheck every `rowCheckbox`, then `updateState()` -- `toggleRow` — `updateState()` - -`updateState()`: - -- `count = selected row checkboxes` -- `total = all row checkboxes` -- Set summary text to `"count of total row(s) selected"` -- `selectAll.checked = count === total && total > 0` -- `selectAll.indeterminate = count > 0 && count < total` -- Toggle `hidden` on `summary` vs `bulkActions`: summary visible when - `count === 0`, bulk actions visible when `count > 0` - -`connect()` calls `updateState()` once — matches server-rendered page load. - -### `data_table_column_visibility_controller.js` - -Attached to the `DataTableColumnToggle` root. Targets: none required (we read -`event.target.dataset.columnKey`). - -Action: - -- `toggle(event)` — compute `key`/`visible`, then find the nearest ancestor - with `[data-controller~="ruby-ui--data-table"]` and - `querySelectorAll('[data-column="KEY"]')`, adding/removing `hidden`. - -Dropdown open/close is delegated to the existing `ruby-ui--dropdown-menu` -controller. No re-implementation. - -### Why only two controllers - -Explored and rejected: - -- `data_table_search_controller` — debounced auto-submit. Trivial, nice to - have, not necessary. Form submits on explicit action. -- `data_table_per_page_controller` — auto-submit select on change. Doable - with `onchange="this.form.requestSubmit()"` or a generic - `form-submit-on-change` utility controller. Single-purpose per-table - controller is over-engineering. -- `data_table_sort_controller` — not needed; sort heads are plain ``. -- `data_table_selection_controller` split from root — both pieces read the - same counter. Splitting spreads the same computation. -- `data_table_bulk_actions_controller` for confirm — Rails provides - `data-turbo-confirm=`. -- `data_table_column_toggle_menu_controller` — already delegated to - `ruby-ui--dropdown-menu`. - -## Server / Turbo wiring - -### Request flow - -| Action | Request | Response | -|---|---|---| -| Type + submit search | `GET /demo?search=foo` (form `data-turbo-frame`) | Frame swap | -| Click sort header | `GET /demo?sort=name&direction=asc` (link in frame) | Frame swap | -| Change per-page | `GET /demo?per_page=25` (auto-submit select form) | Frame swap | -| Click page N | `GET /demo?page=3` (link in frame) | Frame swap | -| Toggle row checkbox | none | client-only `updateState()` | -| Click "Delete" | `POST /bulk_delete` (form with `ids[]`) | Redirect or Turbo Stream | - -### Frame anatomy - -```html - -
- -
- -
-
-
- - -
- - …
-
-
-
0 of N selected
- -
-
-
- - -
-
-``` - -Nested `
` is invalid HTML. Search/per-page forms are **siblings** of the -main bulk form, not nested. Visually grouped in the toolbar. - -### Demo controller - -`Docs::DataTableDemoController`: - -- `index` — reads `search`, `sort`, `direction`, `page`, `per_page`; filters - in-memory `EMPLOYEES`; paginates; renders - `Views::Docs::DataTableDemo::Index`. Clamps `per_page` to `1..100`, clamps - `page` to valid range. -- `bulk_delete` — flashes `"Would delete: #{params[:ids].join(', ')}"`, - redirects to `docs_data_table_demo_path`. -- `bulk_export` — same pattern, flashes export intent. - -## File layout - -New: - -``` -app/components/ruby_ui/data_table/ (12 components + 3 adapters) -app/javascript/controllers/ruby_ui/ - data_table_controller.js - data_table_column_visibility_controller.js -app/controllers/docs/ - data_table_demo_controller.rb - data_table_demo_data.rb (100-row EMPLOYEES module) -app/views/docs/ - data_table.rb (6 examples page) - data_table_demo/index.rb (complete demo view) -test/components/ruby_ui/data_table/ (12 + 3 files) -test/controllers/docs/ - data_table_demo_controller_test.rb -docs/superpowers/specs/ - 2026-04-24-datatable-hotwire-design.md (this file) -docs/superpowers/plans/ - 2026-04-24-datatable-hotwire-plan.md (follow-up) -``` - -Modified: - -- `app/javascript/controllers/index.js` — register 2 controllers -- `config/routes.rb` — add docs + demo routes -- `app/controllers/docs_controller.rb` — `#data_table` action -- `app/components/shared/menu.rb` — sidebar entry - -## Testing - -Per user decision (Q10 = B): component render tests + controller integration. -No JS unit tests, no Capybara system tests (devcontainer incompatibility). - -**Component tests (one per file):** - -- Render correct tag -- Default classes merged -- Required props raise `ArgumentError` when missing -- Optional props overridden correctly -- Stimulus targets / data-attributes present where expected -- Param-name overrides reflected in emitted attributes/URLs - -**Pagination adapter tests (one per adapter):** - -- Normalizes to `current_page`, `total_pages`, `total_count` -- Duck-typing works against doubles (Pagy, Kaminari) - -**Controller integration test:** - -- `GET /docs/data_table_demo` returns 200 -- `?search=alice` filters -- `?sort=name&direction=desc` sorts correctly (and numeric sort for salary) -- `?page=3&per_page=5` paginates -- `POST /docs/data_table_demo/bulk_delete` accepts `ids[]`, redirects, flashes - -## Documentation examples - -`app/views/docs/data_table.rb` renders six `Docs::VisualCodeExample` sections: - -1. **Complete demo** (primary) — all features wired -2. **Basic static table** — composition only -3. **Server-driven** — search + sort + numbered pagination -4. **Selection + bulk actions** — form-first submission pattern -5. **Column visibility** — column toggle in isolation -6. **Custom cell renderers** — badge/date/currency helpers - -## Branch / commit workflow - -Branch: `da/datatable-hotwire` (from `main`). - -Commit incrementally — after each meaningful unit, not batched at end: - -1. Spec -2. Plan -3. Each component file with its test (one commit per component) -4. Each Stimulus controller -5. Demo data module -6. Demo controller + tests -7. Routes + menu entry + docs_controller action -8. Docs page (grouped if small, split if large) -9. Manual smoke of the docs page in devcontainer — no commit needed, capture - notes in PR - -No background-subprocess usage. Work directly per superpowers standards. - -## Risks / trade-offs - -- **Selection clears on every Turbo Frame swap** — documented intent. Users - who need persistent selection must implement their own pattern. -- **No JS means no client-side sort** — every sort is a server roundtrip. - For small datasets (< 100 rows) this may feel slower than instant local - sort. Acceptable for a server-first philosophy; cacheable at the Rails - layer. -- **Pagy and Kaminari not in Gemfile** — adapter classes are thin wrappers - that work by duck-typing against the adapter's object. Users install the - gem they choose; adapters do not add dependencies. -- **Nested form constraint** — search/per-page forms are siblings, not - nested. Any visual "toolbar" is a flex container, not a form. - -## Success criteria - -- All six docs examples render without errors at `/docs/data_table`. -- Complete demo at `/docs/data_table_demo` (example 1) performs: - search, sort, per-page, page nav (numbered), select-all, per-row checkbox, - bulk action form submission, column toggle, row actions dropdown — each via - the documented request pattern. -- `bundle exec rake test` passes (new tests green, existing ones unaffected). -- `bundle exec rake standard` passes (no lint regressions). -- Manual devcontainer smoke confirms all interactions work at - `http://localhost:3001/docs/data_table`. From ca2244058638e7109c20b789c2977efecc66a03b Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:11:08 -0300 Subject: [PATCH 69/81] chore: gitignore /docs/superpowers (local design artifacts) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 401f00c6..3f5c8c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ yarn-error.log # Pnpm .pnpm-store + +# Local design artifacts (not checked in) +/docs/superpowers/ From 3ecdd360f213645cbb8e5fbcd357e5cbe2af718a Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:11:09 -0300 Subject: [PATCH 70/81] docs(data_table): remove basic static table example --- app/views/docs/data_table.rb | 41 ++++++------------------------------ 1 file changed, 6 insertions(+), 35 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index f291830f..e5c11579 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true class Views::Docs::DataTable < Views::Base - # Stub data used by code-snippet previews (examples 3-6) + # Stub data used by code-snippet previews (examples 2-6) Row = Struct.new(:id, :name, :email, :salary, :status, keyword_init: true) def view_template @@ -76,36 +76,7 @@ def view_template RUBY end - # ── Example 2: Basic static table ───────────────────────────────────── - Heading(level: 2) { "Basic static table" } - p(class: "-mt-6") { "Composition only — no interactivity." } - - render Docs::VisualCodeExample.new(title: "Basic static table", context: self) do - <<~RUBY - DataTable(id: "basic") do - Table do - TableHeader do - TableRow do - TableHead { "Name" } - TableHead { "Role" } - end - end - TableBody do - TableRow do - TableCell { "Alice" } - TableCell { "Engineer" } - end - TableRow do - TableCell { "Bob" } - TableCell { "Designer" } - end - end - end - end - RUBY - end - - # ── Example 3: Server-driven (search + sort + pagination) ───────────── + # ── Example 2: Server-driven (search + sort + pagination) ───────────── Heading(level: 2) { "Server-driven" } p(class: "-mt-6") { "Turbo Frame GET on each sort/search/page. No client-only state." } @@ -133,7 +104,7 @@ def view_template RUBY end - # ── Example 4: Selection + bulk actions ─────────────────────────────── + # ── Example 3: Selection + bulk actions ─────────────────────────────── 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." } @@ -227,7 +198,7 @@ def bulk_export end RUBY - # ── Example 5: Column visibility ────────────────────────────────────── + # ── Example 4: Column visibility ────────────────────────────────────── 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." } @@ -264,7 +235,7 @@ def bulk_export RUBY end - # ── Example 6: Custom cell renderers ────────────────────────────────── + # ── Example 5: Custom cell renderers ────────────────────────────────── Heading(level: 2) { "Custom cell renderers" } p(class: "-mt-6") { "Plain Ruby helpers for badge/date/currency — the gem does not ship renderers." } @@ -302,7 +273,7 @@ def status_badge(status) RUBY end - # ── Example 7: Expandable rows ──────────────────────────────────────── + # ── Example 6: Expandable rows ──────────────────────────────────────── 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." } From aaf5cab6c14fe0e9cdbca674dfe92324f4f64149 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:12:24 -0300 Subject: [PATCH 71/81] docs(data_table): explain pagination adapters (Manual/Pagy/Kaminari/custom) --- app/views/docs/data_table.rb | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index e5c11579..34f98cba 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -310,6 +310,54 @@ def status_badge(status) end RUBY end + + # ── Pagination adapters ─────────────────────────────────────────────── + 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 From 9e83f836658ea0dec8607b0fc920b975a10b6262 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:17:36 -0300 Subject: [PATCH 72/81] refactor(data_table): ivar @id instead of @frame_id --- app/components/ruby_ui/data_table/data_table.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table.rb b/app/components/ruby_ui/data_table/data_table.rb index 88885eed..8a64aed2 100644 --- a/app/components/ruby_ui/data_table/data_table.rb +++ b/app/components/ruby_ui/data_table/data_table.rb @@ -5,12 +5,12 @@ class DataTable < Base register_element :turbo_frame, tag: "turbo-frame" def initialize(id:, **attrs) - @frame_id = id + @id = id super(**attrs) end def view_template(&block) - turbo_frame(id: @frame_id, target: "_top") do + turbo_frame(id: @id, target: "_top") do div(**attrs) do yield if block end From 6fc14d4d6ac6959838da43c7f87e8a3ccca7dd7c Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:18:19 -0300 Subject: [PATCH 73/81] refactor(data_table): configurable pagination window via kwarg --- .../ruby_ui/data_table/data_table_pagination.rb | 11 +++++------ .../ruby_ui/data_table/data_table_pagination_test.rb | 9 +++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/components/ruby_ui/data_table/data_table_pagination.rb b/app/components/ruby_ui/data_table/data_table_pagination.rb index 4f91e2fa..ab5be1c0 100644 --- a/app/components/ruby_ui/data_table/data_table_pagination.rb +++ b/app/components/ruby_ui/data_table/data_table_pagination.rb @@ -2,13 +2,12 @@ module RubyUI class DataTablePagination < Base - WINDOW = 1 - - def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, **attrs) + def initialize(with: nil, pagy: nil, kaminari: nil, page: nil, per_page: nil, total_count: nil, page_param: "page", path: "", query: {}, window: 1, **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 super(**attrs) end @@ -75,9 +74,9 @@ def number_items 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 << :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 diff --git a/test/components/ruby_ui/data_table/data_table_pagination_test.rb b/test/components/ruby_ui/data_table/data_table_pagination_test.rb index 9b7365f0..66e5c22f 100644 --- a/test/components/ruby_ui/data_table/data_table_pagination_test.rb +++ b/test/components/ruby_ui/data_table/data_table_pagination_test.rb @@ -27,4 +27,13 @@ class RubyUI::DataTablePaginationTest < ActiveSupport::TestCase test "raises when no adapter and no manual args" do assert_raises(ArgumentError) { RubyUI::DataTablePagination.new(path: "/x", query: {}) } end + + test "window: kwarg widens the numbered page range" do + # 20 pages, on page 10, window:1 → [1, :gap, 9, 10, 11, :gap, 20] + # window:2 → [1, :gap, 8, 9, 10, 11, 12, :gap, 20] + out_narrow = RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 1).call + out_wide = RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 2).call + assert_no_match(/page=8/, out_narrow) + assert_match(/page=8/, out_wide) + end end From de85b6e452006980aee99d5402b359d5d8157058 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:19:11 -0300 Subject: [PATCH 74/81] test(data_table): cover DataTableForm method: kwarg --- .../ruby_ui/data_table/data_table_form_test.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/components/ruby_ui/data_table/data_table_form_test.rb b/test/components/ruby_ui/data_table/data_table_form_test.rb index bb64f249..8c9ab44c 100644 --- a/test/components/ruby_ui/data_table/data_table_form_test.rb +++ b/test/components/ruby_ui/data_table/data_table_form_test.rb @@ -20,4 +20,14 @@ class RubyUI::DataTableFormTest < ActiveSupport::TestCase out = render_component(RubyUI::DataTableForm.new(id: "my_form")) assert_match(/]*id="my_form"/, out) end + + test "renders form with method=get when given" do + out = render_component(RubyUI::DataTableForm.new(method: "get")) + assert_match(/]*method="get"/, out) + end + + test "renders form with method=delete when given" do + out = render_component(RubyUI::DataTableForm.new(method: "delete")) + assert_match(/]*method="delete"/, out) + end end From 2fa3dd3d975f049437d00de42ea82162dfba7523 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:20:17 -0300 Subject: [PATCH 75/81] refactor(data_table): use number_to_currency instead of regex reverse --- app/views/docs/data_table.rb | 6 +----- app/views/docs/data_table_demo/index.rb | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 34f98cba..7c44d038 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -241,10 +241,6 @@ def bulk_export render Docs::VisualCodeExample.new(title: "Custom cell renderers", context: self) do <<~'RUBY' - def format_currency(n) - "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" - end - def status_badge(status) variant = {"Active" => :success, "Inactive" => :destructive}.fetch(status, :outline) Badge(variant: variant, size: :sm) { plain status } @@ -264,7 +260,7 @@ def status_badge(status) TableRow do TableCell { r.name } TableCell { status_badge(r.status) } - TableCell(class: "text-right") { plain format_currency(r.salary) } + TableCell(class: "text-right") { plain view_context.number_to_currency(r.salary, precision: 0) } end end end diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 70f40451..38812999 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -87,7 +87,7 @@ def render_table 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 format_currency(e.salary) } + 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 @@ -136,7 +136,4 @@ def preserved_query }.compact_blank end - def format_currency(n) - "$#{n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}" - end end From 4ff5510b31bcab9f05037c4b466be015d0b01951 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:20:49 -0300 Subject: [PATCH 76/81] test(data_table): assert row checkbox markup on index --- test/controllers/docs/data_table_demo_controller_test.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/controllers/docs/data_table_demo_controller_test.rb b/test/controllers/docs/data_table_demo_controller_test.rb index db3690a5..cf01c83e 100644 --- a/test/controllers/docs/data_table_demo_controller_test.rb +++ b/test/controllers/docs/data_table_demo_controller_test.rb @@ -43,4 +43,9 @@ class Docs::DataTableDemoControllerTest < ActionDispatch::IntegrationTest post docs_data_table_demo_bulk_export_url, params: {ids: ["3"]} assert_redirected_to docs_data_table_demo_path end + + test "GET index renders row checkboxes with ids[] name" do + get docs_data_table_demo_url(per_page: 5) + assert_match(/name="ids\[\]"[^>]*value="1"|value="1"[^>]*name="ids\[\]"/, response.body) + end end From 8383fe25aaa36432cf7cbd11d99b50c35cdfc5d8 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:21:13 -0300 Subject: [PATCH 77/81] refactor(data_table): drop redundant arbitrary selector in expand toggle --- app/components/ruby_ui/data_table/data_table_expand_toggle.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 6d72a9bf..3f797e2a 100644 --- a/app/components/ruby_ui/data_table/data_table_expand_toggle.rb +++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb @@ -27,7 +27,7 @@ def view_template private def render_icon - raw view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 aria-expanded:rotate-90 [button[aria-expanded='true']_&]:rotate-90") + raw view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 aria-expanded:rotate-90") end def default_attrs From 6bcf141acbee77fa6b5f230ce5bb2d0301aa74f7 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:21:52 -0300 Subject: [PATCH 78/81] feat(data_table): row checkbox accepts label: kwarg for meaningful aria-label --- app/components/ruby_ui/data_table/data_table_row_checkbox.rb | 5 +++-- app/views/docs/data_table_demo/index.rb | 2 +- .../ruby_ui/data_table/data_table_row_checkbox_test.rb | 5 +++++ 3 files changed, 9 insertions(+), 3 deletions(-) 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 index 69f9aa0b..0eba666a 100644 --- a/app/components/ruby_ui/data_table/data_table_row_checkbox.rb +++ b/app/components/ruby_ui/data_table/data_table_row_checkbox.rb @@ -2,9 +2,10 @@ module RubyUI class DataTableRowCheckbox < Base - def initialize(value:, name: "ids[]", **attrs) + def initialize(value:, name: "ids[]", label: nil, **attrs) @value = value @name = name + @label = label super(**attrs) end @@ -18,7 +19,7 @@ def default_attrs { name: @name, value: @value, - aria_label: "Select row #{@value}", + aria_label: @label || "Select row #{@value}", data: { "ruby-ui--data-table-target": "rowCheckbox", action: "change->ruby-ui--data-table#toggleRow" diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 38812999..2a59cbf9 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -80,7 +80,7 @@ def render_table else @employees.each do |e| TableRow do - TableCell(class: "w-10") { DataTableRowCheckbox(value: e.id) } + 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 } diff --git a/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb b/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb index fa38175f..9d0f30d4 100644 --- a/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb +++ b/test/components/ruby_ui/data_table/data_table_row_checkbox_test.rb @@ -23,4 +23,9 @@ class RubyUI::DataTableRowCheckboxTest < ActiveSupport::TestCase out = RubyUI::DataTableRowCheckbox.new(value: 7).call assert_match(/aria-label="Select row 7"/, out) end + + test "custom aria-label via label: kwarg" do + out = render_component(RubyUI::DataTableRowCheckbox.new(value: 1, label: "Select Alice Johnson")) + assert_match(/aria-label="Select Alice Johnson"/, out) + end end From e7d8656f55b5f7205fe4614bd2e29f1f77e14a43 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:23:08 -0300 Subject: [PATCH 79/81] style: fix standardrb offenses (heredoc quotes, extra spacing, class body newline) --- app/views/docs/data_table.rb | 2 +- app/views/docs/data_table_demo/index.rb | 1 - .../components/ruby_ui/data_table/data_table_pagination_test.rb | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/views/docs/data_table.rb b/app/views/docs/data_table.rb index 7c44d038..d9b1a82e 100644 --- a/app/views/docs/data_table.rb +++ b/app/views/docs/data_table.rb @@ -240,7 +240,7 @@ def bulk_export 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' + <<~RUBY def status_badge(status) variant = {"Active" => :success, "Inactive" => :destructive}.fetch(status, :outline) Badge(variant: variant, size: :sm) { plain status } diff --git a/app/views/docs/data_table_demo/index.rb b/app/views/docs/data_table_demo/index.rb index 2a59cbf9..19c8ee40 100644 --- a/app/views/docs/data_table_demo/index.rb +++ b/app/views/docs/data_table_demo/index.rb @@ -135,5 +135,4 @@ def preserved_query "per_page" => @per_page.to_s }.compact_blank end - end diff --git a/test/components/ruby_ui/data_table/data_table_pagination_test.rb b/test/components/ruby_ui/data_table/data_table_pagination_test.rb index 66e5c22f..a48221d2 100644 --- a/test/components/ruby_ui/data_table/data_table_pagination_test.rb +++ b/test/components/ruby_ui/data_table/data_table_pagination_test.rb @@ -32,7 +32,7 @@ class RubyUI::DataTablePaginationTest < ActiveSupport::TestCase # 20 pages, on page 10, window:1 → [1, :gap, 9, 10, 11, :gap, 20] # window:2 → [1, :gap, 8, 9, 10, 11, 12, :gap, 20] out_narrow = RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 1).call - out_wide = RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 2).call + out_wide = RubyUI::DataTablePagination.new(page: 10, per_page: 1, total_count: 20, path: "/x", query: {}, window: 2).call assert_no_match(/page=8/, out_narrow) assert_match(/page=8/, out_wide) end From 112bd8e0283c936e91f7508378fe3e0e25991ed1 Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:24:21 -0300 Subject: [PATCH 80/81] fix(data_table): search focus survives Turbo Frame swap via module-level map --- .../ruby_ui/data_table_search_controller.js | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/app/javascript/controllers/ruby_ui/data_table_search_controller.js b/app/javascript/controllers/ruby_ui/data_table_search_controller.js index daf02773..0dc4101c 100644 --- a/app/javascript/controllers/ruby_ui/data_table_search_controller.js +++ b/app/javascript/controllers/ruby_ui/data_table_search_controller.js @@ -1,21 +1,23 @@ 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.restoreState = null; this.beforeFrameRender = this.captureBeforeRender.bind(this); - this.afterFrameRender = this.applyAfterRender.bind(this); document.addEventListener("turbo:before-frame-render", this.beforeFrameRender); - document.addEventListener("turbo:frame-render", this.afterFrameRender); + // 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); - document.removeEventListener("turbo:frame-render", this.afterFrameRender); } submit(event) { @@ -27,20 +29,17 @@ export default class extends Controller { captureBeforeRender() { const input = this.input(); - if (!input || document.activeElement !== input) { - this.restoreState = null; - return; - } - this.restoreState = { + if (!input || document.activeElement !== input) return; + PENDING_FOCUS.set(this.key(), { selectionStart: input.selectionStart, selectionEnd: input.selectionEnd - }; + }); } - applyAfterRender() { - if (!this.restoreState) return; - const state = this.restoreState; - this.restoreState = null; + 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(); @@ -56,4 +55,8 @@ export default class extends Controller { input() { return this.element.querySelector('input[type="search"]'); } + + key() { + return this.element.action || "_"; + } } From 8abdf6f235b2a91b34d94dddf7f5e0b77b151c9f Mon Sep 17 00:00:00 2001 From: Djalma Araujo Date: Fri, 24 Apr 2026 17:25:46 -0300 Subject: [PATCH 81/81] fix(data_table): expand toggle arrow rotates via group-aria-expanded variant --- app/components/ruby_ui/data_table/data_table_expand_toggle.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 3f797e2a..445a05ba 100644 --- a/app/components/ruby_ui/data_table/data_table_expand_toggle.rb +++ b/app/components/ruby_ui/data_table/data_table_expand_toggle.rb @@ -27,12 +27,12 @@ def view_template private def render_icon - raw view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 aria-expanded:rotate-90") + raw view_context.lucide_icon("chevron-right", class: "h-4 w-4 transition-transform duration-150 group-aria-expanded:rotate-90") end def default_attrs { - class: "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" + 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