mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user