diff --git a/src/renderer/ui/ContextInfoButton.tsx b/src/renderer/ui/ContextInfoButton.tsx index 8429738..1a03fed 100644 --- a/src/renderer/ui/ContextInfoButton.tsx +++ b/src/renderer/ui/ContextInfoButton.tsx @@ -43,13 +43,23 @@ export function ContextInfoButton({ } return ( -
+
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + onOpenChange(false); + } + }} + onFocus={() => onOpenChange(true)} + onMouseEnter={() => onOpenChange(true)} + onMouseLeave={() => onOpenChange(false)} + > )} diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 425b313..80ccbaf 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -39,18 +39,11 @@ padding: 14px 12px 6px; } -.downloads-mode-title { - display: flex; - min-height: 36px; - align-items: center; - justify-content: center; - padding: 0 10px; +.downloads-filter-group { + padding: 4px; border: 1px solid var(--ui-border); border-radius: 6px; - color: #0a0f1a; - background: #90cdf4; - text-align: center; - font-weight: 700; + background: var(--ui-input); } .downloads-sidebar nav button, diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index b32803b..b7e73c2 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import fs from "node:fs"; import path from "node:path"; -import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types"; @@ -70,6 +70,7 @@ import { normalizeDownloadServiceLabel } from "../src/renderer/download-format"; import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue"; +import { SlidingSelection } from "../src/renderer/ui/SlidingSelection"; const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime(); @@ -1044,14 +1045,21 @@ describe("downloads view", () => { expect(multiple).not.toMatch(/]*aria-label="Service filtern"[^>]*disabled=""/); }); - it("shows only the package mode while the file mode remains hidden", () => { - const html = renderToStaticMarkup(); + it("starts with the six download filters in a compact neutral group without a package title", () => { + const sidebar = DownloadsSidebar({ actions: createActions(), model: withRuntime(createInput()) }); + const children = Children.toArray(sidebar.props.children).filter(isValidElement); + const filterGroup = children[0] as ReactElement<{ className?: string }>; + const html = renderToStaticMarkup(sidebar); const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8"); - expect(html).toContain("Pakete"); + expect(filterGroup.type).toBe(SlidingSelection); + expect(filterGroup.props.className).toBe("downloads-filter-group"); + expect(html).not.toContain("downloads-mode-title"); + expect(html).not.toContain(">Pakete<"); expect(html).not.toContain(">Dateien<"); expect(html).not.toContain("downloads-mode-switch"); - expect(css).toMatch(/\.downloads-mode-title\s*\{[^}]*justify-content:\s*center;[^}]*color:\s*#0a0f1a;[^}]*background:\s*#90cdf4;[^}]*text-align:\s*center;/s); + expect(css).toMatch(/\.downloads-filter-group\s*\{[^}]*padding:\s*4px;[^}]*border:\s*1px solid var\(--ui-border\);[^}]*border-radius:\s*6px;[^}]*background:\s*var\(--ui-input\);/s); + expect(css).not.toContain(".downloads-mode-title"); }); it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => { diff --git a/tests/visual-manifest.test.ts b/tests/visual-manifest.test.ts index b8430f3..508e326 100644 --- a/tests/visual-manifest.test.ts +++ b/tests/visual-manifest.test.ts @@ -155,6 +155,10 @@ describe("reference capture manifest", () => { "context-statistics", "info-closed", "info-open", + "info-closed-after-leave", + "info-open-focus", + "info-closed-after-blur", + "info-open-focus-inside", "info-absent" ])); const collector = manifest.find((entry: { name: string }) => entry.name === "collector-dense"); @@ -172,6 +176,48 @@ describe("reference capture manifest", () => { role: "button", name: "Update verfügbar" }); + const infoOpen = manifest.find((entry: { name: string }) => entry.name === "info-open"); + expect(infoOpen.interactions).toEqual([ + { type: "hover", role: "button", name: "Informationen" }, + { type: "wait-visible", role: "region", name: "Informationen zu Downloads" } + ]); + const infoClosedAfterLeave = manifest.find((entry: { name: string }) => entry.name === "info-closed-after-leave"); + expect(infoClosedAfterLeave.interactions).toEqual([ + { type: "hover", role: "button", name: "Informationen" }, + { type: "wait-visible", role: "region", name: "Informationen zu Downloads" }, + { type: "leave", role: "button", name: "Informationen" }, + { type: "wait-absent", role: "region", name: "Informationen zu Downloads" } + ]); + const infoOpenFocus = manifest.find((entry: { name: string }) => entry.name === "info-open-focus"); + expect(infoOpenFocus.interactions).toEqual([ + { type: "focus", role: "button", name: "Informationen" }, + { type: "wait-visible", role: "region", name: "Informationen zu Downloads" } + ]); + const infoClosedAfterBlur = manifest.find((entry: { name: string }) => entry.name === "info-closed-after-blur"); + expect(infoClosedAfterBlur.interactions).toEqual([ + { type: "focus", role: "button", name: "Informationen" }, + { type: "wait-visible", role: "region", name: "Informationen zu Downloads" }, + { type: "blur", role: "button", name: "Informationen" }, + { type: "wait-absent", role: "region", name: "Informationen zu Downloads" } + ]); + const infoOpenFocusInside = manifest.find((entry: { name: string }) => entry.name === "info-open-focus-inside"); + expect(infoOpenFocusInside.interactions).toEqual([ + { type: "focus", role: "button", name: "Informationen" }, + { type: "wait-visible", role: "region", name: "Informationen zu Downloads" }, + { type: "focus", role: "button", name: "Kontextaktion" }, + { type: "wait-visible", role: "region", name: "Informationen zu Downloads" } + ]); + }); + + it("accepts explicit leave, focus and blur interactions", () => { + expect(validateVisualCaptureManifest([{ + ...validCapture, + interactions: [ + { type: "leave", role: "button", name: "Informationen" }, + { type: "focus", role: "button", name: "Informationen" }, + { type: "blur", role: "button", name: "Informationen" } + ] + }])).toEqual([]); }); it("defines representative responsive captures before the final matrix", () => { diff --git a/tests/visual/capture-manifest.json b/tests/visual/capture-manifest.json index 6b3286c..26b18c6 100644 --- a/tests/visual/capture-manifest.json +++ b/tests/visual/capture-manifest.json @@ -189,13 +189,72 @@ "viewport": { "width": 2560, "height": 1369 }, "activeView": "downloads", "interactions": [ - { "type": "click", "role": "button", "name": "Informationen" } + { "type": "hover", "role": "button", "name": "Informationen" }, + { "type": "wait-visible", "role": "region", "name": "Informationen zu Downloads" } ], "assertions": [ { "type": "visible", "role": "region", "name": "Informationen zu Downloads" }, { "type": "nonempty", "role": "region", "name": "Informationen zu Downloads" } ] }, + { + "name": "info-closed-after-leave", + "scenario": "dense", + "viewport": { "width": 2560, "height": 1369 }, + "activeView": "downloads", + "interactions": [ + { "type": "hover", "role": "button", "name": "Informationen" }, + { "type": "wait-visible", "role": "region", "name": "Informationen zu Downloads" }, + { "type": "leave", "role": "button", "name": "Informationen" }, + { "type": "wait-absent", "role": "region", "name": "Informationen zu Downloads" } + ], + "assertions": [ + { "type": "absent", "role": "region", "name": "Informationen zu Downloads" } + ] + }, + { + "name": "info-open-focus", + "scenario": "dense", + "viewport": { "width": 2560, "height": 1369 }, + "activeView": "downloads", + "interactions": [ + { "type": "focus", "role": "button", "name": "Informationen" }, + { "type": "wait-visible", "role": "region", "name": "Informationen zu Downloads" } + ], + "assertions": [ + { "type": "visible", "role": "region", "name": "Informationen zu Downloads" } + ] + }, + { + "name": "info-closed-after-blur", + "scenario": "dense", + "viewport": { "width": 2560, "height": 1369 }, + "activeView": "downloads", + "interactions": [ + { "type": "focus", "role": "button", "name": "Informationen" }, + { "type": "wait-visible", "role": "region", "name": "Informationen zu Downloads" }, + { "type": "blur", "role": "button", "name": "Informationen" }, + { "type": "wait-absent", "role": "region", "name": "Informationen zu Downloads" } + ], + "assertions": [ + { "type": "absent", "role": "region", "name": "Informationen zu Downloads" } + ] + }, + { + "name": "info-open-focus-inside", + "scenario": "dense", + "viewport": { "width": 2560, "height": 1369 }, + "activeView": "downloads", + "interactions": [ + { "type": "focus", "role": "button", "name": "Informationen" }, + { "type": "wait-visible", "role": "region", "name": "Informationen zu Downloads" }, + { "type": "focus", "role": "button", "name": "Kontextaktion" }, + { "type": "wait-visible", "role": "region", "name": "Informationen zu Downloads" } + ], + "assertions": [ + { "type": "visible", "role": "region", "name": "Informationen zu Downloads" } + ] + }, { "name": "info-absent", "scenario": "empty", diff --git a/tests/visual/download-disclosure-transition.test.tsx b/tests/visual/download-disclosure-transition.test.tsx index 53452e0..bab12bc 100644 --- a/tests/visual/download-disclosure-transition.test.tsx +++ b/tests/visual/download-disclosure-transition.test.tsx @@ -36,6 +36,16 @@ type ToolbarMeasurement = { schedule: DOMRect; }; +type ContextInfoMeasurement = { + afterHover: boolean; + afterPopover: boolean; + afterLeave: boolean; + afterFocus: boolean; + afterBlur: boolean; + afterFirstClick: boolean; + afterSecondClick: boolean; +}; + class CdpClient { private nextId = 0; private readonly pending = new Map { throw new Error("Visual harness did not reach its ready state"); } + async function loadDriverCapture(name: string): Promise { + if (!client) throw new Error("Chrome DevTools client is missing"); + await client.send("Page.navigate", { + url: `http://127.0.0.1:${visualPort}/driver-test.html?capture=${encodeURIComponent(name)}&run=${Date.now()}` + }); + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const state = await client.evaluate<{ error: string; ready: boolean }>(`({ + error: document.getElementById('root')?.textContent || '', + ready: document.documentElement.dataset.visualReady === 'true' + })`); + if (state.ready) return; + if (state.error.startsWith("Visual-Harness-Fehler:")) throw new Error(state.error); + await delay(50); + } + throw new Error(`Visual driver capture did not reach its ready state: ${name}`); + } + async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise { if (!client) throw new Error("Chrome DevTools client is missing"); return client.evaluate(`(async () => { @@ -348,4 +376,79 @@ describe("download disclosure in the headless visual harness", () => { expect(measurement.toggle.right).toBeLessThanOrEqual(measurement.toolbar.right + 1); expect(measurement.toolbar.right - measurement.toggle.right).toBeLessThanOrEqual(12); }, 30_000); + + it("opens context info on hover and focus, stays open over the popover and closes outside", async () => { + await loadDenseDownloads(1120); + if (!client) throw new Error("Chrome DevTools client is missing"); + const trigger = await client.evaluate(`(() => { + const element = document.querySelector('.ui-context-info-trigger'); + if (!(element instanceof HTMLButtonElement)) throw new Error('Context info trigger is missing'); + return element.getBoundingClientRect().toJSON(); + })()`); + await client.send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: trigger.left + trigger.width / 2, + y: trigger.top + trigger.height / 2 + }); + await delay(50); + const afterHover = await client.evaluate("Boolean(document.querySelector('.ui-context-info-region'))"); + const popover = await client.evaluate(`(() => { + const element = document.querySelector('.ui-context-info-region'); + if (!(element instanceof HTMLElement)) throw new Error('Context info region is missing after hover'); + return element.getBoundingClientRect().toJSON(); + })()`); + await client.send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: popover.left + popover.width / 2, + y: popover.top + popover.height / 2 + }); + await delay(50); + const afterPopover = await client.evaluate("Boolean(document.querySelector('.ui-context-info-region'))"); + await client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 1110, y: 10 }); + await delay(50); + const afterLeave = await client.evaluate("Boolean(document.querySelector('.ui-context-info-region'))"); + const focusStates = await client.evaluate>(`(async () => { + const wait = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const trigger = document.querySelector('.ui-context-info-trigger'); + const outside = document.querySelector('.md-shell-navigation-item'); + if (!(trigger instanceof HTMLButtonElement) || !(outside instanceof HTMLButtonElement)) { + throw new Error('Context info focus targets are missing'); + } + trigger.focus(); + await wait(); + const afterFocus = Boolean(document.querySelector('.ui-context-info-region')); + outside.focus(); + await wait(); + const afterBlur = Boolean(document.querySelector('.ui-context-info-region')); + trigger.click(); + await wait(); + const afterFirstClick = Boolean(document.querySelector('.ui-context-info-region')); + trigger.click(); + await wait(); + const afterSecondClick = Boolean(document.querySelector('.ui-context-info-region')); + return { afterFocus, afterBlur, afterFirstClick, afterSecondClick }; + })()`); + const measurement: ContextInfoMeasurement = { afterHover, afterPopover, afterLeave, ...focusStates }; + + expect(measurement).toEqual({ + afterHover: true, + afterPopover: true, + afterLeave: false, + afterFocus: true, + afterBlur: false, + afterFirstClick: true, + afterSecondClick: true + }); + }, 30_000); + + it("executes the context info hover, leave, focus and blur manifest captures", async () => { + for (const name of ["info-open", "info-closed-after-leave", "info-open-focus", "info-closed-after-blur", "info-open-focus-inside"]) { + await loadDriverCapture(name); + if (name === "info-open-focus-inside") { + if (!client) throw new Error("Chrome DevTools client is missing"); + const transitions = await client.evaluate("document.querySelector('[data-context-info-transitions]')?.getAttribute('data-context-info-transitions') || ''"); + expect(transitions.split(",")).not.toContain("closed"); + } + } + }, 30_000); }); diff --git a/tests/visual/driver-test.tsx b/tests/visual/driver-test.tsx index efbadd6..3019e26 100644 --- a/tests/visual/driver-test.tsx +++ b/tests/visual/driver-test.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { createRoot } from "react-dom/client"; +import { ContextInfoButton } from "../../src/renderer/ui/ContextInfoButton"; import { loadVisualCapture, prepareVisualCapture, @@ -23,6 +24,7 @@ function DriverTestApp({ updateAvailable }: { updateAvailable: boolean }) { const [updateDialogOpen, setUpdateDialogOpen] = useState(updateAvailable); const [updateTooltipOpen, setUpdateTooltipOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); + const [infoTransitions, setInfoTransitions] = useState([]); return ( <> @@ -48,8 +50,16 @@ function DriverTestApp({ updateAvailable }: { updateAvailable: boolean }) {
Dokumentation Staffel 1
Seite 1 von 1
- - {infoOpen &&
Drei Downloads sind sichtbar.
} + Drei Downloads sind sichtbar.} + contextName="Downloads" + onOpenChange={(open) => { + setInfoTransitions((transitions) => [...transitions, open]); + setInfoOpen(open); + }} + open={infoOpen} + /> + open ? "open" : "closed").join(",")} /> )} {activeView === "collector" && ( diff --git a/tests/visual/ui-driver.ts b/tests/visual/ui-driver.ts index 6529737..2db2f79 100644 --- a/tests/visual/ui-driver.ts +++ b/tests/visual/ui-driver.ts @@ -3,7 +3,7 @@ import { VISUAL_SCENARIOS, type VisualScenario } from "./fixtures"; export type MainViewId = "downloads" | "collector" | "settings" | "history" | "statistics"; export interface VisualInteraction { - type: "click" | "hover" | "fill" | "press" | "wait-visible" | "wait-absent"; + type: "click" | "hover" | "leave" | "focus" | "blur" | "fill" | "press" | "wait-visible" | "wait-absent"; role?: string; name?: string; value?: string; @@ -33,7 +33,7 @@ export interface VisualCapture { } const MAIN_VIEWS = ["downloads", "collector", "settings", "history", "statistics"] as const; -const INTERACTION_TYPES = ["click", "hover", "fill", "press", "wait-visible", "wait-absent"] as const; +const INTERACTION_TYPES = ["click", "hover", "leave", "focus", "blur", "fill", "press", "wait-visible", "wait-absent"] as const; const ASSERTION_TYPES = ["active-view", "visible", "absent", "nonempty", "minimum-row-count", "layer-above"] as const; const REGION_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const VIEW_NAMES: Record = { @@ -366,6 +366,19 @@ function hoverElement(targetDocument: Document, element: Element): void { dispatch(element, createEvent(targetDocument, "mouseenter", "mouse")); } +function leaveElement(targetDocument: Document, element: Element): void { + dispatch(element, createEvent(targetDocument, "pointerout", "pointer")); + dispatch(element, createEvent(targetDocument, "pointerleave", "pointer")); + dispatch(element, createEvent(targetDocument, "mouseout", "mouse")); + dispatch(element, createEvent(targetDocument, "mouseleave", "mouse")); +} + +function blurElement(element: Element): void { + if ("blur" in element && typeof element.blur === "function") { + element.blur(); + } +} + function setNativeValue(targetDocument: Document, element: Element, value: string): void { const view = targetDocument.defaultView; if (!view) { @@ -447,6 +460,12 @@ async function runInteraction(interaction: VisualInteraction, targetDocument: Do clickElement(targetDocument, element); } else if (interaction.type === "hover") { hoverElement(targetDocument, element); + } else if (interaction.type === "leave") { + leaveElement(targetDocument, element); + } else if (interaction.type === "focus") { + focusElement(element); + } else if (interaction.type === "blur") { + blurElement(element); } else if (interaction.type === "fill") { setNativeValue(targetDocument, element, interaction.value as string); } else {