diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0bf42b1a70..7760e00f05 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 1aa8041768..db65ab3598 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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` diff --git a/frontend-modern/browser-tests/guest-row-touch.cjs b/frontend-modern/browser-tests/guest-row-touch.cjs new file mode 100644 index 0000000000..b75faf4c31 --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/guest-row-touch.html b/frontend-modern/browser-tests/guest-row-touch.html new file mode 100644 index 0000000000..c3c2c52ed3 --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.html @@ -0,0 +1,12 @@ + + + + + + Guest row touch verification + + +
+ + + diff --git a/frontend-modern/browser-tests/guest-row-touch.tsx b/frontend-modern/browser-tests/guest-row-touch.tsx new file mode 100644 index 0000000000..539499c43e --- /dev/null +++ b/frontend-modern/browser-tests/guest-row-touch.tsx @@ -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( + 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 ( +
+

Guest row touch verification

+

Synthetic observations; no backup command is sent.

+ + + + +
+ +
+ { + setCloseCount((count) => count + 1); + setExpanded(false); + }} + /> +
+
+
+ ); +} + +render( + () => ( + + ( + document.documentElement.classList.contains('dark')} + > + + + )} + /> + + ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification/7139f2120612aedc.json b/frontend-modern/browser-verification/7139f2120612aedc.json new file mode 100644 index 0000000000..604657fe02 --- /dev/null +++ b/frontend-modern/browser-verification/7139f2120612aedc.json @@ -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." + ] +} diff --git a/frontend-modern/browser-verification/9596a94267f10b6d.json b/frontend-modern/browser-verification/9596a94267f10b6d.json new file mode 100644 index 0000000000..b18aa6a289 --- /dev/null +++ b/frontend-modern/browser-verification/9596a94267f10b6d.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "base_sha": "d856689f5b3b200c942db4151a0b657cfdacf1c4", + "verified_at": "2026-10-03T23:44:57.288Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/components/Workloads/GuestRow.tsx" + ], + "content_sha256": { + "frontend-modern/src/components/Workloads/GuestRow.tsx": "e531b8c4584a8a8f4c76a3205b472ae6cc41c15780cf70a0205b06f7bdf553ed" + }, + "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.", + "Final GuestRow uses typed splitProps forwarding, matching the shared TableRow native/delegated pattern after the initial JSX lint failure. No lint rule was disabled; all browser interaction checks rerun on these exact final bytes." + ], + "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." + ] +} diff --git a/frontend-modern/src/components/Workloads/GuestRow.tsx b/frontend-modern/src/components/Workloads/GuestRow.tsx index 2622930599..784665c66b 100644 --- a/frontend-modern/src/components/Workloads/GuestRow.tsx +++ b/frontend-modern/src/components/Workloads/GuestRow.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, Show } from 'solid-js'; +import { createMemo, createSignal, Show, splitProps } from 'solid-js'; import BoxIcon from 'lucide-solid/icons/box'; import type { VM } from '@/types/api'; import type { WorkloadGuest } from '@/types/workloads'; @@ -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'; @@ -49,6 +50,7 @@ export type { GuestRowProps, WorkloadIOEmphasis } from './guestRowModel'; import { getGuestColumnStyle } from './guestRowModel'; export function GuestRow(props: GuestRowProps) { + const [rowActionProps] = splitProps(props, ['onClick']); const { agentVersion, appContainerRuntimeBadge, @@ -241,7 +243,8 @@ export function GuestRow(props: GuestRowProps) { : undefined } data-summary-row-active={props.isSummaryHighlighted && !props.isExpanded ? 'true' : 'false'} - onClick={props.onClick} + on:click={rowActionProps.onClick ? nativeRowClickTarget : undefined} + {...rowActionProps} {...interactiveRowHandlers} > {/* Name - always visible */} diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx index 42b1032ce3..16dd981390 100644 --- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx @@ -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(() => ( + + + + +
+ )); + 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({ diff --git a/frontend-modern/src/components/shared/Table.tsx b/frontend-modern/src/components/shared/Table.tsx index a39c4ebabc..7b87a7d588 100644 --- a/frontend-modern/src/components/shared/Table.tsx +++ b/frontend-modern/src/components/shared/Table.tsx @@ -66,7 +66,8 @@ export type TableRowProps = JSX.HTMLAttributes; // 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']); diff --git a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx index b42076dff2..b3da84eeb9 100644 --- a/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/PulseDataGrid.test.tsx @@ -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(() => ( 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(); }