Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions webview/src/components/VcfPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useDiagnostics } from '../diagnostics/DiagnosticsContext';
import { RowStatus, RowStatusHeaderSpacer, severityRowClass, STATUS_COL_WIDTH } from './RowStatus';
import { useTableFind } from '../find/useTableFind';
import { FindBar } from './FindBar';
import { useGridKeyboard } from '../keyboard/useGridKeyboard';

interface VcfPreviewProps {
metadata: DocumentMetadata;
Expand Down Expand Up @@ -221,6 +222,24 @@ export function VcfPreview({ metadata, rows, headerInfo, loadedLineCount, onRequ
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [find.scrollTick]);

// Keyboard navigation: arrow/paging row focus, Enter expands, Esc collapses.
const grid = useGridKeyboard({
rowCount: displayRows.length,
colCount: 1,
scrollToRow: (r) => listRef.current?.scrollToItem(r, 'smart'),
onActivate: (r) => {
const row = displayRows[r];
if (row) setExpandedRow((prev) => (prev === row.lineNumber ? null : row.lineNumber));
},
onEscape: () => {
if (expandedRow !== null) {
setExpandedRow(null);
return true;
}
return false;
},
});

// Export visible (filtered + sorted) rows as a proper VCF file
const exportVcf = useCallback(() => {
const headerEndLine = headerInfo?.headerEndLine ?? 0;
Expand Down Expand Up @@ -257,8 +276,8 @@ export function VcfPreview({ metadata, rows, headerInfo, loadedLineCount, onRequ
return (
<div style={style}>
<div
className={`table-row ${isExpanded ? 'expanded' : ''} ${severityRowClass(sev)} ${isActiveMatch ? 'find-active-row' : ''}`}
onClick={() => setExpandedRow(isExpanded ? null : row.lineNumber)}
className={`table-row ${isExpanded ? 'expanded' : ''} ${severityRowClass(sev)} ${isActiveMatch ? 'find-active-row' : ''} ${grid.isRowFocused(index) ? 'grid-row-focused' : ''}`}
onClick={() => { grid.focus(index, 0); setExpandedRow(isExpanded ? null : row.lineNumber); }}
>
{showStatus && <RowStatus line={row.lineNumber} />}
<div className="table-cell" style={{ width: colWidths.chrom, flexShrink: 0 }} title={row.chrom}>{find.highlight(row.chrom)}</div>
Expand Down Expand Up @@ -313,7 +332,7 @@ export function VcfPreview({ metadata, rows, headerInfo, loadedLineCount, onRequ
</div>
</div>
);
}, [displayRows, expandedRow, sampleColumns, colWidths, formatDefs, showStatus, worstFor, find.highlight, find.activeRow]);
}, [displayRows, expandedRow, sampleColumns, colWidths, formatDefs, showStatus, worstFor, find.highlight, find.activeRow, grid.isRowFocused, grid.focus]);

// Handle scroll to load more rows
const handleScroll = useCallback(({ scrollOffset }: { scrollOffset: number }) => {
Expand Down Expand Up @@ -446,7 +465,7 @@ export function VcfPreview({ metadata, rows, headerInfo, loadedLineCount, onRequ
</div>

{/* Table */}
<div className="table-container">
<div className="table-container" tabIndex={0} onKeyDown={grid.handleKeyDown}>
<div ref={containerRef} style={{ overflowX: 'auto' }}>
{/* Header row */}
<div className="table-header" style={{ width: Math.max(totalWidth + statusWidth, containerWidth) }}>
Expand Down
27 changes: 21 additions & 6 deletions webview/src/components/VirtualTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useDiagnostics } from '../diagnostics/DiagnosticsContext';
import { RowStatus, RowStatusHeaderSpacer, severityRowClass, STATUS_COL_WIDTH } from './RowStatus';
import { useTableFind } from '../find/useTableFind';
import { FindBar } from './FindBar';
import { useGridKeyboard } from '../keyboard/useGridKeyboard';

// Row type allows string columns plus extra parsed metadata (prefixed with _)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -158,6 +159,14 @@ export function VirtualTable({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [find.scrollTick]);

// Keyboard navigation: arrow/paging focus with Enter to activate a row.
const grid = useGridKeyboard({
rowCount: displayRows.length,
colCount: columns.length,
scrollToRow: (r) => listRef.current?.scrollToItem(r, 'smart'),
onActivate: (r) => onRowClick?.(displayRows[r], r),
});

// Register how to scroll this list to a given source line (for error nav).
const displayRowsRef = useRef(displayRows);
displayRowsRef.current = displayRows;
Expand Down Expand Up @@ -190,14 +199,14 @@ export function VirtualTable({
return (
<div
style={baseStyle}
className={`virtual-table-row ${isExpanded ? 'expanded' : ''} ${severityRowClass(sev)} ${isActiveMatch ? 'find-active-row' : ''}`}
onClick={() => onRowClick?.(row, index)}
className={`virtual-table-row ${isExpanded ? 'expanded' : ''} ${severityRowClass(sev)} ${isActiveMatch ? 'find-active-row' : ''} ${grid.isRowFocused(index) ? 'grid-row-focused' : ''}`}
onClick={() => { grid.focus(index, 0); onRowClick?.(row, index); }}
>
{showStatus && <RowStatus line={line} />}
{columnWidths.map(col => (
{columnWidths.map((col, colIdx) => (
<div
key={col.key}
className="virtual-table-cell"
className={`virtual-table-cell ${grid.isFocused(index, colIdx) ? 'grid-cell-focused' : ''}`}
style={{
width: col.computedWidth,
minWidth: col.computedMinWidth,
Expand All @@ -217,15 +226,21 @@ export function VirtualTable({
))}
</div>
);
}, [displayRows, columnWidths, expandedRow, onRowClick, showStatus, resolveLine, worstFor, find.highlight, find.activeRow]);
}, [displayRows, columnWidths, expandedRow, onRowClick, showStatus, resolveLine, worstFor, find.highlight, find.activeRow, grid.isRowFocused, grid.isFocused, grid.focus]);

// Calculate total width (add the status column when diagnostics are shown)
const totalWidth =
columnWidths.reduce((sum, col) => sum + col.computedWidth, 0) +
(showStatus ? STATUS_COL_WIDTH : 0);

return (
<div ref={containerRef} className={`virtual-table-container ${className}`} style={{ flex: 1, overflow: 'hidden' }}>
<div
ref={containerRef}
className={`virtual-table-container ${className}`}
style={{ flex: 1, overflow: 'hidden' }}
tabIndex={0}
onKeyDown={grid.handleKeyDown}
>
{/* Search/Export toolbar */}
{(searchable || exportable) && (
<div
Expand Down
115 changes: 115 additions & 0 deletions webview/src/keyboard/useGridKeyboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-or-later

import { render, screen, fireEvent } from '@testing-library/react';
import { useGridKeyboard, type UseGridKeyboardOptions } from './useGridKeyboard';

type ProbeProps = Partial<UseGridKeyboardOptions>;

function Probe(props: ProbeProps) {
const grid = useGridKeyboard({
rowCount: props.rowCount ?? 5,
colCount: props.colCount ?? 3,
scrollToRow: props.scrollToRow,
onActivate: props.onActivate,
onEscape: props.onEscape,
});
return (
<div data-testid="grid" tabIndex={0} onKeyDown={grid.handleKeyDown}>
<span data-testid="focus">
{grid.focused ? `${grid.focused.row},${grid.focused.col}` : 'none'}
</span>
<input data-testid="inp" />
</div>
);
}

function key(k: string, init: Partial<KeyboardEventInit> = {}) {
fireEvent.keyDown(screen.getByTestId('grid'), { key: k, ...init });
}

const focus = () => screen.getByTestId('focus').textContent;

describe('useGridKeyboard', () => {
it('starts unfocused and enters at 0,0 on first ArrowDown', () => {
render(<Probe />);
expect(focus()).toBe('none');
key('ArrowDown');
expect(focus()).toBe('0,0');
});

it('moves down/right and clamps at edges', () => {
render(<Probe rowCount={3} colCount={2} />);
key('ArrowDown'); // 0,0
key('ArrowDown'); // 1,0
key('ArrowRight'); // 1,1
expect(focus()).toBe('1,1');
key('ArrowRight'); // clamp col
expect(focus()).toBe('1,1');
key('ArrowUp');
key('ArrowUp');
key('ArrowUp'); // clamp row at 0
expect(focus()).toBe('0,1');
});

it('supports Home/End and Ctrl+Home/End', () => {
render(<Probe rowCount={10} colCount={4} />);
key('ArrowDown');
key('End');
expect(focus()).toBe('0,3'); // last column
key('Home');
expect(focus()).toBe('0,0'); // first column
key('End', { ctrlKey: true });
expect(focus()).toBe('9,0'); // last row
key('Home', { ctrlKey: true });
expect(focus()).toBe('0,0'); // first row
});

it('pages by the configured size', () => {
render(<Probe rowCount={100} colCount={1} pageSize={10} />);
key('ArrowDown'); // 0,0
key('PageDown');
expect(focus()).toBe('10,0');
key('PageUp');
expect(focus()).toBe('0,0');
});

it('calls scrollToRow with the focused row on move', () => {
const scrollToRow = jest.fn();
render(<Probe scrollToRow={scrollToRow} />);
key('ArrowDown');
key('ArrowDown');
expect(scrollToRow).toHaveBeenLastCalledWith(1);
});

it('fires onActivate for the focused row on Enter', () => {
const onActivate = jest.fn();
render(<Probe onActivate={onActivate} />);
key('ArrowDown');
key('ArrowDown'); // row 1
key('Enter');
expect(onActivate).toHaveBeenCalledWith(1);
});

it('Escape clears focus when onEscape does not consume it', () => {
render(<Probe />);
key('ArrowDown');
expect(focus()).toBe('0,0');
key('Escape');
expect(focus()).toBe('none');
});

it('Escape keeps focus when onEscape returns true (consumed)', () => {
const onEscape = jest.fn(() => true);
render(<Probe onEscape={onEscape} />);
key('ArrowDown');
key('Escape');
expect(onEscape).toHaveBeenCalled();
expect(focus()).toBe('0,0');
});

it('ignores navigation keys while an input is focused', () => {
render(<Probe />);
fireEvent.keyDown(screen.getByTestId('inp'), { key: 'ArrowDown' });
expect(focus()).toBe('none');
});
});
145 changes: 145 additions & 0 deletions webview/src/keyboard/useGridKeyboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: GPL-3.0-or-later

import React, { useCallback, useEffect, useState } from 'react';

export interface GridFocus {
row: number;
col: number;
}

export interface UseGridKeyboardOptions {
rowCount: number;
colCount: number;
/** Scroll the virtualized list so `row` is visible. */
scrollToRow?: (row: number) => void;
/** Enter on a focused row. */
onActivate?: (row: number) => void;
/** Escape handler; return true if it consumed the key (e.g. collapsed a row),
* otherwise focus is cleared. */
onEscape?: () => boolean | void;
/** Rows moved per PageUp/PageDown. */
pageSize?: number;
}

export interface GridKeyboard {
focused: GridFocus | null;
isFocused(row: number, col: number): boolean;
isRowFocused(row: number): boolean;
focus(row: number, col: number): void;
clear(): void;
handleKeyDown(e: React.KeyboardEvent): void;
}

/**
* Keyboard focus/navigation for a virtualized grid. Owns a focused cell and
* translates arrow / paging / Home-End / Enter / Escape keys into focus moves,
* scrolling the list to keep the focused row visible. Keys are ignored while an
* input/textarea/select is focused so it never fights the find box.
*/
export function useGridKeyboard(opts: UseGridKeyboardOptions): GridKeyboard {
const { rowCount, colCount, scrollToRow, onActivate, onEscape, pageSize = 10 } = opts;
const [focused, setFocused] = useState<GridFocus | null>(null);

const clamp = useCallback(
(row: number, col: number): GridFocus => ({
row: Math.max(0, Math.min(row, rowCount - 1)),
col: Math.max(0, Math.min(col, colCount - 1)),
}),
[rowCount, colCount]
);

// Drop focus if the row set shrank past it (e.g. filter-to-matches).
useEffect(() => {
setFocused((f) => (f && f.row >= rowCount ? null : f));
}, [rowCount]);

const move = useCallback(
(nextRow: number, nextCol: number) => {
if (rowCount === 0) return;
const f = clamp(nextRow, nextCol);
setFocused(f);
scrollToRow?.(f.row);
},
[rowCount, clamp, scrollToRow]
);

const focus = useCallback(
(row: number, col: number) => {
if (rowCount === 0) return;
setFocused(clamp(row, col));
},
[rowCount, clamp]
);

const clear = useCallback(() => setFocused(null), []);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (rowCount === 0) return;

const cur = focused ?? { row: 0, col: 0 };
const has = focused !== null;

switch (e.key) {
case 'ArrowDown':
e.preventDefault();
move(has ? cur.row + 1 : 0, cur.col);
break;
case 'ArrowUp':
e.preventDefault();
move(has ? cur.row - 1 : 0, cur.col);
break;
case 'ArrowRight':
e.preventDefault();
move(cur.row, has ? cur.col + 1 : 0);
break;
case 'ArrowLeft':
e.preventDefault();
move(cur.row, has ? cur.col - 1 : 0);
break;
case 'Home':
e.preventDefault();
if (e.ctrlKey || e.metaKey) move(0, cur.col);
else move(cur.row, 0);
break;
case 'End':
e.preventDefault();
if (e.ctrlKey || e.metaKey) move(rowCount - 1, cur.col);
else move(cur.row, colCount - 1);
break;
case 'PageDown':
e.preventDefault();
move((has ? cur.row : 0) + pageSize, cur.col);
break;
case 'PageUp':
e.preventDefault();
move((has ? cur.row : 0) - pageSize, cur.col);
break;
case 'Enter':
if (has) {
e.preventDefault();
onActivate?.(cur.row);
}
break;
case 'Escape': {
const handled = onEscape?.();
if (!handled) clear();
break;
}
default:
break;
}
},
[focused, rowCount, colCount, move, onActivate, onEscape, pageSize, clear]
);

const isFocused = useCallback(
(row: number, col: number) => focused?.row === row && focused?.col === col,
[focused]
);
const isRowFocused = useCallback((row: number) => focused?.row === row, [focused]);

return { focused, isFocused, isRowFocused, focus, clear, handleKeyDown };
}
Loading