Skip to content

feat: implement cell match navigation via Enter key#500

Merged
zknpr merged 5 commits into
zknpr:devfrom
yukina3230:feat/filter-match-navigation
Jun 21, 2026
Merged

feat: implement cell match navigation via Enter key#500
zknpr merged 5 commits into
zknpr:devfrom
yukina3230:feat/filter-match-navigation

Conversation

@yukina3230

Copy link
Copy Markdown

Description

  • Adds Enter-to-navigate functionality for matching grid cells in both global search and column filters.
  • Highlights the currently focused cell with an .active-match-cell border and tracks progress via a dynamic counter.
  • Optimizes performance: initial Enter applies the filter (backend fetch), while subsequent presses cycle focus locally without redundant re-queries.
  • Prevents UI bugs by automatically resetting match navigation and counters upon grid mutation actions (sorting, pagination, or filter text changes).

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing behavior)
  • Documentation update
  • Refactor / chore (no functional change)

Checklist

  • npm test passes locally
  • npm run build completes without errors
  • I followed the project's coding standards (parameterized SQL, escaped identifiers, textContent rendering, strict CSP)
  • I added or updated tests for my changes
  • I updated the documentation (README / CHANGELOG / CLAUDE.md) where relevant
  • My commits follow Conventional Commits

Screenshots

nav.webm

@vercel

vercel Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

@yukina3230 is attempting to deploy a commit to the zknpr's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 28e4958d-81c4-4944-b4c2-9606e62ad159

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a filter match navigation feature that allows users to cycle through matching cells using Enter and Shift+Enter in both global and column filters, complete with a match counter UI and cell highlighting. The review feedback highlights several critical improvements: handling MouseEvent arguments when filters are triggered via click events, preventing concurrent data loads during rapid filter submissions, calling event.preventDefault() on Enter keydown events, defensively handling non-string return values from cell formatting to avoid runtime errors, and correcting a CSS z-index conflict that causes active matches to override pinned cell layering.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +17 to +27
export async function applyGlobalFilter(direction = 1) {
const value = document.getElementById('filterInput').value;
if (value !== state.filterQuery) {
state.filterQuery = value;
state.currentPageIndex = 0;
loadTableData();
resetMatchNav();
await loadTableData();
persistState();
}, 300);
}
navigateMatches('global', direction);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When applyGlobalFilter is registered as a click event listener on btnApplyFilter, the browser passes the MouseEvent object as the first argument (direction). This causes direction to be an object, leading to NaN when performing index calculations in navigateMatches. Additionally, we should defensively check if filterInput exists and prevent concurrent re-fetches if state.isLoadingData is true.

export async function applyGlobalFilter(direction = 1) {
    if (state.isLoadingData) return;
    const input = document.getElementById('filterInput');
    if (!input) return;
    const value = input.value;
    const dir = typeof direction === 'number' ? direction : 1;
    if (value !== state.filterQuery) {
        state.filterQuery = value;
        state.currentPageIndex = 0;
        resetMatchNav();
        await loadTableData();
        persistState();
    }
    navigateMatches('global', dir);
}

Comment on lines +78 to 97
export async function applyColumnFilter(columnName, direction = 1) {
const input = document.querySelector(`.column-filter[data-column="${columnName}"]`);
if (input) {
if (!input) return;

const changed = input.value !== (state.columnFilters[columnName] || '');
if (changed) {
state.columnFilters[columnName] = input.value;
state.currentPageIndex = 0;
loadTableData();
resetMatchNav();
await loadTableData();
// loadTableData() rebuilds the header, so the input we focused is gone.
// Re-focus the freshly rendered one and place the caret at the end.
const newInput = document.querySelector(`.column-filter[data-column="${columnName}"]`);
if (newInput) {
newInput.focus();
newInput.setSelectionRange(newInput.value.length, newInput.value.length);
}
}
navigateMatches(columnName, direction);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

To prevent race conditions and redundant database queries when the user rapidly presses Enter, we should check if state.isLoadingData is true and return early. We should also ensure direction is a number to prevent any potential type coercion issues.

export async function applyColumnFilter(columnName, direction = 1) {
    if (state.isLoadingData) return;
    const input = document.querySelector(`.column-filter[data-column="${columnName}"]`);
    if (!input) return;

    const dir = typeof direction === 'number' ? direction : 1;
    const changed = input.value !== (state.columnFilters[columnName] || '');
    if (changed) {
        state.columnFilters[columnName] = input.value;
        state.currentPageIndex = 0;
        resetMatchNav();
        await loadTableData();
        // loadTableData() rebuilds the header, so the input we focused is gone.
        // Re-focus the freshly rendered one and place the caret at the end.
        const newInput = document.querySelector(`.column-filter[data-column="${columnName}"]`);
        if (newInput) {
            newInput.focus();
            newInput.setSelectionRange(newInput.value.length, newInput.value.length);
        }
    }
    navigateMatches(columnName, dir);
}

Comment on lines +29 to 32
export function onFilterEnter(event) {
// Enter jumps to the next match, Shift+Enter to the previous one.
if (event.key === 'Enter') applyGlobalFilter(event.shiftKey ? -1 : 1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

It is a good practice to call event.preventDefault() when handling the Enter keydown event to prevent any default browser actions (such as accidental form submissions or unexpected page reloads).

export function onFilterEnter(event) {
    // Enter jumps to the next match, Shift+Enter to the previous one.
    if (event.key === 'Enter') {
        event.preventDefault();
        applyGlobalFilter(event.shiftKey ? -1 : 1);
    }
}

Comment on lines 99 to 102
export function onColumnFilterKeydown(event, columnName) {
if (event.key === 'Enter') {
applyColumnFilter(columnName);
}
// Enter jumps to the next match, Shift+Enter to the previous one.
if (event.key === 'Enter') applyColumnFilter(columnName, event.shiftKey ? -1 : 1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Call event.preventDefault() when handling the Enter keydown event to prevent default browser actions.

export function onColumnFilterKeydown(event, columnName) {
    // Enter jumps to the next match, Shift+Enter to the previous one.
    if (event.key === 'Enter') {
        event.preventDefault();
        applyColumnFilter(columnName, event.shiftKey ? -1 : 1);
    }
}

Comment on lines +31 to +35
const value = getCellValue(row, colIdx);
const text = formatCellValueAsText(value, col.type, state.dateFormat, col.name);
if (text.toLowerCase().includes(term)) {
matches.push({ rowIdx, colIdx });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If formatCellValueAsText returns a non-string value (e.g., null, undefined, or a number), calling .toLowerCase() directly on it will throw a TypeError and crash the application. We should defensively convert the formatted value to a string.

            const value = getCellValue(row, colIdx);
            const rawText = formatCellValueAsText(value, col.type, state.dateFormat, col.name);
            const text = typeof rawText === 'string' ? rawText : String(rawText || '');
            if (text.toLowerCase().includes(term)) {
                matches.push({ rowIdx, colIdx });
            }

Comment thread core/ui/viewer.css
Comment on lines +699 to +703
.data-cell.active-match-cell {
outline: 2px solid var(--accent-color);
outline-offset: -2px;
z-index: 3;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Since .data-cell.active-match-cell has z-index: 3 and is declared later in the stylesheet than .data-cell.pinned (which has z-index: 5), it will override the z-index of pinned cells to 3. This causes pinned cells to lose their correct stacking order when they become the active match. We should add a rule to preserve the z-index of pinned cells.

.data-cell.active-match-cell {
    outline: 2px solid var(--accent-color);
    outline-offset: -2px;
    z-index: 3;
}

.data-cell.pinned.active-match-cell {
    z-index: 5;
}

@zknpr

zknpr commented Jun 21, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0cfd7ddb5e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread core/ui/modules/grid-events.js Outdated
export function initGridControls() {
document.getElementById('filterInput')?.addEventListener('keyup', onFilterChange);
document.getElementById('filterInput')?.addEventListener('keydown', onFilterEnter);
document.getElementById('btnApplyFilter')?.addEventListener('click', applyGlobalFilter);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wrap the global search click handler

When the toolbar Search button is clicked, addEventListener passes the MouseEvent into applyGlobalFilter as the direction argument. That makes a fresh click search start at the last match (MouseEvent !== 1), and repeated clicks hit currentIndex + direction in navigateMatches, coercing the event into a string and producing NaN, so the counter/active cell stop working. Use a wrapper such as () => applyGlobalFilter(1) for this click path.

Useful? React with 👍 / 👎.

Comment on lines +96 to +98
? state.matchNav.matches[state.matchNav.currentIndex]
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Clear stale match state before rendering new tables

This renders an active match solely from state.matchNav, but selecting another table clears filterQuery/columnFilters without resetting matchNav. After navigating matches in one table and then selecting a different table, the new grid can highlight the same row/column index even though the filter box is empty (and the old counter remains), so table switches show a stale match. Reset the match navigation state when table/filter state is cleared, or guard this active match on the current active term.

Useful? React with 👍 / 👎.

import { formatCellValueAsText } from './utils.js';

function activeTerm(scope) {
return (scope === 'global' ? state.filterQuery : state.columnFilters[scope] || '').trim().toLowerCase();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Use a non-colliding scope for global search

When a table has a column literally named global, column-filter navigation for that column is treated as the toolbar global search because this sentinel is compared directly with column names. In that case activeTerm() reads state.filterQuery instead of state.columnFilters.global, and the matching code also scans every column, so Enter on the global column filter shows no/incorrect match navigation. Store the scope as a distinct type or reserve a sentinel that cannot collide with user column names.

Useful? React with 👍 / 👎.

Comment on lines +79 to +81
const cacheValid = state.matchNav.scope === scope
&& state.matchNav.term === term
&& state.matchNav.matches.length > 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Invalidate cached matches when displayed text changes

After matches are cached, this validity check only considers scope and term, but computeMatches() depends on the current rendered cell text via state.gridData and state.dateFormat. If a user edits a matched cell or changes the Date Format control and then presses Enter with the same filter term, navigation can keep jumping to/counting cells whose current text no longer matches. Include a data/display version in the cache key or reset match navigation when cell data or date formatting changes.

Useful? React with 👍 / 👎.

@zknpr zknpr changed the base branch from main to dev June 21, 2026 19:55
yukina3230 and others added 2 commits June 21, 2026 23:00
- btnApplyFilter passed the click MouseEvent as `direction`, making
  navigateMatches compute NaN (first click jumped to the last match, a
  second wedged navigation). Bind `() => applyGlobalFilter(1)` and
  normalize direction to ±1 in navigateMatches.
- Guard applyGlobalFilter/applyColumnFilter on state.isGridReloading so
  the toolbar filter (which bypasses the #gridContainer guards) can't
  stack a concurrent refetch or act on the stale grid.
- preventDefault() on the global + column filter Enter handlers.
- match-nav: String(formatCellValueAsText(...)) before toLowerCase() to
  avoid a throw on non-string formatter output.
- onDateFormatChange now resetMatchNav() — the cached matches/active
  highlight are stale once the displayed text changes.
- viewer.css: keep a pinned cell's z-index (5) when it is the active
  match, instead of dropping to the active-match z-index (3).
- Remove the now-dead state.filterTimer (the old debounced onFilterChange
  was replaced by Enter-to-submit). Regenerated bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zknpr zknpr force-pushed the feat/filter-match-navigation branch from 0cfd7dd to 94451b4 Compare June 21, 2026 21:06

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 94451b4173

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread core/ui/modules/grid-actions.js Outdated
Comment on lines +37 to +39
if (event.key === 'Enter') {
event.preventDefault();
applyGlobalFilter(event.shiftKey ? -1 : 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor IME composition before submitting filters

When a user is composing text with an IME in the filter input, the Enter key used to confirm the candidate can arrive as key === 'Enter'; this path treats it as a filter submit, prevents the composition confirmation, and starts a grid reload before the composed text is committed. Guarding on event.isComposing (and the same column-filter Enter handler below) avoids breaking Japanese/Chinese/Korean input.

Useful? React with 👍 / 👎.

The Enter that confirms an IME candidate (Japanese/Chinese/Korean) fires
as key==='Enter'; with the new preventDefault the global and column
filter handlers were submitting the filter and starting a reload before
the composed text committed, breaking CJK input. Guard both Enter
handlers on !event.isComposing. Regenerated bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32040944c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread core/ui/modules/grid-actions.js Outdated
Comment on lines +25 to +29
if (value !== state.filterQuery) {
state.filterQuery = value;
state.currentPageIndex = 0;
loadTableData();
resetMatchNav();
await loadTableData();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allow failed filter submits to be retried

If loadTableData() fails or times out, it catches the error and leaves the error view in place, but this path has already made the submitted value look applied. A subsequent Enter/Search with the same query skips the refetch and only calls navigateMatches, so users cannot retry a transient failed global filter without editing the text first; the column-filter path has the same changed-before-success pattern.

Useful? React with 👍 / 👎.

applyGlobalFilter/applyColumnFilter set the filter term before awaiting
loadTableData, which swallows errors. After a failed/timed-out fetch the
term looked applied, so re-submitting the same query skipped the refetch
(value === state.filterQuery) and only navigated — the user couldn't
retry without editing the text.

loadTableData now returns true on success / false on a genuine
(non-superseded) failure. Both apply paths revert the term (global) or
restore/delete the prior column value on false, so the same query
re-runs the fetch. Regenerated bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b5c64453a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread core/ui/modules/grid-actions.js Outdated
loadTableData();
resetMatchNav();
const ok = await loadTableData();
if (ok === false) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip navigation after superseded filter loads

When loadTableData() is superseded by a newer toolbar load it resolves undefined, but this check only stops explicit false failures, so a slow filter submit can resume after the user clicks pagination/page-size and call navigateMatches against the stale grid while the newer load is still in flight; the newer render can then show a stale active match/counter on the wrong page. Treat only ok === true as applied before persisting/navigating, and apply the same guard to the column-filter path below.

Useful? React with 👍 / 👎.

A filter load superseded by a newer toolbar load (pagination/page-size/
table switch) resolves undefined; the previous check only stopped
explicit false, so a slow submit could resume and navigateMatches
against the stale grid while the newer load was still rendering, showing
a stale active match/counter on the wrong page. Treat only ok === true as
applied: false still reverts the term (retry), undefined now returns
without navigating. Applied to both global and column filter paths.
Regenerated bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zknpr zknpr merged commit 1525d52 into zknpr:dev Jun 21, 2026
1 of 2 checks passed
@zknpr

zknpr commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Thanks for this, @yukina3230! 🙏 Enter-to-navigate with the match counter is a great addition, and it pairs really nicely with your highlighting PR. Merged into dev for the next release. I added some fixes on top (IME composition handling, the filter-submit retry/supersession lifecycle, a couple of edge guards), but the feature and design are yours.

That's four solid PRs in one batch — thank you for all of them! 🎉

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.

2 participants