mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 04:38:48 +00:00
Make storage History readings inspectable by keyboard
Add focus, bounded sample navigation and polite timestamp/value announcements without changing pointer or target ownership. Keep wrapped tooltip readings inside their box. Validate parent failure and final desktop/phone browser states. Change-source: pulse-maintainer
This commit is contained in:
parent
cf5d6cdf06
commit
0be430813f
9 changed files with 266 additions and 17 deletions
|
|
@ -7686,3 +7686,16 @@ field, polling, supplied-data transitions, locked/empty targets and cleanup.
|
|||
accessible description in desktop Chromium and phone WebKit, including a late
|
||||
old-target response and current-target loading/failure. Synthetic response proof
|
||||
is not native PBS collection or whole-report #1723 acceptance.
|
||||
|
||||
### Shared History keyboard inspection
|
||||
|
||||
The existing Storage pool and disk canvas charts accept keyboard focus with a
|
||||
visible focus indicator. Focus selects the latest observed sample; Left/Right
|
||||
step through actual observations, Home/End select the endpoints, and Escape or
|
||||
blur clears inspection without trapping focus. Each keyboard-selected timestamp
|
||||
and formatted value is announced politely. Pointer inspection remains available
|
||||
without announcing every mouse move. Matching sample refreshes retain inspection;
|
||||
selection changes and empty data clear stale readings. No readings are invented
|
||||
for missing data. Tooltips reserve room for a wrapped local timestamp and reading. The mounted HistoryChart regression and history-keyboard browser
|
||||
fixture cover navigation, bounds, focus exit, refresh and replacement. Synthetic
|
||||
browser proof does not establish native collector or assistive-device acceptance.
|
||||
|
|
|
|||
80
frontend-modern/browser-tests/history-keyboard.cjs
Normal file
80
frontend-modern/browser-tests/history-keyboard.cjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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-keyboard-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: 5223, strictPort: true } });
|
||||
let browser;
|
||||
const observations = [];
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) {
|
||||
browser = await engine.launch(name === 'chromium' ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } : { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit' });
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5223') return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
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:5223/browser-tests/history-keyboard.html');
|
||||
if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark'));
|
||||
const chart = page.getByRole('img', { name: 'Usage chart', exact: true });
|
||||
await chart.waitFor();
|
||||
if (process.argv.includes('--parent')) {
|
||||
assert.equal(await chart.getAttribute('tabindex'), null);
|
||||
await page.getByRole('button', { name: 'Change target' }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.evaluate(() => document.activeElement.textContent), 'After charts');
|
||||
await page.screenshot({ path: path.join(artifacts, 'parent-skipped.png'), fullPage: true });
|
||||
console.log('Parent reproduced: Tab skips both storage charts; no keyboard inspection');
|
||||
break;
|
||||
}
|
||||
assert.equal(await chart.getAttribute('tabindex'), '0');
|
||||
await page.getByRole('button', { name: 'Change target' }).focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await chart.evaluate(el => el === document.activeElement), true);
|
||||
const live = page.locator('[aria-live="polite"]').first();
|
||||
for (const [phase, key, expected] of [['focus', null, '30.0%'], ['previous', 'ArrowLeft', '20.0%'], ['first', 'Home', '10.0%'], ['lower-bound', 'ArrowLeft', '10.0%'], ['last', 'End', '30.0%'], ['upper-bound', 'ArrowRight', '30.0%'], ['escape', 'Escape', '']]) {
|
||||
if (key) await page.keyboard.press(key);
|
||||
await page.waitForTimeout(60);
|
||||
assert.ok((await live.textContent()).includes(expected));
|
||||
if (!expected) assert.equal(await live.textContent(), '');
|
||||
else assert.ok((await page.locator('[data-history-chart-tooltip]').first().textContent()).includes(expected));
|
||||
if (phase === 'previous') {
|
||||
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 2);
|
||||
for (const tooltip of await page.locator('[data-history-chart-tooltip]').all()) {
|
||||
assert.ok(await tooltip.evaluate(el => el.scrollHeight <= el.clientHeight));
|
||||
}
|
||||
assert.equal(await page.locator('[aria-live="polite"]').nth(1).textContent(), '');
|
||||
const outline = await chart.evaluate(el => getComputedStyle(el).outlineWidth);
|
||||
assert.notEqual(outline, '0px');
|
||||
await page.screenshot({ path: path.join(artifacts, `${name}-keyboard.png`), fullPage: true });
|
||||
}
|
||||
observations.push({ name, version: browser.version(), phase, announcement: await live.textContent() });
|
||||
}
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.getByRole('img', { name: 'Read chart', exact: true }).evaluate(el => el === document.activeElement), true);
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(await page.getByRole('button', { name: 'After charts' }).evaluate(el => el === document.activeElement), true);
|
||||
await page.getByRole('button', { name: 'Toggle empty' }).click();
|
||||
await chart.focus();
|
||||
await page.keyboard.press('End');
|
||||
assert.equal(await live.textContent(), '');
|
||||
assert.equal(await page.locator('[data-history-chart-tooltip]').count(), 0);
|
||||
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth }));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions));
|
||||
assert.deepEqual(errors, []);
|
||||
await browser.close(); browser = null;
|
||||
}
|
||||
fs.writeFileSync(path.join(artifacts, process.argv.includes('--parent') ? 'parent.json' : 'result.json'), JSON.stringify({ playwright: require('playwright/package.json').version, observations }, null, 2));
|
||||
console.log(JSON.stringify({ result: 'passed', states: observations.length }));
|
||||
} finally { if (browser) await browser.close(); await server.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
1
frontend-modern/browser-tests/history-keyboard.html
Normal file
1
frontend-modern/browser-tests/history-keyboard.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>History keyboard verification</title></head><body><div id="root"></div><script type="module" src="./history-keyboard.tsx"></script></body></html>
|
||||
48
frontend-modern/browser-tests/history-keyboard.tsx
Normal file
48
frontend-modern/browser-tests/history-keyboard.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { HistoryChart, HistoryChartHoverGroup } from '../src/components/shared/HistoryChart';
|
||||
import '../src/index.css';
|
||||
const Fixture = () => {
|
||||
const [empty, setEmpty] = createSignal(false);
|
||||
const [target, setTarget] = createSignal('a');
|
||||
const points = () =>
|
||||
empty()
|
||||
? []
|
||||
: [10, 20, 30].map((value, i) => ({
|
||||
timestamp: 1790942400000 + i * 60000,
|
||||
value,
|
||||
min: value,
|
||||
max: value,
|
||||
}));
|
||||
return (
|
||||
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
|
||||
<h1>Storage History keyboard inspection</h1>
|
||||
<button onClick={() => setEmpty(!empty())}>Toggle empty</button>
|
||||
<button onClick={() => setTarget(target() === 'a' ? 'b' : 'a')}>Change target</button>
|
||||
<HistoryChartHoverGroup>
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId={target()}
|
||||
metric="usage"
|
||||
label="Usage"
|
||||
unit="%"
|
||||
hideSelector
|
||||
range="1h"
|
||||
data={points()}
|
||||
/>
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId={target()}
|
||||
metric="diskread"
|
||||
label="Read"
|
||||
unit="B/s"
|
||||
hideSelector
|
||||
range="1h"
|
||||
data={points()}
|
||||
/>
|
||||
</HistoryChartHoverGroup>
|
||||
<button>After charts</button>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
render(() => <Fixture />, document.getElementById('root')!);
|
||||
|
|
@ -1,16 +1,20 @@
|
|||
{
|
||||
"version": 1,
|
||||
"base_sha": "93531c9cd3bfc9278633476ba26a1c77e66bc802",
|
||||
"verified_at": "2026-10-02T14:23:47.145834Z",
|
||||
"base_sha": "cf5d6cdf066539b6e29f589e42d408edb1ceb73a",
|
||||
"verified_at": "2026-10-02T15:02:34.899181Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts"
|
||||
"frontend-modern/src/components/shared/HistoryChart.tsx",
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts",
|
||||
"frontend-modern/src/components/shared/historyChartModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts": "f10b5cfc7cb296c60ad7e87bf7c3001c4308d29af702b8c7a7e77e7c4c4c3fb6"
|
||||
"frontend-modern/src/components/shared/HistoryChart.tsx": "24cd863fb6288bc2c13ef2d26e02aad95d3e4f54278f3078b1703fabf8785f8c",
|
||||
"frontend-modern/src/components/shared/useHistoryChartState.ts": "b1ebd93d5fee328b78f8f88f1837514a30d12be069d852d08f5d7f2b6ce999ec",
|
||||
"frontend-modern/src/components/shared/historyChartModel.ts": "bd9e8954ef7e274a1eb026752d304556df303f612c4485369f8c90a7183cc751"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/history-selection.html"
|
||||
"/browser-tests/history-keyboard.html"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
|
|
@ -23,23 +27,22 @@
|
|||
}
|
||||
],
|
||||
"states": [
|
||||
"Synthetic delayed transport using the production shared chart, with hidden selector matching storage drawers; not native PBS acceptance",
|
||||
"Selected B remains 80% after old A completes at 10%; new C clears readings while loading and displays its own failure",
|
||||
"Desktop Chromium light and phone WebKit dark: eight states, no page errors or overflow in storage layout"
|
||||
"Populated, empty and linked charts in Chromium light and WebKit phone dark; synthetic data, not native PBS acceptance",
|
||||
"Visible keyboard focus, wrapped timestamp and reading contained within tooltip; no horizontal overflow or page errors"
|
||||
],
|
||||
"interactions": [
|
||||
"Select B, complete B then delayed A",
|
||||
"Select C then fail its request; verify accessible description and plotted extrema"
|
||||
"Tab into and out of both charts; arrows, Home/End, endpoint clamping, Escape; only focused chart announces keyboard readings",
|
||||
"Parent Tab skips both charts; final empty chart invents no reading"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-selection.cjs",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/history-keyboard.cjs",
|
||||
"browser_versions": {
|
||||
"playwright": "1.56.1",
|
||||
"chromium": "141.0.7390.37",
|
||||
"webkit": "26.0"
|
||||
},
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/webkit-old-a-finished.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-wwhurxzu/browser/chromium-c-loading.png"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/webkit-keyboard.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-tuqfk84d/browser/chromium-keyboard.png"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Component, createMemo, createUniqueId } from 'solid-js';
|
||||
import {
|
||||
formatHistoryChartTooltipValue,
|
||||
getHistoryChartAccessibleDescription,
|
||||
getHistoryChartAccessibleLabel,
|
||||
type HistoryChartProps,
|
||||
|
|
@ -56,7 +57,11 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
|||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
class="block w-full h-full cursor-crosshair"
|
||||
class="block w-full h-full cursor-crosshair rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500"
|
||||
tabIndex={0}
|
||||
onFocus={chart.handleFocus}
|
||||
onBlur={chart.handleBlur}
|
||||
onKeyDown={chart.handleKeyDown}
|
||||
role="img"
|
||||
aria-label={getHistoryChartAccessibleLabel(props.label)}
|
||||
aria-describedby={descriptionId}
|
||||
|
|
@ -64,7 +69,13 @@ export const HistoryChart: Component<HistoryChartProps> = (props) => {
|
|||
onMouseLeave={chart.handleMouseLeave}
|
||||
/>
|
||||
<p id={descriptionId} class="sr-only">
|
||||
{accessibleDescription()}
|
||||
{accessibleDescription()} Use Left and Right arrow keys to inspect readings, Home and End
|
||||
for the first and last reading, and Escape to clear inspection.
|
||||
</p>
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{chart.keyboardInspecting() && chart.hoveredPoint()
|
||||
? `${new Date(chart.hoveredPoint()!.timestamp).toLocaleString()}: ${formatHistoryChartTooltipValue(chart.hoveredPoint()!.value, props.unit)}`
|
||||
: ''}
|
||||
</p>
|
||||
<HistoryChartOverlay chart={chart} hideLock={props.hideLock} />
|
||||
<HistoryChartTooltip
|
||||
|
|
|
|||
|
|
@ -130,6 +130,56 @@ describe('HistoryChart', () => {
|
|||
expect(historyChartTooltipSource).not.toContain('ChartsAPI.getMetricsHistory');
|
||||
});
|
||||
|
||||
it('inspects actual readings with the keyboard and clears on escape, blur and selection changes', () => {
|
||||
const [target, setTarget] = createSignal('a');
|
||||
const [points, setPoints] = createSignal([
|
||||
{ timestamp: 1_000, value: 10, min: 10, max: 10 },
|
||||
{ timestamp: 2_000, value: 20, min: 20, max: 20 },
|
||||
{ timestamp: 3_000, value: 30, min: 30, max: 30 },
|
||||
]);
|
||||
const { container } = render(() => (
|
||||
<HistoryChart
|
||||
resourceType="disk"
|
||||
resourceId={target()}
|
||||
metric="usage"
|
||||
unit="%"
|
||||
hideSelector
|
||||
data={points()}
|
||||
/>
|
||||
));
|
||||
const canvas = screen.getByRole('img', { name: 'History chart' });
|
||||
const announcement = container.querySelector('[aria-live="polite"]')!;
|
||||
expect(canvas).toHaveAttribute('tabindex', '0');
|
||||
expect(announcement).toBeEmptyDOMElement();
|
||||
fireEvent.focus(canvas);
|
||||
expect(announcement).toHaveTextContent('30.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
|
||||
expect(announcement).toHaveTextContent('20.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'Home' });
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowLeft' });
|
||||
expect(announcement).toHaveTextContent('10.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight', ctrlKey: true });
|
||||
expect(announcement).toHaveTextContent('10.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'End' });
|
||||
fireEvent.keyDown(canvas, { key: 'ArrowRight' });
|
||||
expect(announcement).toHaveTextContent('30.0%');
|
||||
fireEvent.keyDown(canvas, { key: 'Escape' });
|
||||
expect(announcement).toBeEmptyDOMElement();
|
||||
expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull();
|
||||
fireEvent.keyDown(canvas, { key: 'Home' });
|
||||
setPoints(points().map((point) => ({ ...point, value: point.value + 1 })));
|
||||
expect(announcement).toHaveTextContent('11.0%');
|
||||
setTarget('b');
|
||||
expect(announcement).toBeEmptyDOMElement();
|
||||
fireEvent.keyDown(canvas, { key: 'Home' });
|
||||
fireEvent.blur(canvas);
|
||||
expect(announcement).toBeEmptyDOMElement();
|
||||
setPoints([]);
|
||||
fireEvent.focus(canvas);
|
||||
fireEvent.keyDown(canvas, { key: 'End' });
|
||||
expect(announcement).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the default history label', () => {
|
||||
render(() => <HistoryChart resourceType="agent" resourceId="node-1" metric="cpu" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -279,7 +279,8 @@ export function getHistoryChartTooltipLayout({
|
|||
chartHeight: number;
|
||||
}): HistoryChartTooltipLayout {
|
||||
const width = 156;
|
||||
const height = 46;
|
||||
// Allow a wrapped local timestamp plus the reading at normal text size.
|
||||
const height = 64;
|
||||
const margin = 8;
|
||||
const pointGap = 12;
|
||||
const minX = margin;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export function useHistoryChartState(
|
|||
) {
|
||||
const [range, setRange] = createSignal<HistoryTimeRange>(props.range || '24h');
|
||||
const [data, setData] = createSignal(props.data ?? []);
|
||||
const [keyboardInspecting, setKeyboardInspecting] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [source, setSource] = createSignal<'store' | 'memory' | 'live' | 'mock_synthetic' | null>(
|
||||
|
|
@ -351,11 +352,47 @@ export function useHistoryChartState(
|
|||
onCleanup(() => resizeObserver.disconnect());
|
||||
});
|
||||
|
||||
const handleFocus = () => {
|
||||
setKeyboardInspecting(true);
|
||||
const points = data();
|
||||
setHoveredTimestamp(points.length ? points[points.length - 1].timestamp : null);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setKeyboardInspecting(false);
|
||||
setHoveredTimestamp(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
if (event.key === 'Escape') {
|
||||
setHoveredTimestamp(null);
|
||||
return;
|
||||
}
|
||||
const points = data();
|
||||
if (!points.length || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
setKeyboardInspecting(true);
|
||||
const timestamp = hoveredTimestamp();
|
||||
const index =
|
||||
timestamp === null
|
||||
? points.length - 1
|
||||
: points.indexOf(findHistoryChartClosestPoint(points, timestamp));
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? points.length - 1
|
||||
: Math.max(0, Math.min(points.length - 1, index + (event.key === 'ArrowLeft' ? -1 : 1)));
|
||||
setHoveredTimestamp(points[next].timestamp);
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
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 width = rect.width;
|
||||
|
|
@ -370,6 +407,7 @@ export function useHistoryChartState(
|
|||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (keyboardInspecting()) return;
|
||||
setHoveredTimestamp(null);
|
||||
};
|
||||
|
||||
|
|
@ -378,6 +416,10 @@ export function useHistoryChartState(
|
|||
dataMax,
|
||||
dataMin,
|
||||
error,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
keyboardInspecting,
|
||||
handleMouseLeave,
|
||||
handleMouseMove,
|
||||
chartHeight,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue