fix(ui): harden navigation and queue interactions

This commit is contained in:
NimBold
2026-07-28 22:11:38 +03:30
parent 7e8f1c7d7b
commit 62f8c83c57
9 changed files with 299 additions and 68 deletions
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { updateDownloadSelection } from './downloadSelection';
const orderedIds = ['a', 'b', 'c', 'd'];
describe('download selection', () => {
it('collapses select-all to the clicked row', () => {
const result = updateDownloadSelection({
orderedIds,
selectedIds: new Set(orderedIds),
lastSelectedId: 'd',
targetId: 'b',
extendRange: false,
toggle: false,
});
expect([...result.selectedIds]).toEqual(['b']);
expect(result.lastSelectedId).toBe('b');
});
it('toggles a row without losing the rest of the selection', () => {
const result = updateDownloadSelection({
orderedIds,
selectedIds: new Set(['a', 'c']),
lastSelectedId: 'a',
targetId: 'c',
extendRange: false,
toggle: true,
});
expect([...result.selectedIds]).toEqual(['a']);
});
it('extends from the existing anchor for a range selection', () => {
const result = updateDownloadSelection({
orderedIds,
selectedIds: new Set(['a']),
lastSelectedId: 'a',
targetId: 'c',
extendRange: true,
toggle: false,
});
expect([...result.selectedIds]).toEqual(['a', 'b', 'c']);
expect(result.lastSelectedId).toBe('a');
});
});
+53
View File
@@ -0,0 +1,53 @@
export interface DownloadSelectionResult {
selectedIds: Set<string>;
lastSelectedId: string | null;
}
interface DownloadSelectionOptions {
orderedIds: readonly string[];
selectedIds: ReadonlySet<string>;
lastSelectedId: string | null;
targetId: string;
extendRange: boolean;
toggle: boolean;
}
/** Apply the table's click-selection rules without depending on React state timing. */
export const updateDownloadSelection = ({
orderedIds,
selectedIds,
lastSelectedId,
targetId,
extendRange,
toggle,
}: DownloadSelectionOptions): DownloadSelectionResult => {
const targetIndex = orderedIds.indexOf(targetId);
if (targetIndex === -1) {
return { selectedIds: new Set(selectedIds), lastSelectedId };
}
if (extendRange && lastSelectedId) {
const anchorIndex = orderedIds.indexOf(lastSelectedId);
if (anchorIndex !== -1) {
const nextSelectedIds = toggle ? new Set(selectedIds) : new Set<string>();
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
for (let index = start; index <= end; index += 1) {
nextSelectedIds.add(orderedIds[index]);
}
return { selectedIds: nextSelectedIds, lastSelectedId };
}
}
if (toggle) {
const nextSelectedIds = new Set(selectedIds);
if (nextSelectedIds.has(targetId)) {
nextSelectedIds.delete(targetId);
} else {
nextSelectedIds.add(targetId);
}
return { selectedIds: nextSelectedIds, lastSelectedId: targetId };
}
return { selectedIds: new Set([targetId]), lastSelectedId: targetId };
};
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { clampFloatingPosition } from './floatingPosition';
describe('floating surface positioning', () => {
it('keeps a variable-height menu inside the viewport', () => {
expect(clampFloatingPosition(350, 580, 192, 220, 400, 640)).toEqual({
x: 200,
y: 412,
});
});
it('keeps oversized surfaces anchored to the safe gutter', () => {
expect(clampFloatingPosition(-20, -10, 700, 900, 400, 640)).toEqual({
x: 8,
y: 8,
});
});
});
+23
View File
@@ -0,0 +1,23 @@
export interface FloatingPosition {
x: number;
y: number;
}
/** Keep a fixed-position surface inside the viewport with a small safe gutter. */
export const clampFloatingPosition = (
x: number,
y: number,
width: number,
height: number,
viewportWidth: number,
viewportHeight: number,
gutter = 8
): FloatingPosition => {
const maxX = Math.max(gutter, viewportWidth - width - gutter);
const maxY = Math.max(gutter, viewportHeight - height - gutter);
return {
x: Math.min(Math.max(gutter, x), maxX),
y: Math.min(Math.max(gutter, y), maxY),
};
};