mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(ui): harden download table actions and layout
This commit is contained in:
@@ -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<DownloadItemProps>(({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||
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 updateActionPosition = React.useCallback(() => {
|
||||
const row = rowRef.current;
|
||||
const view = row?.closest<HTMLElement>('.downloads-view');
|
||||
if (!row || !view) return;
|
||||
|
||||
const horizontalViewport = row.closest<HTMLElement>('.download-table-scroll') ?? view;
|
||||
const verticalViewport = row.closest<HTMLElement>('.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<HTMLElement>('.downloads-view');
|
||||
const horizontalViewport = row?.closest<HTMLElement>('.download-table-scroll');
|
||||
const verticalViewport = row?.closest<HTMLElement>('.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<DownloadItemProps>(({
|
||||
Status: (
|
||||
<div
|
||||
className="download-column-cell download-status-cell"
|
||||
data-column-alignment={columnAlignments.Status}
|
||||
style={columnStyle('Status')}
|
||||
>
|
||||
{download.status === 'completed' ? (
|
||||
<span className="download-cell-content download-status download-status-completed" title={downloadStatusLabel}>
|
||||
<div className="download-cell-content download-status-content download-status-content-static">
|
||||
<span className="download-status download-status-completed" title={downloadStatusLabel}>
|
||||
{downloadStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="download-cell-content download-status-content">
|
||||
<div className="download-progress-track">
|
||||
@@ -226,7 +306,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
const rowActions = (
|
||||
<div
|
||||
className="download-row-actions hidden group-hover:flex items-center gap-0.5"
|
||||
className="download-row-actions items-center gap-0.5"
|
||||
style={{
|
||||
...actionPosition,
|
||||
width: `${DOWNLOAD_ACTIONS_COLUMN_WIDTH}px`,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -275,8 +359,30 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`download-row group cursor-default relative ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||
ref={rowRef}
|
||||
className={`download-row group cursor-default relative ${isActionVisible ? 'has-visible-actions' : ''} ${index % 2 !== 0 ? 'striped' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||
style={{ gridTemplateColumns: tableGridTemplate, minWidth: tableMinWidth }}
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => {
|
||||
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();
|
||||
|
||||
@@ -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<DownloadTableProps> = ({ 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<HTMLDivElement>(null);
|
||||
|
||||
+93
-15
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(' ');
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user