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
+23 -11
View File
@@ -1,6 +1,6 @@
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads'; import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
import { schedulerCompletionState } from './utils/schedulerCompletion'; import { schedulerCompletionState } from './utils/schedulerCompletion';
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable"; import { DownloadTable, type DownloadTableStatusSummary } from "./components/DownloadTable";
// Keep the primary Add action eager so the modal cannot disappear behind a // Keep the primary Add action eager so the modal cannot disappear behind a
@@ -198,6 +198,16 @@ function App() {
const fontFamily = useSettingsStore(state => state.fontFamily); const fontFamily = useSettingsStore(state => state.fontFamily);
const appFontSize = useSettingsStore(state => state.appFontSize); const appFontSize = useSettingsStore(state => state.appFontSize);
const listRowDensity = useSettingsStore(state => state.listRowDensity); const listRowDensity = useSettingsStore(state => state.listRowDensity);
const pageTransitionKey = activeView === 'downloads'
? `${activeView}:${filter}`
: activeView;
const [isPageTransitionActive, setIsPageTransitionActive] = useState(false);
useLayoutEffect(() => {
setIsPageTransitionActive(false);
const frame = window.requestAnimationFrame(() => setIsPageTransitionActive(true));
return () => window.cancelAnimationFrame(frame);
}, [pageTransitionKey]);
useEffect(() => { useEffect(() => {
const locale = languagePreference === 'system' const locale = languagePreference === 'system'
@@ -1110,16 +1120,18 @@ function App() {
)} )}
<div className="flex-1 flex flex-col overflow-hidden relative"> <div className="flex-1 flex flex-col overflow-hidden relative">
<Suspense fallback={<PageLoadingFallback />}> <Suspense fallback={<PageLoadingFallback />}>
{activeView === 'downloads' && ( <div className={`${isPageTransitionActive ? 'app-page-transition' : ''} flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden`}>
<DownloadTable {activeView === 'downloads' && (
filter={filter} <DownloadTable
onSummaryChange={handleDownloadTableSummaryChange} filter={filter}
/> onSummaryChange={handleDownloadTableSummaryChange}
)} />
{activeView === 'settings' && <SettingsView />} )}
{activeView === 'scheduler' && <SchedulerView />} {activeView === 'settings' && <SettingsView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />} {activeView === 'scheduler' && <SchedulerView />}
{activeView === 'logs' && <LogsView />} {activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'logs' && <LogsView />}
</div>
</Suspense> </Suspense>
</div> </div>
+31 -5
View File
@@ -62,10 +62,17 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]); const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(null); const rowRef = React.useRef<HTMLDivElement>(null);
const [isRowHovered, setIsRowHovered] = React.useState(false); const [isRowHovered, setIsRowHovered] = React.useState(false);
const [isRowFocused, setIsRowFocused] = React.useState(false); const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
const [isActionHovered, setIsActionHovered] = React.useState(false);
const [isActionFocused, setIsActionFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>(); const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
const hasRowActions = download.status !== 'completed'; const hasRowActions = download.status !== 'completed';
const isActionVisible = hasRowActions && (isRowHovered || isRowFocused); const isActionVisible = hasRowActions && (
isRowHovered ||
isActionHovered ||
isRowKeyboardFocused ||
isActionFocused
);
const mediaQualityLabel = (() => { const mediaQualityLabel = (() => {
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined; if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim(); const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
@@ -332,6 +339,23 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}
onMouseEnter={() => {
setIsActionHovered(true);
updateActionPosition();
}}
onMouseLeave={() => setIsActionHovered(false)}
onFocusCapture={event => {
const target = event.target;
const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible');
setIsActionFocused(keyboardFocused);
if (keyboardFocused) updateActionPosition();
}}
onBlurCapture={event => {
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsActionFocused(false);
}
}}
> >
{canPauseDownload(download.status) && ( {canPauseDownload(download.status) && (
<button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}> <button onClick={() => handlePause(download.id)} className="app-icon-button h-7 w-7" title={t($ => $.downloads.actions.pause)}>
@@ -374,13 +398,15 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
} }
}} }}
onFocus={() => { onFocus={() => {
setIsRowFocused(true); const target = document.activeElement;
if (hasRowActions) updateActionPosition(); const keyboardFocused = target instanceof HTMLElement && target.matches(':focus-visible');
setIsRowKeyboardFocused(keyboardFocused);
if (hasRowActions && keyboardFocused) updateActionPosition();
}} }}
onBlur={event => { onBlur={event => {
const nextTarget = event.relatedTarget; const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setIsRowFocused(false); setIsRowKeyboardFocused(false);
} }
}} }}
onPointerDown={event => { onPointerDown={event => {
+45 -41
View File
@@ -50,6 +50,7 @@ import {
import { import {
moveSelectedBlockToIndex moveSelectedBlockToIndex
} from '../utils/queueOrdering'; } from '../utils/queueOrdering';
import { updateDownloadSelection } from '../utils/downloadSelection';
export interface DownloadTableStatusSummary { export interface DownloadTableStatusSummary {
summary: DownloadSummary; summary: DownloadSummary;
@@ -222,9 +223,18 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const queueRowAnimationGenerationRef = useRef(0); const queueRowAnimationGenerationRef = useRef(0);
const queueReorderPendingCountRef = useRef(0); const queueReorderPendingCountRef = useRef(0);
const suppressQueueClickRef = useRef(false); const suppressQueueClickRef = useRef(false);
const suppressQueueClickTimerRef = useRef<number | null>(null);
const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null); const [queueDragState, setQueueDragState] = useState<QueueDragState | null>(null);
const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState<string[] | null>(null); const [queueDragPreviewOrder, setQueueDragPreviewOrder] = useState<string[] | null>(null);
const clearQueueClickSuppression = useCallback(() => {
suppressQueueClickRef.current = false;
if (suppressQueueClickTimerRef.current !== null) {
window.clearTimeout(suppressQueueClickTimerRef.current);
suppressQueueClickTimerRef.current = null;
}
}, []);
useEffect(() => { useEffect(() => {
const className = 'is-queue-dragging'; const className = 'is-queue-dragging';
if (queueDragState?.active) { if (queueDragState?.active) {
@@ -765,6 +775,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
columnDragCleanupRef.current = null; columnDragCleanupRef.current = null;
queueDragCleanupRef.current = null; queueDragCleanupRef.current = null;
queueDragStateRef.current = null; queueDragStateRef.current = null;
clearQueueClickSuppression();
const queueCaptureTarget = queueDragCaptureTargetRef.current; const queueCaptureTarget = queueDragCaptureTargetRef.current;
const queueCapturePointerId = queueDragCapturePointerIdRef.current; const queueCapturePointerId = queueDragCapturePointerIdRef.current;
queueDragCaptureTargetRef.current = null; queueDragCaptureTargetRef.current = null;
@@ -1042,9 +1053,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
releaseQueuePointerCapture(); releaseQueuePointerCapture();
} }
if (current.active) { if (current.active) {
if (suppressQueueClickTimerRef.current !== null) {
window.clearTimeout(suppressQueueClickTimerRef.current);
}
suppressQueueClickRef.current = true; suppressQueueClickRef.current = true;
window.setTimeout(() => { suppressQueueClickTimerRef.current = window.setTimeout(() => {
suppressQueueClickRef.current = false; suppressQueueClickRef.current = false;
suppressQueueClickTimerRef.current = null;
}, 0); }, 0);
} }
@@ -1102,6 +1117,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
queueDragPreviewOrderRef.current || queueDragPreviewOrderRef.current ||
queueReorderPendingCountRef.current > 0 queueReorderPendingCountRef.current > 0
) return; ) return;
clearQueueClickSuppression();
if ( if (
event.target instanceof Element && event.target instanceof Element &&
event.target.closest('button, a, input, textarea, select, [role="menu"], .download-row-actions') event.target.closest('button, a, input, textarea, select, [role="menu"], .download-row-actions')
@@ -1156,13 +1172,6 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setQueueDragState(initialState); setQueueDragState(initialState);
queueDragCaptureTargetRef.current = captureTarget; queueDragCaptureTargetRef.current = captureTarget;
queueDragCapturePointerIdRef.current = pointerId; queueDragCapturePointerIdRef.current = pointerId;
try {
captureTarget.setPointerCapture(pointerId);
} catch {
// The pointer may have been cancelled between pointerdown and capture.
// Window-level listeners remain the best-effort cleanup fallback.
}
let pointerMoveFrame: number | null = null; let pointerMoveFrame: number | null = null;
let pendingPointerPosition: { clientX: number; clientY: number } | null = null; let pendingPointerPosition: { clientX: number; clientY: number } | null = null;
@@ -1175,6 +1184,15 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
); );
if (!drag.active && distance < 5) return; if (!drag.active && distance < 5) return;
if (!drag.active) {
try {
captureTarget.setPointerCapture(pointerId);
} catch {
// The pointer may have been cancelled before the drag threshold.
// Window-level listeners remain the best-effort cleanup fallback.
}
}
const baseItems = queueDragBaseItemsRef.current; const baseItems = queueDragBaseItemsRef.current;
const selectedIdSet = new Set(drag.ids); const selectedIdSet = new Set(drag.ids);
queueDragStateRef.current = { ...drag, pointerY: clientY }; queueDragStateRef.current = { ...drag, pointerY: clientY };
@@ -1224,7 +1242,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
clientX: pointerEvent.clientX, clientX: pointerEvent.clientX,
clientY: pointerEvent.clientY, clientY: pointerEvent.clientY,
}; };
pointerEvent.preventDefault(); const drag = queueDragStateRef.current;
if (drag && (drag.active || Math.hypot(
pointerEvent.clientX - drag.startX,
pointerEvent.clientY - drag.startY
) >= 5)) {
pointerEvent.preventDefault();
}
if (pointerMoveFrame === null) { if (pointerMoveFrame === null) {
pointerMoveFrame = window.requestAnimationFrame(() => { pointerMoveFrame = window.requestAnimationFrame(() => {
pointerMoveFrame = null; pointerMoveFrame = null;
@@ -1648,44 +1672,24 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [filter, isQueueFilter]); }, [filter, isQueueFilter]);
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => { const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
if (suppressQueueClickRef.current) { if (suppressQueueClickRef.current) {
suppressQueueClickRef.current = false; clearQueueClickSuppression();
return; return;
} }
if (e.detail === 2) { if (e.detail === 2) {
handleDownloadDoubleClick(item); handleDownloadDoubleClick(item);
return; return;
} }
const currentSortedDownloads = sortedDownloadsRef.current; const nextSelection = updateDownloadSelection({
const currentSelectedIds = selectedIdsRef.current; orderedIds: sortedDownloadsRef.current.map(download => download.id),
const currentLastSelectedId = lastSelectedIdRef.current; selectedIds: selectedIdsRef.current,
if (e.shiftKey && currentLastSelectedId) { lastSelectedId: lastSelectedIdRef.current,
const currentIndex = currentSortedDownloads.findIndex(d => d.id === item.id); targetId: item.id,
const lastIndex = currentSortedDownloads.findIndex(d => d.id === currentLastSelectedId); extendRange: e.shiftKey,
toggle: e.metaKey || e.ctrlKey,
if (currentIndex !== -1 && lastIndex !== -1) { });
const start = Math.min(currentIndex, lastIndex); setSelectedIds(nextSelection.selectedIds);
const end = Math.max(currentIndex, lastIndex); setLastSelectedId(nextSelection.lastSelectedId);
}, [clearQueueClickSuppression, handleDownloadDoubleClick]);
const newSelected = (e.metaKey || e.ctrlKey) ? new Set(currentSelectedIds) : new Set<string>();
for (let i = start; i <= end; i++) {
newSelected.add(currentSortedDownloads[i].id);
}
setSelectedIds(newSelected);
}
} else if (e.metaKey || e.ctrlKey) {
const newSelected = new Set(currentSelectedIds);
if (newSelected.has(item.id)) {
newSelected.delete(item.id);
} else {
newSelected.add(item.id);
}
setSelectedIds(newSelected);
setLastSelectedId(item.id);
} else {
setSelectedIds(new Set([item.id]));
setLastSelectedId(item.id);
}
}, [handleDownloadDoubleClick]);
const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => { const handleContextMenu = useCallback((menu: { x: number; y: number; id: string }) => {
if (!selectedIdsRef.current.has(menu.id)) { if (!selectedIdsRef.current.has(menu.id)) {
+55 -8
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { import {
Inbox, Zap, CheckCircle2, CircleDashed, Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion, Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
@@ -11,6 +12,7 @@ import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion'; import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import { isTransferActiveStatus } from '../utils/downloads'; import { isTransferActiveStatus } from '../utils/downloads';
import { clampFloatingPosition } from '../utils/floatingPosition';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string; export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -25,13 +27,15 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore(); const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore(); const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const { addToast } = useToast(); const { addToast } = useToast();
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const [isAddingQueue, setIsAddingQueue] = useState(false); const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState(''); const [newQueueName, setNewQueueName] = useState('');
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null); const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
const [editingQueueName, setEditingQueueName] = useState(''); const [editingQueueName, setEditingQueueName] = useState('');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null);
const [foldersCollapsed, setFoldersCollapsed] = useState(() => const [foldersCollapsed, setFoldersCollapsed] = useState(() =>
window.localStorage.getItem('firelink-folders-collapsed') === 'true' window.localStorage.getItem('firelink-folders-collapsed') === 'true'
); );
@@ -49,6 +53,46 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const rejectedAddQueueNameRef = useRef<string | null>(null); const rejectedAddQueueNameRef = useRef<string | null>(null);
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null); const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
useLayoutEffect(() => {
if (!contextMenu) return;
if (!queues.some(queue => queue.id === contextMenu.id)) {
setContextMenu(null);
setContextMenuPosition(null);
return;
}
const updateContextMenuPosition = () => {
const menu = contextMenuRef.current;
if (!menu) return;
const rect = menu.getBoundingClientRect();
const nextPosition = clampFloatingPosition(
contextMenu.x,
contextMenu.y,
rect.width,
rect.height,
window.innerWidth,
window.innerHeight
);
setContextMenuPosition(current => (
current?.x === nextPosition.x && current.y === nextPosition.y
? current
: nextPosition
));
};
updateContextMenuPosition();
const resizeObserver = typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(updateContextMenuPosition);
const menu = contextMenuRef.current;
if (menu) resizeObserver?.observe(menu);
window.addEventListener('resize', updateContextMenuPosition);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateContextMenuPosition);
};
}, [contextMenu, queues, i18n.language]);
useEffect(() => { useEffect(() => {
const handleCloseMenu = () => setContextMenu(null); const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => { const handleEscape = (event: KeyboardEvent) => {
@@ -156,6 +200,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => { const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setContextMenuPosition(null);
setContextMenu({ x: e.clientX, y: e.clientY, id }); setContextMenu({ x: e.clientX, y: e.clientY, id });
}; };
@@ -431,13 +476,14 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
</button> </button>
</div> </div>
{contextMenu && ( {contextMenu && createPortal(
<div <div
role="menu" role="menu"
className="fixed z-50 w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden" ref={contextMenuRef}
style={{ className="fixed z-[70] w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
top: Math.min(contextMenu.y, window.innerHeight - 200), style={{
left: Math.min(contextMenu.x, window.innerWidth - 200) top: contextMenuPosition?.y ?? contextMenu.y,
left: contextMenuPosition?.x ?? contextMenu.x,
}} }}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
@@ -561,7 +607,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{t($ => $.actions.deleteQueue)} {t($ => $.actions.deleteQueue)}
</button> </button>
)} )}
</div> </div>,
document.body
)} )}
</aside> </aside>
); );
+4 -3
View File
@@ -1731,8 +1731,9 @@ html[data-list-density="relaxed"] {
padding: 32px; padding: 32px;
} }
.settings-page-transition { .settings-page-transition,
animation: settings-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1); .app-page-transition {
animation: app-page-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
transform-origin: top center; transform-origin: top center;
} }
@@ -3331,7 +3332,7 @@ html[dir="rtl"] .download-context-menu-chevron {
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes settings-page-in { @keyframes app-page-in {
from { from {
opacity: 0; opacity: 0;
transform: translateY(6px); transform: translateY(6px);
+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),
};
};