Restore first-tap guest details on touch browsers

Reuse the shared non-activating native row marker without moving actions out of delegated event ordering. Preserve nested controls, keyboard disclosure, row identity and conditional action removal. Cover guest/full-drawer touch input and safe backup guidance with exact browser proof and source regressions.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-04 00:31:00 +01:00
parent 888d918ba1
commit d856689f5b
10 changed files with 581 additions and 4 deletions
@@ -623,6 +623,20 @@ scrolling. Explicit caller-owned native listeners remain caller-owned.
isolation and bound handlers; `PulseDataGrid.test.tsx` checks inheritance.
The production PBS table/drawer browser fixture exercises first-tap disclosure
and History in WebKit phone emulation, not installed collection or devices.
The custom Workloads `GuestRow` shell reuses `Table`'s exported
`nativeRowClickTarget` marker, conditional on its own action, without inheriting
shared row styling or changing alert/highlight treatment. Its name and row body
must open the full guest drawer on the first touch even when the compact-layout
disclosure button is visually hidden. Keep the action document-delegated;
nested disclosure SVGs, external links and keyboard controls must still perform
only their own action. Removing the action removes the native marker, and
re-enabling it must work without replacing the row. Pointer-up and touch-end
alone never perform the action. `GuestRow.test.tsx` covers native binding,
non-activation, dynamic removal and child ordering;
`browser-tests/guest-row-touch.cjs` covers trusted row/name taps, the production
full drawer's backup/read guidance, nested controls and shared-row compatibility
on Chromium desktop/phone and WebKit phone VM/CT fixtures. This is emulated
input/presentation proof, not physical-device, native thaw or released acceptance.
`PlatformResourceDetailToggleButton` is the desktop disclosure affordance and
is visually removed on phone layouts where the complete row is the touch target;
provider tables must not add a second mobile chevron. When row activation
@@ -198,6 +198,15 @@ poller, per-agent goroutine, inventory scan, or action-session lookup to normal
request routing; local assets are streamed once and published fallbacks retain
the existing bounded HTTP client and artifact-size checks.
The custom Workloads guest row shares `Table`'s singleton non-activating native
click marker for WebKit touch eligibility. Bind it only while a row action
exists, without changing row identity, alert/highlight classes, metric derivation
or delegated child-control ordering. The marker must not perform actions, read
telemetry, start History work, add polling/timers or activate on pointer-up or
touch-end. `GuestRow.test.tsx` verifies its conditional lifecycle and
non-activation; the production row/full-drawer touch fixture verifies first
activation and unchanged identity independently of native collector acceptance.
## Canonical Files
1. `pkg/metrics/store.go`
@@ -0,0 +1,299 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { chromium, webkit } = require('playwright');
const root = '/workspace/frontend-modern';
const output = '/workspace/tmp/guest-row-touch';
const origin = 'http://127.0.0.1:5313';
const runtime = [
'frontend-modern/src/components/Workloads/GuestRow.tsx',
'frontend-modern/src/components/shared/Table.tsx',
];
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
(async () => {
fs.mkdirSync(output, { recursive: true });
const result = {
playwright: require('playwright/package.json').version,
runtime_hashes: Object.fromEntries(runtime.map((file) => [file, sha256('/workspace/' + file)])),
results: [],
screenshots: [],
result: 'incomplete',
};
assert.equal(
result.playwright,
JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
'node_modules/@playwright/test'
].version,
);
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
cacheDir: output + '/cache',
server: { host: '127.0.0.1', port: 5313, strictPort: true },
});
let browser;
try {
await server.listen();
for (const [name, engine, width, dark, kinds] of [
['webkit-phone', webkit, 390, true, ['qemu', 'lxc']],
['chromium-desktop', chromium, 1365, false, ['qemu', 'lxc']],
['chromium-phone', chromium, 390, false, ['qemu']],
['webkit-narrow', webkit, 360, false, ['lxc']],
]) {
browser = await engine.launch(
engine === chromium
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
: { headless: true },
);
const context = await browser.newContext({
viewport: { width, height: width <= 768 ? 844 : 900 },
isMobile: width <= 768,
hasTouch: width <= 768,
locale: 'en-GB',
timezoneId: 'UTC',
});
for (const kind of kinds) {
const page = await context.newPage();
page.setDefaultTimeout(15000);
page.setDefaultNavigationTimeout(60000);
const record = {
name,
kind,
browser: browser.version(),
viewport: { width, height: width <= 768 ? 844 : 900 },
dark,
errors: [],
requests: [],
events: [],
checks: [],
};
result.results.push(record);
page.on('pageerror', (error) => record.errors.push(error.message));
await page.route('**/*', (route) => {
const request = route.request();
const url = new URL(request.url());
record.requests.push({
method: request.method(),
origin: url.origin,
path: url.pathname,
});
if (url.origin !== origin) return route.abort();
if (url.pathname.startsWith('/api/'))
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: [],
alerts: [],
anomalies: [],
config: null,
}),
});
return route.continue();
});
await page.addInitScript((isDark) => {
window.__inputEvents = [];
for (const type of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'click'])
document.addEventListener(
type,
(event) =>
window.__inputEvents.push({
type,
tag: event.target.tagName,
trusted: event.isTrusted,
pointerType: event.pointerType,
}),
true,
);
document.addEventListener('DOMContentLoaded', () => {
if (isDark) document.documentElement.classList.add('dark');
});
}, dark);
await page.goto(`${origin}/browser-tests/guest-row-touch.html?kind=${kind}`);
await page.waitForFunction(() => window.__guestRowTouch);
assert.equal(await page.evaluate(() => innerWidth), width);
const row = page.locator('[data-guest-id]');
const nameTarget = row.getByText('backup-guest', { exact: true });
const drawer = page.getByRole('region', { name: 'Guest details' });
const disclosure = row.locator('[data-row-action]');
await page.evaluate(() => {
window.__originalGuestRow = document.querySelector('[data-guest-id]');
});
const state = () =>
page.evaluate(() => ({
count: window.__guestRowTouch.actionCount(),
expanded: window.__guestRowTouch.expanded(),
closeCount: window.__guestRowTouch.closeCount(),
disclosureExpanded: document
.querySelector('[data-row-action]')
?.getAttribute('aria-expanded'),
sameRow: document.querySelector('[data-guest-id]') === window.__originalGuestRow,
}));
const activate = async (target) => {
if (width <= 768) await target.tap();
else await target.click();
await page.waitForTimeout(650);
};
const expectState = async (count, expanded) => {
const value = await state();
record.state = value;
record.events = await page.evaluate(() => window.__inputEvents);
assert.equal(value.count, count, `${name}/${kind}: single row activation`);
assert.equal(value.expanded, expanded);
assert.equal(value.disclosureExpanded, String(expanded));
assert.equal(value.sameRow, true);
};
try {
await activate(width === 360 ? row : nameTarget);
await expectState(1, true);
assert(record.events.some((event) => event.type === 'click' && event.trusted));
if (width <= 768) {
assert(record.events.some((event) => event.type === 'touchend' && event.trusted));
assert.equal(await row.getAttribute('data-history-lens-active'), null);
}
await drawer.getByText('No completed backup found', { exact: true }).waitFor();
await drawer.getByText('Running · not completed yet', { exact: true }).waitFor();
if (kind === 'qemu')
await drawer.getByText(/Using last known disk stats\. Guest reads paused/).waitFor();
assert.equal(
await disclosure.getAttribute('aria-controls'),
await drawer.getAttribute('id'),
);
assert.equal(await row.getAttribute('tabindex'), null);
assert.equal(await row.getAttribute('aria-expanded'), null);
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
record.checks.push(
`first trusted ${width === 360 ? 'row-body' : 'name'} activation opens production full drawer with safe backup/read context`,
);
const screenshot = `${output}/${name}-${kind}-open.png`;
await page.screenshot({ path: screenshot, fullPage: true });
result.screenshots.push({
path: path.relative('/workspace', screenshot),
sha256: sha256(screenshot),
});
await activate(width === 360 ? row : nameTarget);
await expectState(2, false);
await activate(row);
await expectState(3, true);
await activate(page.getByRole('button', { name: 'Collapse backup-guest details' }));
await expectState(3, false);
assert.equal((await state()).closeCount, 1);
record.checks.push(
'repeated name and row-centre activation toggle once; nested full-drawer collapse stays independent',
);
await disclosure.focus();
await page.keyboard.press('Enter');
await expectState(4, true);
await page.keyboard.press('Space');
await expectState(5, false);
await disclosure.locator('svg').click();
await expectState(6, true);
// Keyboard focus makes the compact disclosure button visible and
// can consume the name's space; the row body stays the touch target.
await activate(row);
await expectState(7, false);
record.checks.push(
'native disclosure keyboard Enter/Space and nested SVG click do not double-activate',
);
if (width >= 1024) {
// The adjacent link is intentionally hidden in condensed phone
// identity. Exercise its real wide-layout action, preventing only
// external navigation, not production propagation handling.
const link = row.getByRole('link', { name: 'Open web interface for backup-guest' });
await link.evaluate((anchor) =>
anchor.addEventListener('click', (event) => event.preventDefault()),
);
await activate(link.locator('svg'));
await expectState(7, false);
record.checks.push(
'real external-link SVG retains its separate action; navigation prevented in fixture only',
);
}
await page.evaluate(() => window.__guestRowTouch.enabled(false));
assert.equal(await disclosure.count(), 0);
await activate(row);
assert.equal((await state()).count, 7);
assert.equal((await state()).expanded, false);
await page.evaluate(() => window.__guestRowTouch.enabled(true));
await activate(row);
await expectState(8, true);
record.checks.push(
'disabling/re-enabling the row action preserves node identity and first-tap activation',
);
assert.deepEqual(record.errors, []);
assert(
record.requests.every(
(request) => request.origin === origin && request.method === 'GET',
),
);
record.result = 'passed';
} catch (error) {
record.result = 'failed';
record.error = error.message;
const screenshot = `${output}/${name}-${kind}-failed.png`;
await page.screenshot({ path: screenshot, fullPage: true });
result.screenshots.push({
path: path.relative('/workspace', screenshot),
sha256: sha256(screenshot),
});
throw error;
} finally {
await page.close();
}
}
// Exporting the compatibility marker must preserve the original shared
// TableRow's own single-action and embedded-control ordering.
const page = await context.newPage();
await page.goto(`${origin}/browser-tests/table-row-touch.html`);
const sharedState = () => page.getByRole('status', { name: 'Action state' }).textContent();
const shared = page.locator('[data-testid="control-row"]');
if (width <= 768) await shared.getByText('Shared resource name', { exact: true }).tap();
else await shared.getByText('Shared resource name', { exact: true }).click();
const sharedValue = JSON.parse(await sharedState());
assert.equal(sharedValue.rowActions, 1);
await shared.getByRole('button', { name: 'Child action' }).click();
const childValue = JSON.parse(await sharedState());
assert.equal(childValue.rowActions, 1);
assert.equal(childValue.childActions, 1);
result.results.push({
name,
control: 'shared-TableRow',
single: sharedValue,
child: childValue,
result: 'passed',
});
await page.close();
await context.close();
await browser.close();
browser = null;
}
result.result = 'passed';
result.verified_at = new Date().toISOString();
console.log(
'PASS: trusted guest row/disclosure activation, full drawer safety context and child isolation',
);
} catch (error) {
result.result = 'failed';
result.error = error.message;
throw error;
} finally {
fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error.stack);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Guest row touch verification</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./guest-row-touch.tsx"></script>
</body>
</html>
@@ -0,0 +1,123 @@
// Production guest row and full drawer with synthetic observations.
// No collector, backup command, QGA/thaw, installed device or release proof.
import { createSignal, Show } from 'solid-js';
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { GuestRow } from '../src/components/Workloads/GuestRow';
import { buildSummaryDisclosureControlsId } from '../src/components/shared/summaryInteractionA11y';
import type { WorkloadTableLayoutMode } from '../src/components/Workloads/guestRowModel';
import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
import { DarkModeContext } from '../src/contexts/appRuntime';
import { syncAIRuntimeSettings } from '../src/stores/aiRuntimeState';
import type { WorkloadGuest } from '../src/types/workloads';
import '../src/index.css';
syncAIRuntimeSettings({ discovery_enabled: false });
function Fixture() {
const [observation, setObservation] = createSignal({
lastBackup: 0,
running: true,
reason: 'prev-vm-locked',
});
const [closeCount, setCloseCount] = createSignal(0);
const [actionCount, setActionCount] = createSignal(0);
const [expanded, setExpanded] = createSignal(false);
const [enabled, setEnabled] = createSignal(true);
const [layout, setLayout] = createSignal<WorkloadTableLayoutMode>(
window.innerWidth <= 768 ? 'phone' : 'wide',
);
const toggle = () => {
setActionCount((count) => count + 1);
setExpanded((value) => !value);
};
const type = new URLSearchParams(window.location.search).get('kind') === 'lxc' ? 'lxc' : 'qemu';
const guest = (): WorkloadGuest => ({
id: 'fixture-pve:pve-a:101',
vmid: 101,
name: 'backup-guest',
node: 'pve-a',
instance: 'fixture-pve',
status: 'running',
type,
cpu: 0.1,
cpus: 2,
memory: { total: 1024 ** 3, used: 256 * 1024 ** 2, free: 768 * 1024 ** 2, usage: 25 },
disk: { total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
disks: [
{ mountpoint: '/data', type: 'ext4', total: 10 * 1024 ** 3, used: 5 * 1024 ** 3, usage: 50 },
],
diskStatusReason: observation().reason,
networkIn: 0,
networkOut: 0,
diskRead: 0,
diskWrite: 0,
uptime: 3600,
template: false,
lastBackup: observation().lastBackup,
backupInProgress: observation().running,
tags: [],
lock: observation().running ? 'backup' : '',
lastSeen: '2026-10-03T12:00:00Z',
});
(window as any).__guestRowTouch = {
update: setObservation,
closeCount,
actionCount,
expanded,
enabled: setEnabled,
layout: setLayout,
identity: () => guest().id,
};
return (
<main class="min-h-screen space-y-3 bg-surface p-3 text-base-content">
<h1 class="text-base font-semibold">Guest row touch verification</h1>
<p class="text-xs text-muted">Synthetic observations; no backup command is sent.</p>
<table class="w-full table-fixed">
<tbody>
<GuestRow
guest={guest()}
visibleColumnIds={['name', 'disk']}
onClick={enabled() ? toggle : undefined}
isExpanded={expanded()}
workloadTableLayoutMode={layout()}
customUrl="https://guest.example.invalid/"
/>
</tbody>
</table>
<Show when={expanded()}>
<section
id={buildSummaryDisclosureControlsId(guest().id)}
aria-label="Guest details"
class="mx-auto max-w-4xl"
>
<GuestDrawer
guest={guest()}
onClose={() => {
setCloseCount((count) => count + 1);
setExpanded(false);
}}
/>
</section>
</Show>
</main>
);
}
render(
() => (
<Router>
<Route
path="*"
component={() => (
<DarkModeContext.Provider
value={() => document.documentElement.classList.contains('dark')}
>
<Fixture />
</DarkModeContext.Provider>
)}
/>
</Router>
),
document.getElementById('root')!,
);
@@ -0,0 +1,45 @@
{
"version": 1,
"base_sha": "888d918ba1a0a4bf448542630bc0f47af72e4572",
"verified_at": "2026-10-03T23:27:41.013Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/shared/Table.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestRow.tsx": "1096ad3669baea8bec5c6a80b016b64f2c428d2e5a0b7c85746ff6bf21e103fa",
"frontend-modern/src/components/shared/Table.tsx": "014d9c4382d4de03a547f171e410fb9207686bf00dd76695b50c98249d6eacbd"
},
"routes": [
"/browser-tests/guest-row-touch.html?kind=qemu",
"/browser-tests/guest-row-touch.html?kind=lxc",
"/browser-tests/table-row-touch.html"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
},
{
"width": 360,
"height": 844
}
],
"states": [
"Production GuestRow and full GuestDrawer, production CSS and delegated actions, synthetic VM/CT observations. Playwright 1.56.1 matches integration lock; Chromium 141.0.7390.37 desktop/phone and WebKit 26.0 phone. Six screenshots opened and inspected.",
"Trusted first name/row-body activation reveals missing completed backup, separately cautionary running activity and retained VM-lock disk guidance. The 360px CT control uses the row body. Full drawer safety copy wraps without page overflow; the two-column fixture is not full Workloads table layout acceptance.",
"Row remains a table row, not an extra tab stop or duplicate expanded control. Native disclosure retains matching aria-expanded and aria-controls, conditional action removal/re-enable preserves the same row node.",
"Emulated input and presentation only: no physical phone, real guest collector, QGA/thaw, installed recovery or released-availability assertion. Earlier failed parent activation and two adverse harness runs remain retained separately."
],
"interactions": [
"Trusted Playwright touch taps emit click and toggle the same row exactly once; repeated name/row taps and production drawer collapse preserve independent action counts. Touch entry does not activate a hover history lens.",
"Focused native disclosure Enter/Space and nested SVG click each toggle once. The row body remains usable while keyboard focus makes compact disclosure visible. No touch-end or pointer-up action is added.",
"Real adjacent external-link SVG is exercised on the actual wide layout with only navigation prevented; production propagation isolation keeps the row closed. Phone identity intentionally hides that link; it is not forced into a non-condensed phone layout.",
"Removing the row action prevents activation and removes disclosure; re-enabling restores first-tap activation without remount. Exported compatibility marker preserves shared TableRow single action and delegated child-button isolation. Guest fixtures issue only local GET requests and report no page errors."
]
}
@@ -27,6 +27,7 @@ import {
createSummaryInteractiveRowPreviewHandlers,
} from '@/components/shared/summaryInteractionA11y';
import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButton';
import { nativeRowClickTarget } from '@/components/shared/Table';
import { DiscoveryReadinessBadge } from '@/components/shared/DiscoveryReadinessBadge';
import { getWorkloadGuestDiskStatusMessage } from '@/utils/workloadGuestPresentation';
import { ResourceNameWithWebInterfaceLink } from '@/components/shared/WebInterfaceLink';
@@ -241,6 +242,7 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'}
on:click={props.onClick ? nativeRowClickTarget : undefined}
onClick={props.onClick}
{...interactiveRowHandlers}
>
@@ -212,6 +212,7 @@ afterEach(() => {
cleanup();
vi.useRealTimers();
vi.clearAllMocks();
vi.restoreAllMocks();
isMobileMock.mockReturnValue(false);
});
@@ -835,6 +836,70 @@ describe('GuestRow', () => {
expect(onClick).toHaveBeenCalledOnce();
});
it('marks only actionable guest rows as native touch click targets', () => {
const addListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
const removeListener = vi.spyOn(HTMLTableRowElement.prototype, 'removeEventListener');
const onClick = vi.fn();
const [enabled, setEnabled] = createSignal(false);
const { container } = render(() => (
<table>
<tbody>
<GuestRow
guest={makeGuest()}
workloadTableLayoutMode="phone"
onClick={enabled() ? onClick : undefined}
/>
</tbody>
</table>
));
const row = container.querySelector('tr')!;
const nativeClickCalls = () => addListener.mock.calls.filter(([type]) => type === 'click');
expect(nativeClickCalls()).toHaveLength(0);
setEnabled(true);
expect(nativeClickCalls()).toHaveLength(1);
expect(row).not.toHaveAttribute('tabindex');
expect(row).not.toHaveAttribute('role', 'button');
expect(row).not.toHaveAttribute('aria-expanded');
// The native compatibility target must never perform the action itself.
const nativeClick = nativeClickCalls()[0][1] as EventListener;
nativeClick.call(row, new MouseEvent('click'));
fireEvent.touchEnd(row);
fireEvent.pointerUp(row, { pointerType: 'touch' });
expect(onClick).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('test-vm'));
expect(onClick).toHaveBeenCalledTimes(1);
setEnabled(false);
expect(removeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
fireEvent.click(row);
expect(onClick).toHaveBeenCalledTimes(1);
setEnabled(true);
fireEvent.click(screen.getByText('test-vm'));
expect(onClick).toHaveBeenCalledTimes(2);
});
it('keeps child disclosure and external-link actions ahead of guest row activation', () => {
const onClick = vi.fn();
const { container } = renderGuestRow({
guest: makeGuest(),
customUrl: 'https://guest.example.invalid/',
onClick,
});
fireEvent.click(screen.getByRole('link', { name: 'Open web interface for test-vm' }));
expect(onClick).not.toHaveBeenCalled();
const button = screen.getByRole('button', { name: 'Expand test-vm' });
fireEvent.click(button.querySelector('svg')!);
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(button, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(2);
fireEvent.keyDown(button, { key: ' ', code: 'Space' });
expect(onClick).toHaveBeenCalledTimes(3);
fireEvent.keyDown(container.querySelector('tr')!, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(3);
});
it('calls onHoverChange with canonical guestId on fine-pointer preview', () => {
const onHoverChange = vi.fn();
const { container } = renderGuestRow({
@@ -66,7 +66,8 @@ export type TableRowProps = JSX.HTMLAttributes<HTMLTableRowElement>;
// WebKit touch needs a native click target on otherwise-static table rows.
// Keep actions document-delegated: nested controls must run first and retain
// their existing stopPropagation behaviour instead of opening the row too.
const nativeRowClickTarget = () => undefined;
// Custom row shells reuse this marker without inheriting TableRow's styling.
export const nativeRowClickTarget = () => undefined;
export function TableRow(props: TableRowProps) {
const [local, rest] = splitProps(props, ['class', 'children']);
@@ -5,7 +5,7 @@ import pulseDataGridSource from '@/components/shared/PulseDataGrid.tsx?raw';
import pulseDataGridModelSource from '@/components/shared/pulseDataGridModel.ts?raw';
import pulseDataGridStateSource from '@/components/shared/usePulseDataGridState.ts?raw';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import { TableCell } from '@/components/shared/Table';
import { TableCell, nativeRowClickTarget } from '@/components/shared/Table';
import tableSource from '@/components/shared/Table.tsx?raw';
type TestRow = {
@@ -105,16 +105,23 @@ describe('PulseDataGrid', () => {
it('inherits the native touch click target from the shared TableRow', () => {
const nativeListener = vi.spyOn(HTMLTableRowElement.prototype, 'addEventListener');
const onRowClick = vi.fn();
try {
render(() => (
<PulseDataGrid<TestRow>
data={[{ id: '1', name: 'Touch tower' }]}
columns={[{ key: 'name', label: 'Name' }]}
keyExtractor={(row) => row.id}
onRowClick={vi.fn()}
onRowClick={onRowClick}
/>
));
expect(nativeListener.mock.calls.some(([type]) => type === 'click')).toBe(true);
expect(nativeListener.mock.calls.find(([type]) => type === 'click')?.[1]).toBe(
nativeRowClickTarget,
);
nativeRowClickTarget();
expect(onRowClick).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('Touch tower'));
expect(onRowClick).toHaveBeenCalledTimes(1);
} finally {
nativeListener.mockRestore();
}