fix(ui): harden download table layout (#32)

Refs #32
This commit is contained in:
NimBold
2026-07-24 04:29:27 +03:30
parent 9a40b30a0f
commit b1659fa3d0
4 changed files with 108 additions and 42 deletions
+18 -23
View File
@@ -14,7 +14,7 @@ import {
import {
COLUMN_ALIGNMENT_JUSTIFY,
DOWNLOAD_ACTIONS_COLUMN_WIDTH,
DOWNLOAD_ACTIONS_VIEWPORT_INSET,
getDownloadActionPosition,
getColumnGridColumn,
type DownloadColumnAlignment,
type DownloadTableColumnKey
@@ -64,7 +64,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const [isRowHovered, setIsRowHovered] = React.useState(false);
const [isRowFocused, setIsRowFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
const isActionVisible = isRowHovered || isRowFocused;
const hasRowActions = download.status !== 'completed';
const isActionVisible = hasRowActions && (isRowHovered || isRowFocused);
const mediaQualityLabel = (() => {
if (!download.isMedia || typeof download.mediaQuality !== 'string') return undefined;
const normalized = download.mediaQuality.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
@@ -87,19 +88,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const rowRect = row.getBoundingClientRect();
const horizontalViewportRect = horizontalViewport.getBoundingClientRect();
const verticalViewportRect = verticalViewport.getBoundingClientRect();
const viewportTolerance = 1;
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
const isVisible = visibleHeight > viewportTolerance;
const actionHeight = Math.min(rowRect.height, visibleHeight);
const nextPosition: React.CSSProperties = {
top: visibleTop,
right: Math.max(0, window.innerWidth - horizontalViewportRect.right) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
height: actionHeight,
overflow: actionHeight < rowRect.height ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',
};
const nextPosition = getDownloadActionPosition(
rowRect,
horizontalViewportRect,
verticalViewportRect,
window.innerWidth
);
setActionPosition(previous => (
previous?.top === nextPosition.top &&
@@ -115,11 +109,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
React.useEffect(() => {
if (!isActionVisible) return;
let frame = 0;
let frame: number | null = null;
const schedulePositionUpdate = () => {
if (frame) window.cancelAnimationFrame(frame);
if (frame !== null) window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
frame = 0;
frame = null;
updateActionPosition();
});
};
@@ -143,7 +137,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
window.removeEventListener('resize', schedulePositionUpdate);
window.removeEventListener('scroll', schedulePositionUpdate, true);
resizeObserver?.disconnect();
if (frame) window.cancelAnimationFrame(frame);
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, [isActionVisible, updateActionPosition]);
@@ -325,11 +319,12 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
),
};
const rowActions = (
const rowActions = hasRowActions ? (
<div
className="download-row-actions items-center gap-0.5"
style={{
...actionPosition,
visibility: isActionVisible && actionPosition?.visibility === 'visible' ? 'visible' : 'hidden',
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
}}
onClick={(e) => e.stopPropagation()}
@@ -356,7 +351,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<MoreVertical size={14} />
</button>
</div>
);
) : null;
return (
<div
@@ -367,7 +362,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
tabIndex={0}
onMouseEnter={() => {
setIsRowHovered(true);
updateActionPosition();
if (hasRowActions) updateActionPosition();
}}
onMouseLeave={event => {
const nextTarget = event.relatedTarget;
@@ -377,7 +372,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}}
onFocus={() => {
setIsRowFocused(true);
updateActionPosition();
if (hasRowActions) updateActionPosition();
}}
onBlur={event => {
const nextTarget = event.relatedTarget;
+6 -9
View File
@@ -2789,16 +2789,18 @@ body.is-queue-dragging * {
.download-row-actions {
position: fixed;
right: 0;
display: flex;
align-self: stretch;
align-items: center;
justify-content: flex-end;
z-index: 2;
max-width: 100%;
min-width: 0;
padding: 0;
background: transparent;
padding: 0 2px;
border: 1px solid hsl(var(--border-color) / 0.88);
border-radius: 6px;
background: hsl(var(--surface-raised));
box-shadow: 0 1px 2px hsl(var(--shadow-color));
visibility: hidden;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
@@ -2809,11 +2811,6 @@ body.is-queue-dragging * {
pointer-events: auto;
}
.download-row > .download-row-actions {
overflow: visible;
padding: 0;
}
.download-row-actions .app-icon-button,
.download-row-actions .app-icon-button:hover:not(:disabled),
.download-row-actions .app-icon-button:active:not(:disabled) {
+36 -4
View File
@@ -4,6 +4,7 @@ import {
DEFAULT_COLUMN_ORDER,
DEFAULT_COLUMN_WIDTHS,
buildColumnGridTemplate,
getDownloadActionPosition,
getColumnGridColumn,
normalizeColumnAlignments,
normalizeColumnOrder,
@@ -54,12 +55,43 @@ describe('download table column preferences', () => {
expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS);
});
it('keeps user widths fixed while reserving a shared spacer before the final column', () => {
it('keeps every data column fixed while reserving a flexible fill track after them', () => {
expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe(
'340px 100px 220px 100px 80px minmax(0, 1fr) 170px'
'340px 100px 220px 100px 80px 170px minmax(0, 1fr)'
);
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7');
expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('6');
expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined();
expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('7');
expect(getColumnGridColumn('ETA', ['Date Added', 'File Name', 'Size', 'Status', 'Speed', 'ETA'])).toBe('6');
});
it('keeps row actions inside the visible row edge while preserving viewport anchoring for clipped rows', () => {
const viewport = { top: 0, right: 800, bottom: 600, left: 0 };
expect(getDownloadActionPosition(
{ top: 40, right: 1000, bottom: 72, left: -200 },
viewport,
viewport,
800
)).toMatchObject({
right: 8,
height: 30,
visibility: 'visible',
});
expect(getDownloadActionPosition(
{ top: 40, right: 400, bottom: 72, left: 100 },
viewport,
viewport,
800
)).toMatchObject({
right: 408,
visibility: 'visible',
});
expect(getDownloadActionPosition(
{ top: 40, right: -20, bottom: 72, left: -300 },
viewport,
viewport,
800
).visibility).toBe('hidden');
});
});
+48 -6
View File
@@ -14,10 +14,53 @@ export const DEFAULT_COLUMN_ORDER = [
export const DEFAULT_COLUMN_WIDTHS = [340, 100, 220, 100, 80, 170] as const;
export const COLUMN_MINIMUMS = [160, 58, 92, 58, 48, 144] as const;
// Width of the fixed action rail shown while hovering a row.
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 84;
// Keep the fixed rail clear of the viewport edge and horizontal scrollbar.
// Width of the compact action rail shown while hovering or focusing a row.
export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 64;
// Keep the viewport-anchored rail clear of the window edge and scrollbar.
export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8;
export const DOWNLOAD_ACTIONS_MAX_HEIGHT = 30;
export const DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE = 1;
export interface DownloadActionViewportRect {
top: number;
right: number;
bottom: number;
left: number;
}
export interface DownloadActionPosition {
top: number;
right: number;
height: number;
overflow: 'hidden' | 'visible';
visibility: 'hidden' | 'visible';
}
export const getDownloadActionPosition = (
rowRect: DownloadActionViewportRect,
horizontalViewportRect: DownloadActionViewportRect,
verticalViewportRect: DownloadActionViewportRect,
windowWidth: number
): DownloadActionPosition => {
const visibleTop = Math.max(rowRect.top, verticalViewportRect.top);
const visibleBottom = Math.min(rowRect.bottom, verticalViewportRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
const visibleLeft = Math.max(rowRect.left, horizontalViewportRect.left);
const visibleRight = Math.min(rowRect.right, horizontalViewportRect.right);
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
const isVisible = visibleHeight > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE &&
visibleWidth > DOWNLOAD_ACTIONS_VIEWPORT_TOLERANCE;
const actionHeight = Math.min(DOWNLOAD_ACTIONS_MAX_HEIGHT, rowRect.bottom - rowRect.top, visibleHeight);
const actionRightEdge = Math.min(rowRect.right, horizontalViewportRect.right);
return {
top: visibleTop + Math.max(0, (visibleHeight - actionHeight) / 2),
right: Math.max(0, windowWidth - actionRightEdge) + DOWNLOAD_ACTIONS_VIEWPORT_INSET,
height: actionHeight,
overflow: actionHeight < rowRect.bottom - rowRect.top ? 'hidden' : 'visible',
visibility: isVisible ? 'visible' : 'hidden',
};
};
export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths';
export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order';
@@ -99,9 +142,8 @@ export const buildColumnGridTemplate = (
const normalizedWidths = normalizeColumnWidths(widths);
const orderedWidths = order.map(key => normalizedWidths[columnIndex(key)]);
return [
...orderedWidths.slice(0, -1).map(width => `${width}px`),
...orderedWidths.map(width => `${width}px`),
'minmax(0, 1fr)',
`${orderedWidths[orderedWidths.length - 1]}px`,
].join(' ');
};
@@ -109,5 +151,5 @@ export const getColumnGridColumn = (
key: DownloadTableColumnKey,
order: DownloadTableColumnKey[]
): string | undefined => key === order[order.length - 1]
? `${order.length + 1}`
? `${order.length}`
: undefined;