Open clickable rows and cards from the keyboard

A row with onRowClick could only be opened with a mouse or the j/k
cursor: it took no Tab focus and ignored Enter. Clickable rows in the
tables, the grid and the card view are now focusable and open on Enter
or Space, with a focus ring. Keys on a button inside the row still
reach the button, and the cursor listener skips a key a row handled.
This commit is contained in:
Juan Denis committed 2026-10-10 22:17:18 -04:00
1 parent 291b5df844
commit adb9ae8fbb
6 files changed
+48 -7

No files matched your search

@@ -6,11 +6,12 @@ import { createServer } from 'vite';
import react from '@vitejs/plugin-react';
import { chromium } from 'playwright';
// Row actions inside memoized table columns. The columns were memoized
// without the state their delete handlers read, so a delete clicked after
// opening a vault or a variable group used the value from first render: the
// vault refresh read a null vault, and the variable delete sent group null.
// The real pages; only HTTP, toasts, confirm and translation are stubbed.
// List rows on real pages; only HTTP, toasts, confirm and translation are
// stubbed.
// - Row actions inside memoized columns: the columns were memoized without
// the state their delete handlers read, so a delete after opening a vault
// or a variable group used first-render values (a null vault, group null).
// - Keyboard: a clickable row opens with Enter like it does with a click.
const root = fileURLToPath(new URL('../', import.meta.url));
const fixture = `
import React from 'react';
@@ -35,10 +36,10 @@ createRoot(document.getElementById('root')).render(<MemoryRouter>{which==='vault
`;
const server = await createServer({
root, configFile: false, logLevel: 'error', resolve: { alias: { '@': path.join(root, 'src') } },
cacheDir: path.join(root, 'node_modules/.vite-stale-columns-regression'),
cacheDir: path.join(root, 'node_modules/.vite-list-rows-regression'),
optimizeDeps: { entries: [] }, server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'stale-columns-fixtures', enforce: 'pre',
name: 'list-rows-fixtures', enforce: 'pre',
resolveId(id) {
if (id === 'react-i18next') return '\0test-i18n';
if (id.endsWith('/sc-fixture.jsx')) return path.join(root, 'sc-fixture.jsx');
@@ -92,6 +93,18 @@ try {
assert.deepEqual(await groups.evaluate(() => window.calls), [['deleteGroupVariable', 21, 31]]);
assert.deepEqual(groupErrors, []);
console.log('PASS deleting a group variable names the open group');
const keys = await browser.newPage();
const keyErrors = [];
keys.on('pageerror', error => keyErrors.push(error.message));
await keys.goto(`${base}?page=vaults`);
const row = keys.getByRole('row').filter({ hasText: 'alpha-vault' }).first();
await row.focus();
await keys.keyboard.press('Enter');
await keys.getByText('API_TOKEN').first().waitFor({ timeout: 5000 });
assert.deepEqual(await keys.evaluate(() => window.calls), [['getVault', 11]]);
assert.deepEqual(keyErrors, []);
console.log('PASS Enter on a focused row opens it');
} finally {
await browser?.close();
await server.close();
+10
View File
@@ -262,6 +262,8 @@ export function DataTable({
useEffect(() => {
if (!keyboardNav) return undefined;
const onKey = (event) => {
// A focused row already handled its own Enter.
if (event.defaultPrevented) return;
const target = event.target;
// A focused Radix Select (trigger = combobox, open list = listbox)
// owns its arrows and Enter, exactly like the native <select> did.
@@ -345,6 +347,14 @@ export function DataTable({
keyboardNav && rowIndex === cursor && 'is-cursor',
)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={onRowClick ? (e) => {
// Only the row itself: a focused button or input inside keeps its keys.
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onRowClick(row);
}
} : undefined}
>
{selectable && (
<TableCell className="sk-dtable__check" onClick={(e) => e.stopPropagation()}>
@@ -178,6 +178,14 @@ export function DataGrid({
role="row"
className={cn('sk-grid__row', on && 'is-picked', onRowClick && 'is-clickable')}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={onRowClick ? (e) => {
// Only the row itself: a focused button or input inside keeps its keys.
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onRowClick(row);
}
} : undefined}
onContextMenu={onRowContextMenu
? (e) => { e.preventDefault(); onRowContextMenu(row, e); }
: undefined}
@@ -413,6 +413,13 @@ export default function ResourceListPage({
key={item[keyField]}
className={cn('wp-list__cardtile', rowClassName?.(item))}
onClick={() => onRowClick?.(item)}
tabIndex={onRowClick ? 0 : undefined}
onKeyDown={onRowClick ? (e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onRowClick(item);
}
} : undefined}
>
{renderCard(item)}
</div>
@@ -397,6 +397,7 @@
&:last-child { border-bottom: none; }
&.is-clickable { cursor: pointer; }
&.is-clickable:focus-visible { outline: 2px solid var(--accent-dim); outline-offset: -2px; }
&:hover { background: var(--surface-2); }
&.is-picked { background: var(--accent-bg-soft); }
&.is-picked:hover { background: var(--accent-bg); }
@@ -417,6 +417,7 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
tbody td:last-child { padding-inline-end: $space-4; }
tbody tr { transition: background 0.12s; }
tbody tr.is-clickable { cursor: pointer; }
tbody tr.is-clickable:focus-visible { outline: 2px solid var(--accent-dim); outline-offset: -2px; }
tbody tr:hover { background: var(--surface-2); }
tbody tr:last-child td { border-bottom: none; }
tbody tr.is-selected { background: var(--accent-bg-soft); &:hover { background: var(--accent-bg); } }
@@ -1133,6 +1134,7 @@ $sk-tones: ('accent', 'green', 'cyan', 'amber', 'red', 'violet');
transition: border-color 0.14s, transform 0.14s;
&:hover { border-color: var(--border-strong); transform: translateY(-2px); }
&:focus-visible { outline: 2px solid var(--accent-dim); outline-offset: 2px; }
&.is-selected { border-color: var(--accent-dim); background: var(--accent-bg-soft); }
}
}