mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-09 10:49:29 +00:00
fix(ui): harden navigation and queue interactions
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user