feat(ui): improve icon grid layout responsiveness and add single row stretch suppression
Enhance the document icons grid to better handle single-row layouts by dynamically suppressing column stretching when the number of documents fits in a single row. Introduce maxColumnsForIconGrid and related logic to calculate optimal column counts based on container width and icon size. Update grid style computation to allow optional suppression of single row stretch, improving alignment and appearance for small document sets. Add unit tests for icon grid layout calculation and column logic to ensure correctness across different icon sizes and container widths.
This commit is contained in:
parent
fe59685454
commit
e62923fb91
3 changed files with 73 additions and 10 deletions
|
|
@ -1,10 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DocumentListDocument, IconSize } from '@/types/documents';
|
||||
import { DocumentTile } from './DocumentTile';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { iconsGridStyle } from './iconsGrid';
|
||||
import { iconsGridStyle, maxColumnsForIconGrid } from './iconsGrid';
|
||||
|
||||
interface IconsViewProps {
|
||||
documents: DocumentListDocument[];
|
||||
|
|
@ -20,11 +20,31 @@ export function IconsView({
|
|||
onMergeIntoFolder,
|
||||
}: IconsViewProps) {
|
||||
const { setVisibleOrder, clear } = useDocumentSelection();
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
const [suppressSingleRowStretch, setSuppressSingleRowStretch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleOrder(documents);
|
||||
}, [documents, setVisibleOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = gridRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const recompute = () => {
|
||||
const maxColumns = maxColumnsForIconGrid(iconSize, node.clientWidth);
|
||||
const isSingleRow = documents.length > 0 && documents.length <= maxColumns;
|
||||
setSuppressSingleRowStretch((prev) => (prev === isSingleRow ? prev : isSingleRow));
|
||||
};
|
||||
|
||||
recompute();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver(() => recompute());
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [documents.length, iconSize]);
|
||||
|
||||
const handleBackgroundClick: React.MouseEventHandler = (e) => {
|
||||
if ((e.target as HTMLElement).closest('[data-doc-tile]')) return;
|
||||
clear();
|
||||
|
|
@ -35,7 +55,11 @@ export function IconsView({
|
|||
onClick={handleBackgroundClick}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-3"
|
||||
>
|
||||
<div className="grid" style={iconsGridStyle(iconSize, documents.length)}>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid"
|
||||
style={iconsGridStyle(iconSize, documents.length, { suppressSingleRowStretch })}
|
||||
>
|
||||
{documents.map((doc) => (
|
||||
<DocumentTile
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,35 @@ const TILE_WIDTH_PX: Record<IconSize, number> = {
|
|||
};
|
||||
|
||||
const SMALL_GRID_ITEM_COUNT = 3;
|
||||
const GRID_GAP_PX = 12;
|
||||
export const GRID_GAP_PX = 12;
|
||||
|
||||
function responsiveGridTemplate(iconSize: IconSize, itemCount: number): string {
|
||||
export function iconTileWidthPx(iconSize: IconSize): number {
|
||||
return TILE_WIDTH_PX[iconSize];
|
||||
}
|
||||
|
||||
export function maxColumnsForIconGrid(iconSize: IconSize, gridWidthPx: number): number {
|
||||
if (!Number.isFinite(gridWidthPx) || gridWidthPx <= 0) return 1;
|
||||
const tileWidth = iconTileWidthPx(iconSize);
|
||||
return Math.max(1, Math.floor((gridWidthPx + GRID_GAP_PX) / (tileWidth + GRID_GAP_PX)));
|
||||
}
|
||||
|
||||
function responsiveGridTemplate(iconSize: IconSize, itemCount: number, suppressStretch: boolean): string {
|
||||
const width = TILE_WIDTH_PX[iconSize];
|
||||
if (itemCount <= SMALL_GRID_ITEM_COUNT) {
|
||||
return `repeat(auto-fill, minmax(${width}px, ${width}px))`;
|
||||
if (suppressStretch || itemCount <= SMALL_GRID_ITEM_COUNT) {
|
||||
return `repeat(auto-fill, minmax(min(100%, ${width}px), ${width}px))`;
|
||||
}
|
||||
return `repeat(auto-fit, minmax(${width}px, 1fr))`;
|
||||
}
|
||||
|
||||
export function iconsGridStyle(iconSize: IconSize, itemCount: number): CSSProperties {
|
||||
export function iconsGridStyle(
|
||||
iconSize: IconSize,
|
||||
itemCount: number,
|
||||
options?: { suppressSingleRowStretch?: boolean },
|
||||
): CSSProperties {
|
||||
const suppressSingleRowStretch = Boolean(options?.suppressSingleRowStretch);
|
||||
return {
|
||||
gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount),
|
||||
gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount, suppressSingleRowStretch),
|
||||
gap: `${GRID_GAP_PX}px`,
|
||||
justifyContent: itemCount <= SMALL_GRID_ITEM_COUNT ? 'start' : undefined,
|
||||
justifyContent: suppressSingleRowStretch || itemCount <= SMALL_GRID_ITEM_COUNT ? 'start' : undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
24
tests/unit/icons-grid.spec.ts
Normal file
24
tests/unit/icons-grid.spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import { iconsGridStyle, maxColumnsForIconGrid } from '../../src/components/doclist/views/iconsGrid';
|
||||
|
||||
test.describe('icons grid layout', () => {
|
||||
test('calculates max columns from width and icon size', () => {
|
||||
expect(maxColumnsForIconGrid('md', 136)).toBe(1);
|
||||
expect(maxColumnsForIconGrid('md', 300)).toBe(2);
|
||||
expect(maxColumnsForIconGrid('md', 1000)).toBe(6);
|
||||
});
|
||||
|
||||
test('keeps fill behavior when single-row suppression is off', () => {
|
||||
const style = iconsGridStyle('md', 4);
|
||||
expect(style.gridTemplateColumns).toContain('repeat(auto-fit');
|
||||
expect(style.gridTemplateColumns).toContain('1fr');
|
||||
expect(style.justifyContent).toBeUndefined();
|
||||
});
|
||||
|
||||
test('disables stretch when single-row suppression is on', () => {
|
||||
const style = iconsGridStyle('md', 4, { suppressSingleRowStretch: true });
|
||||
expect(style.gridTemplateColumns).toContain('repeat(auto-fill');
|
||||
expect(style.gridTemplateColumns).not.toContain('1fr');
|
||||
expect(style.justifyContent).toBe('start');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue