diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d945c80542..e0d9578876 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7729,3 +7729,26 @@ actual series pixels and tooltips in desktop Chromium and phone WebKit, using the production chart and API transport with synthetic stored samples. This does not establish native collection, whole-report resolution or availability in a release. Existing request, source, units and access boundaries are unchanged. + +### Shared History touch inspection preserves native scrolling + +A primary touch tap inside a Storage History plot selects the nearest actual +observation at that horizontal position. Compatibility mouse focus must not +replace that reading with the latest sample or announce it as keyboard input. +The existing hover group and timestamp reconciliation remain shared with mouse +and keyboard inspection. Single measured-zero observations remain inspectable; +empty data and a changed selection cannot inherit an old touch. + +Movement beyond ten CSS pixels, cancellation, multiple contacts and release +outside the plot are not taps. Inspection neither prevents native touch defaults +nor captures the pointer or changes touch-action: page scrolling and pinch zoom +remain browser-owned. Blur and Escape clear inspection, and supported keyboard +keys resume the existing dated, politely announced sample navigation. + +Mounted `HistoryChart.test.tsx` regressions cover focus ordering, gesture +rejection, selection ownership, refresh, empty/zero and input coexistence. +`browser-tests/history-touch.cjs` exercises actual touchscreen taps through the +production disk/pool details and API transport in phone Chromium/WebKit, native +vertical panning in Chromium, and desktop mouse/keyboard controls. Synthetic +sample presentation is not native collector, physical device, assistive-device +or installed whole-report acceptance. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 4806fe41a8..ad7c1b1ee9 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6295,3 +6295,12 @@ This changes no collector, health thresholds, history policy or server identity. `frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx` exercises snapshot replacement, in-place reconciliation, missing evidence, target changes and fault/recovery using the real keyed renderer. + +Touch inspection in the existing pool and physical-disk History charts selects +an actual stored reading rather than substituting the latest sample on focus. +Page pan/cancel and multi-contact gestures do not imply a selected observation; +browser-owned scrolling remains available. This changes no source target, API, +units, collection, retention, access or recovery boundary. The production +disk/pool fixture `frontend-modern/browser-tests/history-touch.cjs` and mounted +`HistoryChart.test.tsx` regressions verify presentation/input behaviour only, +not live appliance collection or recovery success. diff --git a/frontend-modern/browser-tests/history-touch.cjs b/frontend-modern/browser-tests/history-touch.cjs new file mode 100644 index 0000000000..d00d447fb3 --- /dev/null +++ b/frontend-modern/browser-tests/history-touch.cjs @@ -0,0 +1,181 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules/history-touch-proof'); + fs.mkdirSync(artifacts, { recursive: true }); + 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: fs.mkdtempSync(path.join(artifacts, 'vite-')), + server: { host: '127.0.0.1', port: 5227, strictPort: true }, + }); + const parent = false; + let browser; + const observations = []; + const timestamp = Date.parse('2026-10-02T10:00:00Z'); + const point = (value, index = 0) => ({ timestamp: timestamp + index * 60000, value, min: value, max: value }); + const playwright = require('playwright/package.json').version; + const expectedPlaywright = JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json', 'utf8')).packages['node_modules/@playwright/test'].version; + assert.equal(playwright, expectedPlaywright); + try { + await server.listen(); + const configs = [ + ['chromium-desktop', chromium, false, false], + ['chromium-phone', chromium, true, false], + ['webkit-phone', webkit, true, true], + ]; + for (const [name, engine, touch, dark] of configs) { + if (parent && !touch) continue; + browser = await engine.launch(engine === chromium ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true }); + const page = await browser.newPage({ + viewport: touch ? { width: 390, height: 844 } : { width: 1365, height: 900 }, + hasTouch: touch, isMobile: touch, timezoneId: 'Europe/London', + }); + page.setDefaultTimeout(15000); + const errors = []; + const requests = []; + page.on('pageerror', error => errors.push(error.message)); + let samples = [point(10), point(20, 1), point(30, 2)]; + await page.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5227') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname === '/api/metrics-store/history') { + requests.push(Object.fromEntries(url.searchParams)); + return route.fulfill({ json: { points: samples, source: 'store' } }); + } + return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' + ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } + : { data: [], enabled: false } }); + }); + await page.goto('http://127.0.0.1:5227/browser-tests/history-touch.html'); + if (dark) await page.evaluate(() => document.documentElement.classList.add('dark')); + const disk = page.getByRole('region', { name: 'Disk fixture' }); + const pool = page.getByRole('region', { name: 'Pool fixture' }); + await disk.getByRole('tab', { name: 'History', exact: true }).click(); + const busy = disk.getByRole('img', { name: 'Busy chart', exact: true }); + const description = chart => chart.evaluate(el => document.getElementById(el.getAttribute('aria-describedby')).textContent); + await page.waitForFunction(() => [...document.querySelectorAll('canvas')].some(el => el.getAttribute('aria-label') === 'Busy chart' && document.getElementById(el.getAttribute('aria-describedby')).textContent.includes('3 data points'))); + const tooltip = chart => chart.locator('..').locator('[data-history-chart-tooltip]'); + const live = chart => chart.locator('..').locator('[aria-live="polite"]'); + async function inspect(chart, fraction) { + await chart.scrollIntoViewIfNeeded(); + const box = await chart.boundingBox(); + // Percent labels reserve 40px at left; the time label reserves less than + // 25px at right. These interior positions are nearest to each real sample. + const x = box.x + 45 + (box.width - 70) * fraction; + const y = box.y + box.height / 2; + if (touch) await page.touchscreen.tap(x, y); + else await page.mouse.move(x, y); + await page.waitForTimeout(80); + return (await tooltip(chart).count()) ? await tooltip(chart).textContent() : null; + } + async function record(phase, chart, text) { + const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth })); + assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions)); + observations.push({ name, browser: browser.version(), touch, dark, phase, text, description: await description(chart), dimensions }); + await page.screenshot({ path: path.join(artifacts, `${parent ? 'parent-' : ''}${name}-${phase}.png`) }); + } + if (parent) { + for (const [phase, fraction, expected] of [['first', 0.04, '10.0%'], ['middle', 0.5, '20.0%']]) { + const text = await inspect(busy, fraction); + if (phase === 'first') assert.ok(!text?.includes(expected), `predecessor unexpectedly inspected the first tap: ${text}`); + await record(phase, busy, text); + } + } else { + for (const [phase, fraction, expected] of [['first', 0.04, '10.0%'], ['middle', 0.5, '20.0%'], ['last', 0.96, '30.0%']]) { + const text = await inspect(busy, fraction); + assert.ok(text?.includes(expected), `${name} ${phase}: ${text}`); + assert.equal(await live(busy).textContent(), '', 'pointer inspection must not speak keyboard announcements'); + assert.equal(await disk.locator('[data-history-chart-tooltip]').count(), 3, 'live I/O group shares inspection'); + await record(phase, busy, text); + } + if (touch) { + const read = disk.getByRole('img', { name: 'Read chart', exact: true }); + assert.ok((await inspect(read, 0.5))?.includes('20.0 B/s')); + assert.ok((await tooltip(busy).textContent()).includes('20.0%'), 'a peer blur must not clear the new group tap'); + assert.equal(await live(read).textContent(), ''); + await inspect(busy, 0.5); + // A hybrid device may switch back to a real mouse after a tap. + await busy.scrollIntoViewIfNeeded(); + const box = await busy.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + assert.ok((await tooltip(busy).textContent()).includes('20.0%')); + await page.mouse.move(0, 0); + assert.equal(await tooltip(busy).count(), 0); + } + await busy.focus(); + for (const [key, expected] of [['Home', '10.0%'], ['ArrowRight', '20.0%'], ['End', '30.0%']]) { + await page.keyboard.press(key); + assert.ok((await live(busy).textContent()).includes(expected)); + } + await page.keyboard.press('Escape'); + assert.equal(await tooltip(busy).count(), 0); + await page.getByRole('button', { name: 'After charts' }).focus(); + assert.equal(await live(busy).textContent(), ''); + + if (touch && engine === chromium) { + await busy.scrollIntoViewIfNeeded(); + await inspect(busy, 0.5); + const box = await busy.boundingBox(); + const session = await page.context().newCDPSession(page); + await busy.evaluate(el => { + el.dataset.cancelCount = '0'; + el.addEventListener('pointercancel', () => el.dataset.cancelCount = String(Number(el.dataset.cancelCount) + 1)); + }); + const scrollMetrics = await page.evaluate(() => ({ before: scrollY, maximum: document.documentElement.scrollHeight - innerHeight })); + assert.ok(scrollMetrics.maximum > scrollMetrics.before + 200, 'the fixture must have room to scroll'); + const before = scrollMetrics.before; + const x = box.x + box.width / 2, y = box.y + box.height / 2; + await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] }); + for (let step = 1; step <= 5; step++) { + await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y: y - step * 20, id: 1 }] }); + await page.waitForTimeout(50); + } + await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForTimeout(300); + const after = await page.evaluate(() => scrollY); + fs.writeFileSync(path.join(artifacts, 'pan-control.json'), JSON.stringify({before, after, pointerCancels: await busy.getAttribute('data-cancel-count'), touchAction: await busy.evaluate(el => getComputedStyle(el).touchAction)},null,2)); + assert.ok(after > before + 40, `native pan must scroll: ${before} -> ${after}`); + assert.ok(Number(await busy.getAttribute('data-cancel-count')) > 0); + assert.equal(await tooltip(busy).count(), 0, 'native scrolling must not select a reading'); + observations.push({ name, phase: 'native-vertical-pan', before, after, maximum: scrollMetrics.maximum, pointerCancels: Number(await busy.getAttribute('data-cancel-count')), touchAction: await busy.evaluate(el => getComputedStyle(el).touchAction) }); + await session.detach(); + } + + await pool.getByRole('tab', { name: 'History', exact: true }).click(); + const usage = pool.getByRole('img', { name: 'Usage chart', exact: true }); + await usage.waitFor(); + await page.waitForFunction(() => [...document.querySelectorAll('canvas')].some(el => el.getAttribute('aria-label') === 'Usage chart' && document.getElementById(el.getAttribute('aria-describedby')).textContent.includes('3 data points'))); + assert.ok((await inspect(usage, 0.04))?.includes('10.0%')); + await record('pool-first', usage, await tooltip(usage).textContent()); + assert.equal(await pool.getByLabel('Capacity history range').inputValue(), '7d'); + samples = [point(0)]; + await pool.getByLabel('Capacity history range').selectOption('24h'); + await page.waitForFunction(() => [...document.querySelectorAll('canvas')].some(el => el.getAttribute('aria-label') === 'Usage chart' && document.getElementById(el.getAttribute('aria-describedby')).textContent.includes('1 data point'))); + assert.ok((await inspect(usage, 0.5))?.includes('0.0%')); + await record('pool-zero-singleton', usage, await tooltip(usage).textContent()); + samples = []; + await pool.getByLabel('Capacity history range').selectOption('7d'); + await pool.getByText('No history samples in this time range.', { exact: true }).waitFor(); + assert.equal(await inspect(usage, 0.5), null); + assert.equal(await live(usage).textContent(), ''); + await record('pool-empty', usage, null); + assert.ok(requests.some(request => request.resourceType === 'disk' && request.resourceId === 'fixture-disk')); + assert.ok(requests.some(request => request.resourceType === 'storage' && request.resourceId === 'fixture-pool')); + } + assert.deepEqual(errors, []); + await browser.close(); browser = null; + } + fs.writeFileSync(path.join(artifacts, parent ? 'parent.json' : 'result.json'), JSON.stringify({ playwright, expectedPlaywright, parent, observations }, null, 2)); + console.log(JSON.stringify({ result: 'passed', parent, observations: observations.length })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/history-touch.html b/frontend-modern/browser-tests/history-touch.html new file mode 100644 index 0000000000..ec56435f82 --- /dev/null +++ b/frontend-modern/browser-tests/history-touch.html @@ -0,0 +1 @@ +Storage History touch verification
diff --git a/frontend-modern/browser-tests/history-touch.tsx b/frontend-modern/browser-tests/history-touch.tsx new file mode 100644 index 0000000000..c5eef346b3 --- /dev/null +++ b/frontend-modern/browser-tests/history-touch.tsx @@ -0,0 +1,60 @@ +import { render } from 'solid-js/web'; +import { DiskDetail } from '../src/components/Storage/DiskDetail'; +import { StoragePoolDetail } from '../src/components/Storage/StoragePoolDetail'; +import type { StorageRecord } from '../src/features/storageBackups/models'; +import type { Resource } from '../src/types/resource'; +import '../src/index.css'; + +const disk = { + id: 'fixture-disk', + type: 'physical_disk', + name: 'Archive HDD', + displayName: 'Archive HDD', + platformType: 'proxmox-pve', + platformId: 'fixture', + sourceType: 'api', + status: 'online', + lastSeen: Date.now(), + identity: { hostname: 'storage-host' }, + metricsTarget: { resourceType: 'disk', resourceId: 'fixture-disk' }, + physicalDisk: { devPath: '/dev/sda', model: 'Archive HDD', diskType: 'hdd', temperature: 42 }, +} as Resource; + +const pool: StorageRecord = { + id: 'fixture-pool', + name: 'Archive', + category: 'datastore', + health: 'unknown', + location: { label: 'archive-host', scope: 'host' }, + source: { + platform: 'proxmox-pbs', + family: 'onprem', + origin: 'resource', + adapterId: 'resource-storage', + }, + capacity: { totalBytes: 1024, usedBytes: 512, freeBytes: 512, usagePercent: 50 }, + capabilities: ['capacity'], + observedAt: Date.now(), +}; + +render( + () => ( +
+

Storage History touch verification

+

Synthetic stored samples in production disk and pool details.

+
+ +
+
+ + + + +
+
+ +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index d836db5f31..395e434f7a 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,18 +1,18 @@ { "version": 1, - "base_sha": "ad7f7a1063d45e26f77faf35bacbca6877f41cac", - "verified_at": "2026-10-02T18:37:50.309899Z", + "base_sha": "e5d0843851a3837a90d71afaced3a009c0606bbf", + "verified_at": "2026-10-02T20:44:41.220410Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/historyChartModel.ts", + "frontend-modern/src/components/shared/HistoryChart.tsx", "frontend-modern/src/components/shared/useHistoryChartState.ts" ], "content_sha256": { - "frontend-modern/src/components/shared/historyChartModel.ts": "c96437081022b073dca0c730a8617954987f712ee84e058c6078ae3cad05f162", - "frontend-modern/src/components/shared/useHistoryChartState.ts": "38af5f416fb95874e708b08d66fdbcf27b85660840e0096cb1dd24d9bc744991" + "frontend-modern/src/components/shared/HistoryChart.tsx": "74fb7e6b466e1c2b2d2efb13e0a124d6c301896a7c9423432e903908cc9ee25b", + "frontend-modern/src/components/shared/useHistoryChartState.ts": "d6e8317019c8dc616bd9a4d3c6c71cb5329297ca0341e6ba28a6cc4c84db36b0" }, "routes": [ - "/browser-tests/history-sparse.html" + "/browser-tests/history-touch.html" ], "viewports": [ { @@ -25,37 +25,33 @@ } ], "states": [ - "Single stored 42% before inspection, pointer inspection, measured zero, multiple observations, successful empty response, restored singleton", - "Desktop Chromium light and phone WebKit dark-class presentation; 12 observations, no page errors or horizontal overflow" + "Production disk/pool details with stored multi-point, measured-zero singleton and empty history; 19 observed presentations across desktop Chromium and phone Chromium/WebKit", + "Actual touchscreen first/middle/last taps, shared I/O group focus transfer, keyboard navigation/exit and real mouse coexistence; phone WebKit dark-class presentation" ], "interactions": [ - "Pointer at three plot positions resolves to the real singleton timestamp; no mouse live announcements", - "Tab, Home/End, Left/Right, Escape and focus exit; matching API refreshes across sparse, populated and empty results", - "Both actual predecessor engines reproduce zero series pixels and missing pointer tooltip. Parent phone and final phone singleton/tooltip/zero/empty plus final desktop multi screenshots inspected." + "Native touchscreen taps select the actual reading at that position instead of the latest sample; touch compatibility focus/mouse movement/leave cannot overwrite it", + "Primary contact cancellation, non-tap gesture rejection and native Chromium vertical pan with room-to-scroll precondition; no prevented touch default, pointer capture or touch-action change", + "Desktop/phone keyboard Home/Right/End/Escape and focus exit, pointer announcements remain quiet, range replacement, zero/empty responses and no horizontal overflow", + "Predecessor native Chromium/WebKit first tap near 10% instead displays 30%; final first/middle/last taps display 10/20/30%. Inspected predecessor and final phone, zero/empty and desktop screenshots" ], - "command": "pulse-worker-browser frontend-modern/browser-tests/history-sparse.cjs", + "command": "pulse-worker-browser frontend-modern/browser-tests/history-touch.cjs", "browser_versions": { "playwright": "1.56.1", "chromium": "141.0.7390.37", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-chromium-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-webkit-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-pointer.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-zero.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-multiple.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-empty.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-recovered-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-single.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-pointer.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-zero.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-multiple.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-empty.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-recovered-single.png" + "browser/result.json", + "browser/chromium-phone-first.png", + "browser/webkit-phone-first.png", + "browser/webkit-phone-middle.png", + "browser/webkit-phone-pool-first.png", + "browser/webkit-phone-pool-zero-singleton.png", + "browser/webkit-phone-pool-empty.png", + "browser/chromium-desktop-middle.png", + "browser/parent.json", + "browser/parent-chromium-phone-first.png", + "browser/parent-webkit-phone-first.png" ], - "limitations": "Synthetic samples, not native PBS/TrueNAS collection, assistive-device testing or whole-application theme-transition acceptance." + "limitations": "Synthetic stored APIs, not native PBS/TrueNAS collection, physical phones or assistive-device speech. Native pan is Chromium-only; no physical/WebKit pan, pinch-zoom or whole-app theme-transition claim. Initial fixture and pan-driver failures are retained separately." } diff --git a/frontend-modern/src/components/shared/HistoryChart.tsx b/frontend-modern/src/components/shared/HistoryChart.tsx index ac36f7f4e7..47841fccb2 100644 --- a/frontend-modern/src/components/shared/HistoryChart.tsx +++ b/frontend-modern/src/components/shared/HistoryChart.tsx @@ -78,10 +78,15 @@ export const HistoryChart: Component = (props) => { aria-describedby={descriptionId} onMouseMove={chart.handleMouseMove} onMouseLeave={chart.handleMouseLeave} + onPointerDown={chart.handlePointerDown} + onPointerMove={chart.handlePointerMove} + onPointerUp={chart.handlePointerUp} + onPointerCancel={chart.handlePointerCancel} />

- {refreshStatus()} {accessibleDescription()} Use Left and Right arrow keys to inspect - readings, Home and End for the first and last reading, and Escape to clear inspection. + {refreshStatus()} {accessibleDescription()} Tap the chart to inspect a reading, or use + Left and Right arrow keys, Home and End for the first and last reading, and Escape to + clear inspection.

{chart.keyboardInspecting() && chart.hoveredPoint() diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index d2eb0a84b9..8729c8a2ba 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -15,6 +15,63 @@ import { HISTORY_CHART_RANGES, } from '@/components/shared/historyChartModel'; +function touchPointer(canvas: Element, type: string, fields: Partial = {}) { + const event = Object.assign(new Event(type, { bubbles: true, cancelable: true }), { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 60, + clientY: 60, + ...fields, + }); + fireEvent(canvas, event); + // Inspection must never consume native page panning or pinch zoom. + expect(event.defaultPrevented).toBe(false); +} + +function mountTouchHistory() { + const [target, setTarget] = createSignal('disk-a'); + const [data, setData] = createSignal( + [10, 20, 30].map((value, index) => ({ + timestamp: (index + 1) * 1000, + value, + min: value, + max: value, + })), + ); + const view = render(() => ( + + )); + const canvas = view.container.querySelector('canvas')!; + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + toJSON: () => ({}), + }); + return { + ...view, + canvas, + setTarget, + data, + setData, + tooltip: () => view.container.querySelector('[data-history-chart-tooltip]'), + announcement: view.container.querySelector('[aria-live="polite"]')!, + }; +} + if (typeof globalThis.ResizeObserver === 'undefined') { globalThis.ResizeObserver = class ResizeObserver { observe() {} @@ -181,6 +238,142 @@ describe('HistoryChart', () => { expect(announcement.textContent).toBe(''); }); + it('inspects the tapped sample without compatibility focus selecting the latest reading', () => { + const view = mountTouchHistory(); + touchPointer(view.canvas, 'pointerdown'); + touchPointer(view.canvas, 'pointerup'); + fireEvent.focus(view.canvas); + expect(view.tooltip()).toHaveTextContent('10.0%'); + expect(view.announcement.textContent).toBe(''); + fireEvent.mouseMove(view.canvas, { clientX: 370 }); + fireEvent.mouseLeave(view.canvas); + expect(view.tooltip()).toHaveTextContent('10.0%'); + touchPointer(view.canvas, 'pointerdown', { clientX: 200 }); + touchPointer(view.canvas, 'pointerup', { clientX: 200 }); + expect(view.tooltip()).toHaveTextContent('20.0%'); + fireEvent.keyDown(view.canvas, { key: 'ArrowRight' }); + expect(view.announcement).toHaveTextContent('30.0%'); + fireEvent.keyDown(view.canvas, { key: 'Escape' }); + expect(view.tooltip()).toBeNull(); + fireEvent.blur(view.canvas); + fireEvent.focus(view.canvas); + expect(view.announcement).toHaveTextContent('30.0%'); + }); + + it.each(['moved', 'release-displaced', 'cancelled', 'second-finger', 'different-pointer'])( + 'does not turn a %s touch into inspection', + (gesture) => { + const view = mountTouchHistory(); + touchPointer(view.canvas, 'pointerdown'); + if (gesture === 'moved') { + touchPointer(view.canvas, 'pointermove', { clientY: 90 }); + // Returning to the origin is still a drag, not a new tap. + touchPointer(view.canvas, 'pointermove'); + } else if (gesture === 'cancelled') { + touchPointer(view.canvas, 'pointercancel'); + } else if (gesture === 'second-finger') { + touchPointer(view.canvas, 'pointerdown', { pointerId: 2, isPrimary: false }); + } + touchPointer(view.canvas, 'pointerup', { + ...(gesture === 'release-displaced' ? { clientY: 90 } : {}), + ...(gesture === 'different-pointer' ? { pointerId: 2 } : {}), + }); + fireEvent.focus(view.canvas); + expect(view.tooltip()).toBeNull(); + expect(view.announcement.textContent).toBe(''); + }, + ); + + it.each([{ clientX: 0 }, { clientX: 400 }, { clientY: -1 }, { clientY: 201 }])( + 'does not inspect a tap outside the plot: %j', + (position) => { + const view = mountTouchHistory(); + touchPointer(view.canvas, 'pointerdown', position); + touchPointer(view.canvas, 'pointerup', position); + expect(view.tooltip()).toBeNull(); + }, + ); + + it('invalidates an in-flight touch when the resource changes', () => { + const view = mountTouchHistory(); + touchPointer(view.canvas, 'pointerdown'); + view.setTarget('disk-b'); + touchPointer(view.canvas, 'pointerup'); + fireEvent.focus(view.canvas); + expect(view.tooltip()).toBeNull(); + expect(view.announcement.textContent).toBe(''); + touchPointer(view.canvas, 'pointerdown', { clientX: 200 }); + touchPointer(view.canvas, 'pointerup', { clientX: 200 }); + expect(view.tooltip()).toHaveTextContent('20.0%'); + }); + + it('keeps the tapped time on refresh and distinguishes a lone zero from empty history', () => { + const view = mountTouchHistory(); + touchPointer(view.canvas, 'pointerdown'); + touchPointer(view.canvas, 'pointerup'); + view.setData(view.data().map((point) => ({ ...point, value: point.value + 1 }))); + expect(view.tooltip()).toHaveTextContent('11.0%'); + view.setData([{ timestamp: 1000, value: 0, min: 0, max: 0 }]); + touchPointer(view.canvas, 'pointerdown', { clientX: 200 }); + touchPointer(view.canvas, 'pointerup', { clientX: 200 }); + expect(view.tooltip()).toHaveTextContent('0.0%'); + view.setData([]); + touchPointer(view.canvas, 'pointerdown'); + touchPointer(view.canvas, 'pointerup'); + fireEvent.focus(view.canvas); + expect(view.tooltip()).toBeNull(); + expect(view.announcement.textContent).toBe(''); + }); + + it('leaves mouse hover available after touch inspection', () => { + const view = mountTouchHistory(); + touchPointer(view.canvas, 'pointerdown'); + touchPointer(view.canvas, 'pointerup'); + touchPointer(view.canvas, 'pointermove', { pointerType: 'mouse', clientX: 200 }); + fireEvent.mouseMove(view.canvas, { clientX: 200 }); + expect(view.tooltip()).toHaveTextContent('20.0%'); + fireEvent.mouseLeave(view.canvas); + expect(view.tooltip()).toBeNull(); + }); + + it('keeps a new group chart tap when the previously focused chart blurs', () => { + const points = [10, 20, 30].map((value, index) => ({ + timestamp: (index + 1) * 1000, + value, + min: value, + max: value, + })); + const { container } = render(() => ( + + + + + )); + const [previous, next] = container.querySelectorAll('canvas'); + vi.spyOn(next, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 200, + width: 400, + height: 200, + toJSON: () => ({}), + }); + fireEvent.focus(previous); + touchPointer(next, 'pointerdown'); + touchPointer(next, 'pointerup'); + fireEvent.blur(previous); + fireEvent.focus(next); + const tooltips = container.querySelectorAll('[data-history-chart-tooltip]'); + expect(tooltips).toHaveLength(2); + for (const tooltip of tooltips) expect(tooltip).toHaveTextContent('10.0%'); + for (const live of container.querySelectorAll('[aria-live="polite"]')) { + expect(live.textContent).toBe(''); + } + }); + it('renders the default history label', () => { render(() => ); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index 93d36881d5..2ccfa69c54 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -48,6 +48,9 @@ export function useHistoryChartState( const chartHeight = createMemo(() => props.height || 200); let chartLeftInset = HISTORY_CHART_MIN_LEFT_INSET; let chartRightInset = 0; + let touchFocus = false; + let touchTimestamp: number | null = null; + let pendingTouch: { pointerId: number; x: number; y: number; moved: boolean } | undefined; const hoveredTimestamp = hoverGroup?.hoveredTimestamp ?? localHoveredTimestamp; const setHoveredTimestamp = hoverGroup?.setHoveredTimestamp ?? setLocalHoveredTimestamp; @@ -132,6 +135,8 @@ export function useHistoryChartState( suppliedData !== undefined, ]); if (selection !== previousSelection) { + pendingTouch = undefined; + touchTimestamp = null; setHoveredPoint(null); setHoveredTimestamp(null); } @@ -376,12 +381,21 @@ export function useHistoryChartState( }); const handleFocus = () => { + // Touch's compatibility mouse events can focus the canvas after pointerup. + // That focus must not replace the tapped reading with the latest sample. + if (touchFocus) { + setHoveredTimestamp(touchTimestamp); + return; + } setKeyboardInspecting(true); const points = data(); setHoveredTimestamp(points.length ? points[points.length - 1].timestamp : null); }; const handleBlur = () => { + touchFocus = false; + touchTimestamp = null; + pendingTouch = undefined; setKeyboardInspecting(false); setHoveredTimestamp(null); }; @@ -389,12 +403,18 @@ export function useHistoryChartState( const handleKeyDown = (event: KeyboardEvent) => { if (event.altKey || event.ctrlKey || event.metaKey) return; if (event.key === 'Escape') { + touchFocus = false; + touchTimestamp = null; + pendingTouch = undefined; setHoveredTimestamp(null); return; } const points = data(); if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); + touchFocus = false; + touchTimestamp = null; + pendingTouch = undefined; setKeyboardInspecting(true); const timestamp = hoveredTimestamp(); const index = @@ -410,14 +430,14 @@ export function useHistoryChartState( setHoveredTimestamp(points[next].timestamp); }; - const handleMouseMove = (event: MouseEvent) => { + const inspectAtClientX = (clientX: number, snapToSample = false) => { const canvas = refs.getCanvas(); const points = data(); if (!canvas || points.length === 0) return; setKeyboardInspecting(false); const rect = canvas.getBoundingClientRect(); - const x = event.clientX - rect.left; + const x = clientX - rect.left; const width = rect.width; if (x < chartLeftInset || x > width - chartRightInset) { setHoveredTimestamp(null); @@ -434,11 +454,58 @@ export function useHistoryChartState( leftInset: chartLeftInset, rightInset: chartRightInset, }); - setHoveredTimestamp(geometry.getTimestamp(x)); + const timestamp = geometry.getTimestamp(x); + const inspectedTimestamp = snapToSample + ? findHistoryChartClosestPoint(points, timestamp).timestamp + : timestamp; + setHoveredTimestamp(inspectedTimestamp); + return inspectedTimestamp; + }; + + const handleMouseMove = (event: MouseEvent) => { + if (!touchFocus) inspectAtClientX(event.clientX); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === 'mouse') touchFocus = false; + if (event.pointerType !== 'touch') return; + touchFocus = true; + touchTimestamp = null; + setKeyboardInspecting(false); + setHoveredTimestamp(null); + pendingTouch = event.isPrimary + ? { pointerId: event.pointerId, x: event.clientX, y: event.clientY, moved: false } + : undefined; + }; + + const handlePointerMove = (event: PointerEvent) => { + if (event.pointerType === 'mouse') touchFocus = false; + if (event.pointerType !== 'touch' || pendingTouch?.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - pendingTouch.x, event.clientY - pendingTouch.y) > 10) { + pendingTouch.moved = true; + } + }; + + const handlePointerUp = (event: PointerEvent) => { + if (event.pointerType !== 'touch') return; + const touch = pendingTouch; + pendingTouch = undefined; + if (!touch || !event.isPrimary || touch.pointerId !== event.pointerId || touch.moved) return; + if (Math.hypot(event.clientX - touch.x, event.clientY - touch.y) > 10) return; + const rect = refs.getCanvas()?.getBoundingClientRect(); + if (!rect || event.clientY < rect.top || event.clientY > rect.bottom) return; + touchTimestamp = inspectAtClientX(event.clientX, true) ?? null; + }; + + const handlePointerCancel = (event: PointerEvent) => { + if (event.pointerType !== 'touch') return; + pendingTouch = undefined; + touchTimestamp = null; + setHoveredTimestamp(null); }; const handleMouseLeave = () => { - if (keyboardInspecting()) return; + if (keyboardInspecting() || touchFocus) return; setHoveredTimestamp(null); }; @@ -454,6 +521,10 @@ export function useHistoryChartState( keyboardInspecting, handleMouseLeave, handleMouseMove, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, chartHeight, chartWidth, hoveredPoint,