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:
Richard R 2026-05-29 21:33:01 -06:00
parent fe59685454
commit e62923fb91
3 changed files with 73 additions and 10 deletions

View file

@ -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}`}

View file

@ -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,
};
}

View 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');
});
});