From e62923fb91ee889e519545c68b02b786714770d4 Mon Sep 17 00:00:00 2001 From: Richard R Date: Fri, 29 May 2026 21:33:01 -0600 Subject: [PATCH] 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. --- src/components/doclist/views/IconsView.tsx | 30 +++++++++++++++++++--- src/components/doclist/views/iconsGrid.ts | 29 ++++++++++++++++----- tests/unit/icons-grid.spec.ts | 24 +++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 tests/unit/icons-grid.spec.ts diff --git a/src/components/doclist/views/IconsView.tsx b/src/components/doclist/views/IconsView.tsx index 05c21be..bbc6220 100644 --- a/src/components/doclist/views/IconsView.tsx +++ b/src/components/doclist/views/IconsView.tsx @@ -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(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" > -
+
{documents.map((doc) => ( = { }; 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, }; } diff --git a/tests/unit/icons-grid.spec.ts b/tests/unit/icons-grid.spec.ts new file mode 100644 index 0000000..092844e --- /dev/null +++ b/tests/unit/icons-grid.spec.ts @@ -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'); + }); +});