release: publish v2.0.19 sidebar interaction fixes
Restore the shared sliding-selection transition after React StrictMode effect restarts, disable redundant service filters, add regression coverage, and update the public English changelog and package metadata for v2.0.19.
This commit is contained in:
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.19] - 2026-08-11
|
||||||
|
|
||||||
|
### Interface fixes
|
||||||
|
|
||||||
|
- Restored smooth sidebar selection movement in development builds when React StrictMode restarts layout effects.
|
||||||
|
- Kept the active sidebar entry visible immediately while the shared selection marker moves to its new position.
|
||||||
|
- Disabled the service filter when fewer than two concrete services are available, preventing an empty or redundant dropdown from opening.
|
||||||
|
|
||||||
|
### Reliability and testing
|
||||||
|
|
||||||
|
- Added regression coverage for StrictMode transition initialization and service-filter availability states.
|
||||||
|
|
||||||
## [2.0.18] - 2026-08-11
|
## [2.0.18] - 2026-08-11
|
||||||
|
|
||||||
### Downloads and status handling
|
### Downloads and status handling
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.18",
|
"version": "2.0.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.18",
|
"version": "2.0.19",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.18",
|
"version": "2.0.19",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@@ -25,9 +25,22 @@ export function scheduleSelectionLayout(
|
|||||||
return requestFrame(apply);
|
return requestFrame(apply);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scheduleSelectionTransitions(
|
||||||
|
enabled: boolean,
|
||||||
|
frame: number,
|
||||||
|
enable: () => void,
|
||||||
|
requestFrame: (callback: FrameRequestCallback) => number = requestAnimationFrame,
|
||||||
|
cancelFrame: (frame: number) => void = cancelAnimationFrame
|
||||||
|
): number {
|
||||||
|
if (enabled) return frame;
|
||||||
|
if (frame) cancelFrame(frame);
|
||||||
|
return requestFrame(enable);
|
||||||
|
}
|
||||||
|
|
||||||
export function SlidingSelection({ activeKey, as = "div", axis, children, className = "", ...attributes }: SlidingSelectionProps): ReactElement {
|
export function SlidingSelection({ activeKey, as = "div", axis, children, className = "", ...attributes }: SlidingSelectionProps): ReactElement {
|
||||||
const ref = useRef<HTMLElement>(null);
|
const ref = useRef<HTMLElement>(null);
|
||||||
const hasPositionRef = useRef(false);
|
const hasPositionRef = useRef(false);
|
||||||
|
const transitionsEnabledRef = useRef(false);
|
||||||
|
|
||||||
useSelectionLayoutEffect(() => {
|
useSelectionLayoutEffect(() => {
|
||||||
const element = ref.current;
|
const element = ref.current;
|
||||||
@@ -50,11 +63,12 @@ export function SlidingSelection({ activeKey, as = "div", axis, children, classN
|
|||||||
hasPositionRef.current = true;
|
hasPositionRef.current = true;
|
||||||
};
|
};
|
||||||
const sync = (): void => {
|
const sync = (): void => {
|
||||||
if (!hasPositionRef.current) {
|
if (!hasPositionRef.current || !transitionsEnabledRef.current) {
|
||||||
transitionFrame = scheduleSelectionLayout(false, transitionFrame, applyLayout, requestAnimationFrame, cancelAnimationFrame, () => {
|
applyLayout();
|
||||||
if (hasPositionRef.current) {
|
transitionFrame = scheduleSelectionTransitions(transitionsEnabledRef.current, transitionFrame, () => {
|
||||||
|
if (!hasPositionRef.current) return;
|
||||||
|
transitionsEnabledRef.current = true;
|
||||||
element.style.setProperty("--ui-sliding-selection-duration", "420ms");
|
element.style.setProperty("--ui-sliding-selection-duration", "420ms");
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct
|
|||||||
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
|
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
|
||||||
{filters.map((filter) => <button 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 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" 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 onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||||
|
|||||||
@@ -486,6 +486,16 @@ describe("downloads view", () => {
|
|||||||
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
expect(html.match(/data-sliding-selection-active="true"/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("disables the service filter until more than one concrete service is available", () => {
|
||||||
|
const none = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [] })} />);
|
||||||
|
const one = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [{ id: "rapidgator", label: "RapidGator" }] })} />);
|
||||||
|
const multiple = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput(), { providerOptions: [{ id: "rapidgator", label: "RapidGator" }, { id: "ddownload", label: "DDownload" }] })} />);
|
||||||
|
|
||||||
|
expect(none).toMatch(/<select[^>]*aria-label="Service filtern"[^>]*disabled=""/);
|
||||||
|
expect(one).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("shows only the package mode while the file mode remains hidden", () => {
|
||||||
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
const html = renderToStaticMarkup(<DownloadsSidebar actions={createActions()} model={withRuntime(createInput())} />);
|
||||||
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");
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("sliding selection scheduling", () => {
|
describe("sliding selection scheduling", () => {
|
||||||
|
it("re-arms transitions when a StrictMode effect cleanup cancels the first frame", async () => {
|
||||||
|
const selectionModule = await import("../src/renderer/ui/SlidingSelection");
|
||||||
|
const schedule = Reflect.get(selectionModule, "scheduleSelectionTransitions");
|
||||||
|
|
||||||
|
expect(schedule).toBeTypeOf("function");
|
||||||
|
|
||||||
|
let transitionsEnabled = false;
|
||||||
|
let nextFrame = 0;
|
||||||
|
const pending = new Map<number, FrameRequestCallback>();
|
||||||
|
const requestFrame = (callback: FrameRequestCallback): number => {
|
||||||
|
nextFrame += 1;
|
||||||
|
pending.set(nextFrame, callback);
|
||||||
|
return nextFrame;
|
||||||
|
};
|
||||||
|
const cancelFrame = (frame: number): void => {
|
||||||
|
pending.delete(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstFrame = schedule(false, 0, () => { transitionsEnabled = true; }, requestFrame, cancelFrame);
|
||||||
|
cancelFrame(firstFrame);
|
||||||
|
const secondFrame = schedule(false, 0, () => { transitionsEnabled = true; }, requestFrame, cancelFrame);
|
||||||
|
|
||||||
|
expect(transitionsEnabled).toBe(false);
|
||||||
|
expect(pending.has(firstFrame)).toBe(false);
|
||||||
|
pending.get(secondFrame)?.(0);
|
||||||
|
expect(transitionsEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("applies the first mounted selection before scheduling later movements", async () => {
|
it("applies the first mounted selection before scheduling later movements", async () => {
|
||||||
const selectionModule = await import("../src/renderer/ui/SlidingSelection");
|
const selectionModule = await import("../src/renderer/ui/SlidingSelection");
|
||||||
const schedule = Reflect.get(selectionModule, "scheduleSelectionLayout");
|
const schedule = Reflect.get(selectionModule, "scheduleSelectionLayout");
|
||||||
|
|||||||
Reference in New Issue
Block a user