From b1659fa3d0e96e65639ac0ae7ff6d745da4f9181 Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 24 Jul 2026 04:29:27 +0330 Subject: [PATCH] fix(ui): harden download table layout (#32) Refs #32 --- src/components/DownloadItem.tsx | 41 +++++++++---------- src/index.css | 15 +++---- src/utils/downloadTableColumns.test.ts | 40 +++++++++++++++++-- src/utils/downloadTableColumns.ts | 54 +++++++++++++++++++++++--- 4 files changed, 108 insertions(+), 42 deletions(-) diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 4ddf709..7261aa5 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -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(({ const [isRowHovered, setIsRowHovered] = React.useState(false); const [isRowFocused, setIsRowFocused] = React.useState(false); const [actionPosition, setActionPosition] = React.useState(); - 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(({ 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(({ 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(({ 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(({ ), }; - const rowActions = ( + const rowActions = hasRowActions ? (
e.stopPropagation()} @@ -356,7 +351,7 @@ export const DownloadItem = React.memo(({
- ); + ) : null; return (
(({ tabIndex={0} onMouseEnter={() => { setIsRowHovered(true); - updateActionPosition(); + if (hasRowActions) updateActionPosition(); }} onMouseLeave={event => { const nextTarget = event.relatedTarget; @@ -377,7 +372,7 @@ export const DownloadItem = React.memo(({ }} onFocus={() => { setIsRowFocused(true); - updateActionPosition(); + if (hasRowActions) updateActionPosition(); }} onBlur={event => { const nextTarget = event.relatedTarget; diff --git a/src/index.css b/src/index.css index 3bf8a16..91085e6 100644 --- a/src/index.css +++ b/src/index.css @@ -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) { diff --git a/src/utils/downloadTableColumns.test.ts b/src/utils/downloadTableColumns.test.ts index 3831a36..4a09510 100644 --- a/src/utils/downloadTableColumns.test.ts +++ b/src/utils/downloadTableColumns.test.ts @@ -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'); }); }); diff --git a/src/utils/downloadTableColumns.ts b/src/utils/downloadTableColumns.ts index 599facd..750e263 100644 --- a/src/utils/downloadTableColumns.ts +++ b/src/utils/downloadTableColumns.ts @@ -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;