diff --git a/src/renderer/ui/ContextInfoButton.tsx b/src/renderer/ui/ContextInfoButton.tsx index 8429738..7e0e5a8 100644 --- a/src/renderer/ui/ContextInfoButton.tsx +++ b/src/renderer/ui/ContextInfoButton.tsx @@ -1,4 +1,4 @@ -import { Children, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from "react"; +import { Children, Fragment, isValidElement, useId, useRef, type ReactElement, type ReactNode } from "react"; import { Icon } from "./Icon"; export interface ContextInfoButtonProps { @@ -37,19 +37,45 @@ export function ContextInfoButton({ onOpenChange }: ContextInfoButtonProps): ReactElement | null { const regionId = useId(); + const pointerInside = useRef(false); + const focusInside = useRef(false); if (!hasRenderableContent(content)) { return null; } return ( -
+
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + focusInside.current = false; + if (!pointerInside.current) { + onOpenChange(false); + } + } + }} + onFocus={() => { + focusInside.current = true; + onOpenChange(true); + }} + onPointerEnter={() => { + pointerInside.current = true; + onOpenChange(true); + }} + onPointerLeave={() => { + pointerInside.current = false; + if (!focusInside.current) { + onOpenChange(false); + } + }} + > )} - +
-
@@ -148,8 +146,11 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct - - + + + + +
); } diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts index 715e89f..d8fc373 100644 --- a/src/renderer/views/downloads/download-disclosure-transition.ts +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -62,7 +62,7 @@ function itemGroup( packageId, items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })), height, - disclosureOpacity: 1, + disclosureOpacity: phase === "entering" ? 0 : 1, disclosurePhase: phase }; } @@ -197,8 +197,8 @@ export function activateDownloadDisclosureTransition(rows: readonly DownloadDisc return rows.map((row) => { if (row.type !== "item-group") return row; return row.disclosurePhase === "entering" - ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT } - : { ...row, height: 0 }; + ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 } + : { ...row, height: 0, disclosureOpacity: 0 }; }); } diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 5fbffbd..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, @@ -204,8 +197,10 @@ .downloads-toolbar { display: flex; align-items: center; + flex-wrap: wrap; gap: 6px; min-height: 52px; + overflow-x: hidden; padding: 8px 10px; border-bottom: 1px solid var(--ui-border); background: var(--ui-surface); @@ -237,6 +232,13 @@ background: var(--ui-border); } +.downloads-toolbar-tail { + display: flex; + flex: 1 0 auto; + justify-content: flex-end; + margin-left: auto; +} + .downloads-schedule-slot { display: grid; grid-template-columns: 0fr; @@ -941,7 +943,6 @@ } @media (max-width: 1120px) { - .downloads-toolbar, .downloads-footer { overflow-x: auto; } diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 89f7cfe..d669619 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(); @@ -371,7 +372,7 @@ describe("virtualisierte Paketanimation", () => { expect(prepared.animated).toBe(true); expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ ["package-a", 40, "stable", 1], - ["package-a:items", 0, "entering", 1], + ["package-a:items", 0, "entering", 0], ["package-b", 40, "stable", 1], ["b-1", 38, "stable", 1] ]); @@ -411,7 +412,7 @@ describe("virtualisierte Paketanimation", () => { expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => row.height) : []).toEqual([38, 38]); expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([ ["package-a", 40, 1], - ["package-a:items", 0, 1], + ["package-a:items", 0, 0], ["package-b", 40, 1], ["b-1", 38, 1] ]); @@ -1041,14 +1042,52 @@ 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("places the global package disclosure action in a responsive right toolbar group", () => { + let toggles = 0; + const model = withRuntime(createInput({ filter: "paused" })); + const actions = createActions({ onToggleAllPackages: () => { toggles += 1; } }); + const sidebar = renderToStaticMarkup(); + const toolbar = DownloadsToolbar({ actions, model }); + const toolbarButtons: ReactElement[] = []; + visitElements(toolbar, (element) => { + if (element.type === "button") toolbarButtons.push(element); + }); + const tail = findElement(toolbar, (element) => String(element.props.className || "").includes("downloads-toolbar-tail")); + const css = fs.readFileSync(path.join(process.cwd(), "src/renderer/views/downloads/downloads.css"), "utf8"); + + expect(sidebar).not.toContain("Alle ein-/ausklappen"); + expect(tail.props.role).toBe("group"); + expect(tail.props["aria-label"]).toBe("Paketdarstellung"); + expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen"); + expect(css).toMatch(/\.downloads-toolbar\s*\{[^}]*flex-wrap:\s*wrap;[^}]*overflow-x:\s*hidden;/s); + expect(css).toMatch(/\.downloads-toolbar-tail\s*\{[^}]*flex:\s*1 0 auto;[^}]*justify-content:\s*flex-end;[^}]*margin-left:\s*auto;/s); + findButton(toolbar, "Alle ein-/ausklappen").props.onClick(); + expect(toggles).toBe(1); + }); + + it("disables the global package disclosure action for a truly empty queue", () => { + const model = withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false }); + const toolbar = DownloadsToolbar({ actions: createActions(), model }); + + expect(model.empty).toBe(true); + expect(findButton(toolbar, "Alle ein-/ausklappen").props.disabled).toBe(true); }); it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => { diff --git a/tests/ui-primitives.test.tsx b/tests/ui-primitives.test.tsx index 677207c..ad48cec 100644 --- a/tests/ui-primitives.test.tsx +++ b/tests/ui-primitives.test.tsx @@ -1,5 +1,6 @@ +import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ContextInfoButton } from "../src/renderer/ui/ContextInfoButton"; import { DataTable, @@ -12,6 +13,11 @@ import { Icon } from "../src/renderer/ui/Icon"; import { Toolbar, ToolbarGroup, ToolbarSearch } from "../src/renderer/ui/Toolbar"; import { getThemeVariables, UI_FOCUS_RING_VARIABLE } from "../src/renderer/ui/theme"; +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { ...actual, useId: () => "context-info-test", useRef: (value: T) => ({ current: value }) }; +}); + const expectedThemes = { dark: { "--ui-canvas": "#0F0F0F", @@ -181,4 +187,36 @@ describe("new UI primitives", () => { expect(html).toContain("aria-label=\"Informationen zu Downloads\""); expect(html).toContain("Vorhandene Download-Hilfe"); }); + + it("keeps context help open until both pointer and focus leave the shared region", () => { + const changes: boolean[] = []; + const component = ContextInfoButton({ + contextName: "Downloads", + content: "Download-Hilfe", + open: false, + onOpenChange: (open) => changes.push(open) + }) as ReactElement>; + const props = component.props as unknown as { + onPointerEnter: () => void; + onPointerLeave: () => void; + onFocus: () => void; + onBlur: (event: { currentTarget: { contains: (target: unknown) => boolean }; relatedTarget: unknown }) => void; + children: ReactElement>[]; + }; + const inside = {}; + const outside = {}; + const button = props.children[0] as ReactElement<{ onClick: () => void }>; + + props.onPointerEnter(); + props.onFocus(); + props.onPointerLeave(); + props.onBlur({ currentTarget: { contains: (target) => target === inside }, relatedTarget: inside }); + props.onBlur({ currentTarget: { contains: (target) => target === inside }, relatedTarget: outside }); + props.onPointerEnter(); + props.onBlur({ currentTarget: { contains: (target) => target === inside }, relatedTarget: outside }); + props.onPointerLeave(); + button.props.onClick(); + + expect(changes).toEqual([true, true, false, true, false, true]); + }); });