Skip to content

feat(table): search, sort, and selection on the CSV data table - #204

Merged
obvious-autobuild-staging[bot] merged 9 commits into
masterfrom
feat/table-view-powers
Sep 7, 2026
Merged

obvious-autobuild-staging[bot] merged 9 commits into
masterfrom
feat/table-view-powers

Conversation

@obvious-autobuild-staging

@obvious-autobuild-staging obvious-autobuild-staging Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Why

The CSV table renders thousands of rows but offers no way to navigate them: finding a row means scrolling, understanding a column means eyeballing, and extracting a subset means re-filtering the source text manually. This ships the first stage of the table superpowers spec (search, sort, select) on both table instances — input-table and output-table share the same component, so both get the toolbar for free.

What

Search (criterion 1). A dense toolbar strip above the sticky header: search input, live count chip ("340 of 12,408"), clear via × button and Esc. Debounced ~150ms, case-insensitive substring across all cells; only matching rows render, and matches are highlighted in-cell with <mark> plus a mark fallback that survives with or without color (never color-alone).

Sort (criterion 2). Header cells are now buttons cycling asc → desc → natural. Numeric columns (existing numericColumns detection) compare numerically with non-numeric values forced last; text uses localeCompare. Single-column only. Headers expose aria-sort and a direction glyph.

Selection + copy (criterion 3). Row-number gutter click selects, Shift-click selects a range, Cmd/Ctrl-click toggles. Selection is a Set of source row indices, so it survives filtering and sorting. The toolbar shows "N selected", Copy-as-CSV (view order, proper RFC-4180 serialization via the shared serializeCsvRow), and Clear. Rows expose aria-selected.

Row-number fidelity (criterion 4). The gutter always shows the source row number, even when the view is filtered or sorted.

Empty filter state (criterion 5). A "No rows matching …" message with a Show-all-rows action — never a blank grid.

Architecture (locked by the spec):

  • Pure transforms in a new app/src/lib/tableView.ts — filterRowIndices, sortRowIndices, splitHighlight, serializeCsvRow — fully unit-tested (23 tests). No new dependencies; TanStack Table was considered and rejected in the spec.
  • The virtualizer counts view.length and each rendered row maps back through sourceIndex = view[virtualRow.index]. Fixed 24px row height and absolute positioning untouched; transforms memoized on (table, query, sort) — no per-keystroke re-parse of source text.
  • Raw CSV text remains the single source of truth; the grid persists no conversion state; Raw view and table view always agree.
  • Accessibility floor: role="grid" + aria-rowcount (view length + header), labeled search input, aria-sort headers, aria-selected rows, visible focus rings, highlight/selection never color-alone.

Both table instances get identical behavior — the toolbar, sorting, and selection live entirely in CsvTable.

How to Review

  • app/src/lib/tableView.ts — the pure transforms; read with its test file first, the component just wires them.
  • app/src/components/CsvTable.tsx — toolbar + header buttons + view mapping. The virtualizer contract (count = view length, source-index mapping) is the load-bearing invariant.
  • app/src/components/CsvTable.test.tsx — 16 new DOM tests; the large-grid virtualization test is unchanged and still green (396 tests total, 357 baseline + 39 new).
  • app/src/changelog/entries.ts — entry id 8 announcing the feature.
  • Intentionally excluded: input-side cell editing (dependent stage per spec), multi-column sort, pagination. No behavior change outside the table component.

Test Evidence

WebM of the full flow: filter 'west' (5 of 20 with highlights), sort Units ascending, click + meta-click selection (2 selected), Copy as CSV emits header plus the two selected rows in sorted view order
WebM of the full flow: filter 'west' (5 of 20 with highlights), sort Units ascending, click + meta-click selection (2 selected), Copy as CSV emits header plus the two selected rows in sorted view order — recording

Toolbar remains usable at 375px — search, count chip, and selection controls visible on mobile viewport
Toolbar remains usable at 375px — search, count chip, and selection controls visible on mobile viewport — result

Desktop table shows search toolbar, tri-state sort glyph (Units ascending), and '2 selected' state with source-row gutter fidelity
Desktop table shows search toolbar, tri-state sort glyph (Units ascending), and '2 selected' state with source-row gutter fidelity — result

Local gates: tsc, ESLint, full Vitest suite (396 passing), production build with app/dist rebuilt and committed, verify-seo.sh passing. The shim verification (verify-shim.sh) requires PHP 8.4 which is not installed in the local sandbox — it runs unchanged in the PHP CI job; no PHP files are touched by this PR. Browser evidence (desktop, 375px, and a filter→sort→select→copy recording) is attached below against the tested head SHA.

🔗 Obvious Project · 🧵 Obvious Thread

@obvious-autobuild-staging
obvious-autobuild-staging Bot marked this pull request as ready for review September 7, 2026 22:58

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review

Verdict: COMMENT

Summary

  • Blocker: 0
  • High: 1
  • Medium: 2

High

  • app/src/components/CsvTable.tsx:166 — "N selected" chip vs Copy-as-CSV payload diverge when the filter hides selected rows

Medium

  • app/src/components/CsvTable.tsx:250 — aria-rowcount without aria-rowindex under virtualization
  • app/src/lib/tableView.ts:66 — numeric sort loses precision above Number.MAX_SAFE_INTEGER

Gates re-verified at head 449e237: eslint clean, tsc clean, 396/396 vitest, production build + prerender, committed dist byte-identical (parity check).


View full review in Obvious

Comment thread app/src/components/CsvTable.tsx Outdated
};

const copySelection = () => {
const rowsInView = view.filter((index) => selected.has(index));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High · reliability — The "N selected" chip renders selected.size (all selected source rows) while copySelection copies only rows still present in the current filtered view (view.filter((i) => selected.has(i))). With a filter active that hides selected rows, the chip overstates the clipboard payload: select 10 rows, filter to 3, chip still reads "10 selected", paste yields 3 data rows — silent divergence, no error, no recovery cue (the hidden rows are invisible under the filter). Make chip and clipboard agree: either render view∩selected count in the chip, or copy the full selected set (view order for visible rows, source order for hidden ones). Note the shipped copy test filters with "200", which matches every fixture row, so this narrowing path is untested — add a test where the filter hides a selected row and assert both chip text and clipboard payload.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 92be714. The chip now counts selectedInView — exactly the rows Copy as CSV emits — and the selection toolbar gates on that count, so chip and clipboard can no longer diverge. Selection itself stays keyed by source index, so a selection hidden by the filter comes back when the filter clears. Added the missing narrowing-path DOM test: select two rows, filter so one hides, assert chip reads "1 selected" and the clipboard payload is the header plus that one row only.

ref={scrollRef}
data-testid={testId}
role="grid"
aria-rowcount={view.length + 1}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · reliability — aria-rowcount={view.length + 1} advertises the full view length on the role="grid" container, but under virtualization only the rendered window exists in the DOM and no row carries aria-rowindex. Assistive technology honoring aria-rowcount derives positions from DOM order, announcing e.g. "row 1 of 12,409" for whichever row sits at the top of the window after scrolling — the announced count and the row positions contradict each other. Complete the ARIA grid contract on the rendered window (aria-rowindex={virtualRow.index + 2} per data row with header = 1, aria-colindex on cells), or drop aria-rowcount until row indices are wired.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 92be714. Completed the grid contract on the rendered window: aria-rowindex={1} on the header row and aria-rowindex={virtualRow.index + 2} on data rows, plus aria-colindex on the gutter (1), header buttons, and every data cell. aria-rowcount stays on the role=grid container and now agrees with the DOM positions. DOM test added asserting the header colindex and a data row's rowindex.

Comment thread app/src/lib/tableView.ts
if (aNan && bNan) return 0;
return aNan ? 1 : -1;
}
return factor * (na - nb);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · reliability — The numeric comparator sorts via Number() subtraction (factor * (na - nb)) with no magnitude guard, and numericColumns classifies any all-digit column as numeric. 64-bit id columns (Snowflake/Dynamo/Slack exports — common CSV inputs for this product) lose precision above Number.MAX_SAFE_INTEGER: distinct ids collapse to the same double and mis-sort silently. Compare integer-shaped cells longer than 15 significant digits via BigInt (keep Number for decimals/exponents), and pin it with a unit test on 9007199254740993 vs 9007199254740994.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 92be714. sortRowIndices now compares integer-shaped cells of 16+ digits via BigInt (/^[+-]?\d{16,}$/ on both sides); decimals and exponents keep the Number path. The unit test sorts 9007199254740993 vs 9007199254740994 with a reversed input array — under the old Number comparator both round to the same double, so the stable sort would silently keep the input order, which is exactly the failure this catches.

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

All three findings addressed in 92be714 (chip now counts view∩selected so it always matches the clipboard payload; aria-rowindex/aria-colindex wired across the virtualized window; BigInt comparator for 16+ digit integer cells). Suite at 399 passing, dist rebuilt, gates green. The narrowed-selection path now has the DOM test you asked for.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — pass 3 (incremental)

Verdict: COMMENT

Pass-2 findings — all resolved

  • High (chip vs Copy divergence): selectedInView now feeds both the count chip and copySelection; pinned by a test that hides a selected row and asserts chip text and clipboard payload.
  • Medium (aria-rowcount without aria-rowindex): rows now carry aria-rowindex/aria-colindex; pinned by test.
  • Medium (numeric precision above 2^53): 16+ digit integer-shaped cells compare via BigInt; pinned by tests in both directions.

Gates verified at head 92be714: eslint clean, tsc clean, 399/399 vitest, production build + prerender, committed dist byte-identical.

New findings — 0 Blocker, 0 High, 2 Medium

  • Medium · reliability — inline on app/src/components/CsvTable.tsx:355: grid rows still lack cell roles, so the new aria-rowindex/aria-colindex are inert for assistive-tech cell traversal.
  • Medium · hygiene — body-only (binary files take no line anchor): four debug PNGs are committed at the repo root — .probe.png, .probe2.png (byte-identical blobs), .tc1.png, .tc2.png. None are referenced by any source, none are gitignored, and they ship to master on the auto-deploy merge. Delete them (and consider adding *.png root-level entries or a broader ignore for probe artifacts).

Readiness: CLEAN — no Blocker or High findings open.


View full review in Obvious

return (
<div
key={c}
aria-colindex={c + 2}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium · reliability — The grid contract is still incomplete at the cell level: the children of each role="row" are plain divs (data cells) and a bare button (the gutter), and aria-colindex only conforms on elements with a cell role. Without role="gridcell" (or "cell") on these children, assistive technology cannot traverse the grid cell-by-cell, and the aria-rowindex/aria-colindex values added in this fix never materialize in the accessibility tree. Give each data-cell div role="gridcell" (moving aria-colindex is fine where it is), and wrap the gutter button in a div role="gridcell" aria-colindex={1} so the button keeps its own semantics inside the cell. The row-number header cell has the same gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 13294b5. Every child of a role=row is now a real cell role: data-cell divs carry role="gridcell" (aria-colindex c+2), the gutter button is wrapped in a role="gridcell" aria-colindex={1} div so the button keeps its own semantics inside the cell, and the header gutter cell got role="gridcell" aria-colindex={1}. The DOM test now asserts the full cell contract — 3 gridcells per row, colindex 1/2/2, button semantics preserved inside the first cell.

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Both pass-3 findings addressed in 13294b5: gridcell roles across header gutter, row gutter wrapper, and data cells (pinned by the updated ARIA test), and the four debug PNGs deleted with a root-level /*.png gitignore rule so probe captures cannot be swept into a commit again. Gates green locally at this head: tsc, eslint, 399/399 vitest, build + prerender + SEO, dist rebuilt and committed.

@obvious-autobuild-staging obvious-autobuild-staging Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obvious Code Review — pass 4 (incremental)

Verdict: COMMENT

Pass-3 findings — all resolved

  • Medium (grid cell roles): the gutter button is now wrapped in a role="gridcell" container (the button keeps its own semantics inside), data cells carry role="gridcell" alongside aria-colindex, and the row-number header cell got the same treatment. The updated test asserts three real cells per row with the gutter button's accessible name intact.
  • Medium (committed debug PNGs): all four root-level captures deleted, and .gitignore gains a commented root-level /*.png rule so the debris class can't return.

No re-introductions: chip/copy agreement, aria-rowindex/aria-colindex, and the BigInt comparator are all unchanged.

Gates verified at head 13294b5: eslint clean, tsc clean, 399/399 vitest, production build + prerender, committed dist byte-identical.

No findings — clean pass.


View full review in Obvious

@obvious-autobuild-staging

Copy link
Copy Markdown
Contributor Author

Replying to this comment

Clean pass noted — no action needed on my side. PR is at head 13294b5 with all three evidence items (desktop, 375px, flow WebM) recorded against it, CI green, and zero open findings. Ready for merge.

@obvious-autobuild-staging
obvious-autobuild-staging Bot merged commit 15fbb37 into master Sep 7, 2026
3 checks passed
@obvious-autobuild-staging
obvious-autobuild-staging Bot deleted the feat/table-view-powers branch September 7, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant