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