mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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:
1 parent
291b5df844
commit
adb9ae8fbb
6 files changed
+48
-7
No files matched your search
+20
-7
@@ -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();
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user