feat(ui): refine download controls and package motion

Move the global package disclosure action into a responsive right-aligned toolbar group and disable it for empty queues. Replace the decorative package title with a compact neutral filter group, coordinate disclosure opacity with height transitions, and make contextual help follow combined pointer and keyboard focus state.
This commit is contained in:
Sucukdeluxe
2026-08-24 20:59:50 +02:00
parent e8046f3fa3
commit 967eab00ae
6 changed files with 137 additions and 32 deletions
+29 -3
View File
@@ -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"; import { Icon } from "./Icon";
export interface ContextInfoButtonProps { export interface ContextInfoButtonProps {
@@ -37,19 +37,45 @@ export function ContextInfoButton({
onOpenChange onOpenChange
}: ContextInfoButtonProps): ReactElement | null { }: ContextInfoButtonProps): ReactElement | null {
const regionId = useId(); const regionId = useId();
const pointerInside = useRef(false);
const focusInside = useRef(false);
if (!hasRenderableContent(content)) { if (!hasRenderableContent(content)) {
return null; return null;
} }
return ( return (
<div className="ui-context-info"> <div
className="ui-context-info"
onBlur={(event) => {
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);
}
}}
>
<button <button
aria-controls={regionId} aria-controls={regionId}
aria-expanded={open} aria-expanded={open}
aria-label="Informationen" aria-label="Informationen"
className="ui-context-info-trigger" className="ui-context-info-trigger"
onClick={() => onOpenChange(!open)} onClick={() => onOpenChange(true)}
title="Informationen" title="Informationen"
type="button" type="button"
> >
@@ -94,14 +94,12 @@ const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement { export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return ( return (
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar"> <aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
<div className="downloads-mode-title">Pakete</div> <SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical" className="downloads-filter-group">
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)} {filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
</SlidingSelection> </SlidingSelection>
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label> <label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label> <label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
<div className="downloads-sidebar-actions"> <div className="downloads-sidebar-actions">
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button> <button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
</div> </div>
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label> <label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
@@ -150,6 +148,9 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button> <button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button> <button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button> <button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
<span aria-label="Paketdarstellung" className="downloads-toolbar-tail" role="group">
<button className="downloads-toolbar-toggle-all" disabled={model.empty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
</span>
</div> </div>
); );
} }
@@ -62,7 +62,7 @@ function itemGroup(
packageId, packageId,
items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })), items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })),
height, height,
disclosureOpacity: 1, disclosureOpacity: phase === "entering" ? 0 : 1,
disclosurePhase: phase disclosurePhase: phase
}; };
} }
@@ -197,8 +197,8 @@ export function activateDownloadDisclosureTransition(rows: readonly DownloadDisc
return rows.map((row) => { return rows.map((row) => {
if (row.type !== "item-group") return row; if (row.type !== "item-group") return row;
return row.disclosurePhase === "entering" return row.disclosurePhase === "entering"
? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT } ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 }
: { ...row, height: 0 }; : { ...row, height: 0, disclosureOpacity: 0 };
}); });
} }
+12 -11
View File
@@ -39,18 +39,11 @@
padding: 14px 12px 6px; padding: 14px 12px 6px;
} }
.downloads-mode-title { .downloads-filter-group {
display: flex; padding: 4px;
min-height: 36px;
align-items: center;
justify-content: center;
padding: 0 10px;
border: 1px solid var(--ui-border); border: 1px solid var(--ui-border);
border-radius: 6px; border-radius: 6px;
color: #0a0f1a; background: var(--ui-input);
background: #90cdf4;
text-align: center;
font-weight: 700;
} }
.downloads-sidebar nav button, .downloads-sidebar nav button,
@@ -204,8 +197,10 @@
.downloads-toolbar { .downloads-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 6px; gap: 6px;
min-height: 52px; min-height: 52px;
overflow-x: hidden;
padding: 8px 10px; padding: 8px 10px;
border-bottom: 1px solid var(--ui-border); border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface); background: var(--ui-surface);
@@ -237,6 +232,13 @@
background: var(--ui-border); background: var(--ui-border);
} }
.downloads-toolbar-tail {
display: flex;
flex: 1 0 auto;
justify-content: flex-end;
margin-left: auto;
}
.downloads-schedule-slot { .downloads-schedule-slot {
display: grid; display: grid;
grid-template-columns: 0fr; grid-template-columns: 0fr;
@@ -941,7 +943,6 @@
} }
@media (max-width: 1120px) { @media (max-width: 1120px) {
.downloads-toolbar,
.downloads-footer { .downloads-footer {
overflow-x: auto; overflow-x: auto;
} }
+46 -7
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; 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 { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types"; import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types";
@@ -70,6 +70,7 @@ import {
normalizeDownloadServiceLabel normalizeDownloadServiceLabel
} from "../src/renderer/download-format"; } from "../src/renderer/download-format";
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue"; 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(); 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.animated).toBe(true);
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
["package-a", 40, "stable", 1], ["package-a", 40, "stable", 1],
["package-a:items", 0, "entering", 1], ["package-a:items", 0, "entering", 0],
["package-b", 40, "stable", 1], ["package-b", 40, "stable", 1],
["b-1", 38, "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(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([ expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
["package-a", 40, 1], ["package-a", 40, 1],
["package-a:items", 0, 1], ["package-a:items", 0, 0],
["package-b", 40, 1], ["package-b", 40, 1],
["b-1", 38, 1] ["b-1", 38, 1]
]); ]);
@@ -1041,14 +1042,52 @@ describe("downloads view", () => {
expect(multiple).not.toMatch(/<select[^>]*aria-label="Service filtern"[^>]*disabled=""/); expect(multiple).not.toMatch(/<select[^>]*aria-label="Service filtern"[^>]*disabled=""/);
}); });
it("shows only the package mode while the file mode remains hidden", () => { it("starts with the six download filters in a compact neutral group without a package title", () => {
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />); 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"); 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(">Dateien<");
expect(html).not.toContain("downloads-mode-switch"); 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(<DownloadsSidebar actions={actions} model={model} />);
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", () => { it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
+39 -1
View File
@@ -1,5 +1,6 @@
import type { ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; 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 { ContextInfoButton } from "../src/renderer/ui/ContextInfoButton";
import { import {
DataTable, DataTable,
@@ -12,6 +13,11 @@ import { Icon } from "../src/renderer/ui/Icon";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../src/renderer/ui/Toolbar"; import { Toolbar, ToolbarGroup, ToolbarSearch } from "../src/renderer/ui/Toolbar";
import { getThemeVariables, UI_FOCUS_RING_VARIABLE } from "../src/renderer/ui/theme"; import { getThemeVariables, UI_FOCUS_RING_VARIABLE } from "../src/renderer/ui/theme";
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
return { ...actual, useId: () => "context-info-test", useRef: <T,>(value: T) => ({ current: value }) };
});
const expectedThemes = { const expectedThemes = {
dark: { dark: {
"--ui-canvas": "#0F0F0F", "--ui-canvas": "#0F0F0F",
@@ -181,4 +187,36 @@ describe("new UI primitives", () => {
expect(html).toContain("aria-label=\"Informationen zu Downloads\""); expect(html).toContain("aria-label=\"Informationen zu Downloads\"");
expect(html).toContain("Vorhandene <strong>Download-Hilfe</strong>"); expect(html).toContain("Vorhandene <strong>Download-Hilfe</strong>");
}); });
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<Record<string, unknown>>;
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<Record<string, unknown>>[];
};
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]);
});
}); });