diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 071e84d..e3e2bfe 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -11,6 +11,8 @@ import { } from '../utils/downloadProgress'; import { COLUMN_ALIGNMENT_JUSTIFY, + DOWNLOAD_ACTIONS_COLUMN_WIDTH, + DOWNLOAD_ACTIONS_VIEWPORT_INSET, getColumnGridColumn, type DownloadColumnAlignment, type DownloadTableColumnKey @@ -53,6 +55,81 @@ export const DownloadItem = React.memo(({ }) => { const { t } = useTranslation(); const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]); + const rowRef = React.useRef(null); + const [isRowHovered, setIsRowHovered] = React.useState(false); + const [isRowFocused, setIsRowFocused] = React.useState(false); + const [actionPosition, setActionPosition] = React.useState(); + const isActionVisible = isRowHovered || isRowFocused; + + const updateActionPosition = React.useCallback(() => { + const row = rowRef.current; + const view = row?.closest('.downloads-view'); + if (!row || !view) return; + + const horizontalViewport = row.closest('.download-table-scroll') ?? view; + const verticalViewport = row.closest('.download-table-list') ?? view; + 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', + }; + + setActionPosition(previous => ( + previous?.top === nextPosition.top && + previous?.right === nextPosition.right && + previous?.height === nextPosition.height && + previous?.overflow === nextPosition.overflow && + previous?.visibility === nextPosition.visibility + ? previous + : nextPosition + )); + }, []); + + React.useEffect(() => { + if (!isActionVisible) return; + + let frame = 0; + const schedulePositionUpdate = () => { + if (frame) window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + frame = 0; + updateActionPosition(); + }); + }; + + const row = rowRef.current; + const view = row?.closest('.downloads-view'); + const horizontalViewport = row?.closest('.download-table-scroll'); + const verticalViewport = row?.closest('.download-table-list'); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(schedulePositionUpdate); + + updateActionPosition(); + window.addEventListener('resize', schedulePositionUpdate); + window.addEventListener('scroll', schedulePositionUpdate, true); + [row, view, horizontalViewport, verticalViewport].forEach(element => { + if (element) resizeObserver?.observe(element); + }); + + return () => { + window.removeEventListener('resize', schedulePositionUpdate); + window.removeEventListener('scroll', schedulePositionUpdate, true); + resizeObserver?.disconnect(); + if (frame) window.cancelAnimationFrame(frame); + }; + }, [isActionVisible, updateActionPosition]); const displayFraction = download.status === 'downloading' ? liveProgress?.fraction ?? download.fraction ?? 0 @@ -139,12 +216,15 @@ export const DownloadItem = React.memo(({ Status: (
{download.status === 'completed' ? ( - - {downloadStatusLabel} - +
+ + {downloadStatusLabel} + +
) : (
@@ -226,7 +306,11 @@ export const DownloadItem = React.memo(({ const rowActions = (
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > @@ -275,8 +359,30 @@ export const DownloadItem = React.memo(({ return (
{ + setIsRowHovered(true); + updateActionPosition(); + }} + onMouseLeave={event => { + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setIsRowHovered(false); + } + }} + onFocus={() => { + setIsRowFocused(true); + updateActionPosition(); + }} + onBlur={event => { + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setIsRowFocused(false); + } + }} onClick={(e) => onClick(e, download)} onContextMenu={(e) => { e.preventDefault(); diff --git a/src/components/DownloadTable.tsx b/src/components/DownloadTable.tsx index 4c17098..5cdcfa4 100644 --- a/src/components/DownloadTable.tsx +++ b/src/components/DownloadTable.tsx @@ -37,7 +37,6 @@ import { DEFAULT_COLUMN_ALIGNMENTS, DEFAULT_COLUMN_ORDER, DEFAULT_COLUMN_WIDTHS, - DOWNLOAD_ACTIONS_COLUMN_WIDTH, buildColumnGridTemplate, columnIndex, getColumnGridColumn, @@ -188,7 +187,7 @@ export const DownloadTable: React.FC = ({ filter }) => { const tableGridTemplate = buildColumnGridTemplate(orderedColumns, normalizedColumnWidths); const tableMinWidth = normalizedColumnWidths.reduce( (total, width) => total + width, - DOWNLOAD_ACTIONS_COLUMN_WIDTH + 0 ); const tableMinWidthWithPadding = 'calc(' + tableMinWidth + 'px + var(--download-row-padding-x) + var(--download-row-padding-x))'; const downloadsViewRef = useRef(null); diff --git a/src/index.css b/src/index.css index e3f9aaf..6ba9abb 100644 --- a/src/index.css +++ b/src/index.css @@ -1009,16 +1009,17 @@ html[data-list-density="relaxed"] { } .app-shell { + --window-corner-radius: 18px; direction: ltr; background: hsl(var(--main-bg)); border: 1px solid hsl(var(--border-color)); - border-radius: 14px; + border-radius: var(--window-corner-radius); overflow: hidden; } .app-sidebar-shell { - padding-block: 9px; - padding-inline-start: 9px; + padding-block: 10px; + padding-inline-start: 10px; background: transparent; } @@ -1029,7 +1030,7 @@ html[data-list-density="relaxed"] { .app-sidebar-shell--right { order: 2; padding-inline-start: 0; - padding-inline-end: 9px; + padding-inline-end: 10px; } .sidebar-resize-handle { @@ -1078,10 +1079,7 @@ html[data-list-density="relaxed"] { background: hsl(var(--sidebar-panel-bg)); border: 1px solid hsl(var(--sidebar-border)); - border-start-start-radius: 18px; - border-end-start-radius: 18px; - border-start-end-radius: 17px; - border-end-end-radius: 17px; + border-radius: var(--window-corner-radius); box-shadow: inset 0 1px 0 hsl(0 0% 100% / 0.045), @@ -1974,6 +1972,16 @@ html[data-list-density="relaxed"] { outline-offset: -2px; } +.download-table-header > [data-column-key="Status"] { + padding-inline: 8px; + padding-right: 8px; +} + +.download-table-header > [data-column-key="Status"]:hover, +.download-table-header > [data-column-key="Status"]:focus-within { + padding-right: 30px; +} + .download-column-options { position: absolute; right: 8px; @@ -2105,6 +2113,10 @@ html[data-list-density="relaxed"] .download-ghost-row { padding-inline-start: 0; } +.download-row > .download-status-cell { + padding-inline: 8px; +} + .download-row.is-selected { background: hsl(var(--accent-color) / 0.26) !important; box-shadow: inset 0 0 0 1px hsl(var(--accent-color) / 0.28); @@ -2114,6 +2126,11 @@ html[data-list-density="relaxed"] .download-ghost-row { background: hsl(var(--accent-color) / 0.32) !important; } +.download-row:focus-visible { + outline: 2px solid hsl(var(--accent-color) / 0.7); + outline-offset: -1px; +} + .download-row:hover { background: hsl(var(--item-hover)); } @@ -2201,13 +2218,58 @@ html[data-list-density="relaxed"] .download-ghost-row { } .download-row-actions { - grid-column: 8; + 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; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; +} + +.download-row.has-visible-actions .download-row-actions { + opacity: 1; + 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) { + background: transparent; + color: hsl(var(--text-secondary)); + transition: background-color 120ms ease, box-shadow 120ms ease, color 120ms ease, transform 100ms ease; +} + +@media (prefers-reduced-motion: reduce) { + .download-row-actions, + .download-row-actions .app-icon-button { + transition: none; + } +} + +.download-row-actions .app-icon-button:hover:not(:disabled), +.download-row-actions .app-icon-button:focus-visible { + background: hsl(var(--accent-color) / 0.14); + box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.34); + color: hsl(var(--accent-color)); +} + +.download-row-actions .app-icon-button:active:not(:disabled) { + background: hsl(var(--accent-color) / 0.22); + box-shadow: 0 0 0 1px hsl(var(--accent-color) / 0.46); + color: hsl(var(--accent-color)); } .download-column-menu { @@ -2326,17 +2388,33 @@ html[dir="rtl"] .download-context-menu-chevron { } .download-status-content { - display: inline-flex; + display: flex; align-items: center; gap: var(--download-status-gap); - width: max-content; + width: 100%; + min-width: 0; max-width: 100%; } -.download-status-cell > span { +.download-status-content-static { + justify-content: var(--column-justify, flex-start); +} + +.download-status-cell[data-column-alignment="center"] > .download-status-content, +.download-status-cell[data-column-alignment="right"] > .download-status-content { + width: max-content; +} + +.download-status-cell[data-column-alignment="center"] .download-progress-track, +.download-status-cell[data-column-alignment="right"] .download-progress-track { + flex: 1 1 128px; + width: auto; +} + +.download-status-cell .download-status { min-width: 0; max-width: 100%; - flex: 0 0 auto; + flex: 0 1 auto; text-align: start; overflow: hidden; text-overflow: ellipsis; @@ -2347,8 +2425,8 @@ html[dir="rtl"] .download-context-menu-chevron { } .download-progress-track { - flex: 1 1 112px; - width: 112px; + flex: 1 1 auto; + width: auto; max-width: 100%; min-width: 0; height: var(--download-progress-height); diff --git a/src/utils/downloadTableColumns.test.ts b/src/utils/downloadTableColumns.test.ts index cbbac71..3831a36 100644 --- a/src/utils/downloadTableColumns.test.ts +++ b/src/utils/downloadTableColumns.test.ts @@ -54,9 +54,9 @@ describe('download table column preferences', () => { expect(normalizeColumnAlignments(null)).toEqual(DEFAULT_COLUMN_ALIGNMENTS); }); - it('keeps user widths fixed while reserving a shared trailing spacer track', () => { + it('keeps user widths fixed while reserving a shared spacer before the final column', () => { expect(buildColumnGridTemplate([...DEFAULT_COLUMN_ORDER], [...DEFAULT_COLUMN_WIDTHS])).toBe( - '340px 100px 220px 100px 80px minmax(0, 1fr) 170px 32px' + '340px 100px 220px 100px 80px minmax(0, 1fr) 170px' ); expect(getColumnGridColumn('Date Added', [...DEFAULT_COLUMN_ORDER])).toBe('7'); expect(getColumnGridColumn('Date Added', ['Date Added', ...DEFAULT_COLUMN_ORDER.slice(0, -1)])).toBeUndefined(); diff --git a/src/utils/downloadTableColumns.ts b/src/utils/downloadTableColumns.ts index ea0926d..9c282dd 100644 --- a/src/utils/downloadTableColumns.ts +++ b/src/utils/downloadTableColumns.ts @@ -14,7 +14,10 @@ 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; -export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 32; +// Width of the fixed action rail shown while hovering a row. +export const DOWNLOAD_ACTIONS_COLUMN_WIDTH = 120; +// Keep the fixed rail clear of the viewport edge and horizontal scrollbar. +export const DOWNLOAD_ACTIONS_VIEWPORT_INSET = 8; export const COLUMN_WIDTHS_STORAGE_KEY = 'firelink-download-column-widths'; export const COLUMN_ORDER_STORAGE_KEY = 'firelink-download-column-order'; @@ -99,7 +102,6 @@ export const buildColumnGridTemplate = ( ...orderedWidths.slice(0, -1).map(width => `${width}px`), 'minmax(0, 1fr)', `${orderedWidths[orderedWidths.length - 1]}px`, - `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`, ].join(' '); };