From e7099406c79d2e95b7e04b3ef0476842a4e08f47 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Wed, 22 Jul 2026 12:05:06 +0000 Subject: [PATCH 1/2] feat(OverflowList): add maxVisibleItems cap and bounded multi-row (#4176) --- .changeset/overflowlist-cap-multirow.md | 7 + .../stories/OverflowList.stories.tsx | 119 ++++- .../OverflowListCappedToolbar.doc.mjs | 14 + .../OverflowListCappedToolbar.tsx | 39 ++ .../OverflowListMultiRowTags.doc.mjs | 14 + .../OverflowList/OverflowListMultiRowTags.tsx | 44 ++ .../src/OverflowList/OverflowList.doc.mjs | 45 +- .../src/OverflowList/OverflowList.test.tsx | 111 +++++ .../core/src/OverflowList/OverflowList.tsx | 56 ++- .../core/src/hooks/computeOverflow.test.ts | 422 ++++++++++++++++++ packages/core/src/hooks/computeOverflow.ts | 290 ++++++++++++ packages/core/src/hooks/useOverflow.test.ts | 100 ++++- packages/core/src/hooks/useOverflow.ts | 110 +++-- 13 files changed, 1315 insertions(+), 56 deletions(-) create mode 100644 .changeset/overflowlist-cap-multirow.md create mode 100644 packages/cli/templates/blocks/components/OverflowList/OverflowListCappedToolbar.doc.mjs create mode 100644 packages/cli/templates/blocks/components/OverflowList/OverflowListCappedToolbar.tsx create mode 100644 packages/cli/templates/blocks/components/OverflowList/OverflowListMultiRowTags.doc.mjs create mode 100644 packages/cli/templates/blocks/components/OverflowList/OverflowListMultiRowTags.tsx create mode 100644 packages/core/src/hooks/computeOverflow.test.ts create mode 100644 packages/core/src/hooks/computeOverflow.ts diff --git a/.changeset/overflowlist-cap-multirow.md b/.changeset/overflowlist-cap-multirow.md new file mode 100644 index 000000000000..16c4adb0f331 --- /dev/null +++ b/.changeset/overflowlist-cap-multirow.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] OverflowList: add `maxVisibleItems` to cap the number of visible items (the ceiling partner to `minVisibleItems`) and `maxRows` for bounded multi-row wrapping — items wrap onto up to N rows, then collapse into the overflow indicator. Both props are optional and default to off, so single-line behavior is unchanged. See #4176. + +@cixzhang diff --git a/apps/storybook/stories/OverflowList.stories.tsx b/apps/storybook/stories/OverflowList.stories.tsx index 2c68fbdaebb1..8b911fb639af 100644 --- a/apps/storybook/stories/OverflowList.stories.tsx +++ b/apps/storybook/stories/OverflowList.stories.tsx @@ -22,6 +22,14 @@ const meta: Meta = { control: {type: 'number', min: 0, max: 10}, description: 'Minimum number of items to always show', }, + maxVisibleItems: { + control: {type: 'number', min: 0, max: 10}, + description: 'Maximum number of items to ever show (cap)', + }, + maxRows: { + control: {type: 'number', min: 1, max: 4}, + description: 'Wrap items across up to N rows before collapsing', + }, collapseFrom: { control: 'select', options: ['start', 'end'], @@ -270,11 +278,7 @@ export const DynamicItems: Story = { size="sm" onClick={() => setCount(c => Math.max(1, c - 1))} /> - + + + + , + ); + const vis = visibleContainer(); + expect(within(vis).getByText('A')).toBeInTheDocument(); + expect(within(vis).getByText('B')).toBeInTheDocument(); + expect(within(vis).queryByText('C')).not.toBeInTheDocument(); + expect(within(vis).getByText('more:2,3')).toBeInTheDocument(); + }); + + it('min wins over a smaller cap (D1)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + + + + , + ); + const vis = visibleContainer(); + expect(within(vis).getByText('A')).toBeInTheDocument(); + expect(within(vis).getByText('B')).toBeInTheDocument(); + expect(within(vis).getByText('C')).toBeInTheDocument(); + expect(within(vis).queryByText('D')).not.toBeInTheDocument(); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + }); + + describe('maxRows (multi-row)', () => { + it('applies a different container class when maxRows enables wrapping', () => { + const {rerender} = render( + + + , + ); + const singleLine = visibleContainer().getAttribute('class'); + + rerender( + + + , + ); + const multiRow = visibleContainer().getAttribute('class'); + expect(multiRow).not.toEqual(singleLine); + }); + + it('keeps single-line behavior with maxRows={1}', () => { + render( + + + + + , + ); + const vis = visibleContainer(); + expect(within(vis).getByText('A')).toBeInTheDocument(); + expect(within(vis).queryByText('B')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/core/src/OverflowList/OverflowList.tsx b/packages/core/src/OverflowList/OverflowList.tsx index 0eacd772e95a..94b0084702f1 100644 --- a/packages/core/src/OverflowList/OverflowList.tsx +++ b/packages/core/src/OverflowList/OverflowList.tsx @@ -9,8 +9,9 @@ * @position Core implementation; consumed by index.ts * * Renders a horizontal list of items, hiding those that don't fit in the - * available width and optionally showing an overflow indicator. - * Uses a hidden measurement container to avoid flickering. + * available width and optionally showing an overflow indicator. Supports an + * optional item cap (`maxVisibleItems`) and bounded multi-row wrapping + * (`maxRows`). Uses a hidden measurement container to avoid flickering. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/OverflowList/index.ts (exports if types change) @@ -34,6 +35,14 @@ const styles = stylex.create({ whiteSpace: 'nowrap', minWidth: 0, }, + containerMultiRow: { + display: 'flex', + flexWrap: 'wrap', + alignContent: 'flex-start', + overflow: 'hidden', + whiteSpace: 'normal', + minWidth: 0, + }, fillParent: { width: '100%', }, @@ -52,6 +61,12 @@ const styles = stylex.create({ }, }); +const multiRowHeight = stylex.create({ + height: (maxRows: number, rowHeight: number, gapPx: number) => ({ + maxHeight: `calc(${rowHeight}px * ${maxRows} + ${gapPx}px * ${maxRows - 1})`, + }), +}); + const gapStyles = stylex.create({ 0: {gap: spacingVars['--spacing-0']}, 0.5: {gap: spacingVars['--spacing-0-5']}, @@ -112,6 +127,24 @@ export interface OverflowListProps extends BaseProps { */ minVisibleItems?: number; + /** + * Maximum number of items to ever show, even when they all fit. The ceiling + * partner to `minVisibleItems`; extra items collapse into the overflow + * indicator. Leave undefined for no cap. If it is less than + * `minVisibleItems`, the floor wins (and a dev-only warning is logged). + * @default undefined + */ + maxVisibleItems?: number; + + /** + * Wrap items across up to this many rows before collapsing the remainder + * into the overflow indicator. Leave undefined (or set `1`) for the default + * single-line behavior. A number, not a boolean — unbounded wrapping is a + * plain flex-wrap layout, not overflow collapse. Assumes uniform row height. + * @default undefined + */ + maxRows?: number; + /** * Which end to collapse items from. * @default 'end' @@ -180,6 +213,8 @@ export function OverflowList({ children, gap = 2, minVisibleItems = 0, + maxVisibleItems, + maxRows, collapseFrom = 'end', behavior = 'observeSelf', overflowRenderer, @@ -195,16 +230,17 @@ export function OverflowList({ const gapPx = spacingToPx[gap]; const observeParent = behavior === 'observeParent'; + const isMultiRow = maxRows != null && maxRows > 1; - const {containerRef, measureRef, visibleCount, hasOverflow} = useOverflow( - itemCount, - { + const {containerRef, measureRef, visibleCount, hasOverflow, rowHeight} = + useOverflow(itemCount, { gap: gapPx, minVisibleItems, + maxVisibleItems, + maxRows, collapseFrom, behavior, - }, - ); + }); const allItems: OverflowItem[] = childArray.map((child, index) => ({ child, @@ -248,8 +284,12 @@ export function OverflowList({ {...mergeProps( themeProps('overflow-list'), stylex.props( - styles.container, + isMultiRow ? styles.containerMultiRow : styles.container, gapStyles[gap], + isMultiRow && + rowHeight > 0 && + maxRows != null && + multiRowHeight.height(maxRows, rowHeight, gapPx), observeParent && hasOverflow && styles.fillParent, xstyle, ), diff --git a/packages/core/src/hooks/computeOverflow.test.ts b/packages/core/src/hooks/computeOverflow.test.ts new file mode 100644 index 000000000000..9a43c0306d04 --- /dev/null +++ b/packages/core/src/hooks/computeOverflow.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect} from 'vitest'; +import {computeOverflow, type ComputeOverflowInput} from './computeOverflow'; + +// Convenience wrapper with sensible defaults so each test states only what it +// exercises. All widths/gaps are plain numbers — no DOM involved. +function compute(overrides: Partial) { + return computeOverflow({ + widths: [], + gap: 0, + availableWidth: 0, + indicatorWidth: 0, + minVisibleItems: 0, + collapseFrom: 'end', + ...overrides, + }); +} + +describe('computeOverflow — single line, no limits (parity with legacy loop)', () => { + it('shows all items when they fit', () => { + // 3×50, gap 10 → 170 ≤ 200 + expect( + compute({widths: [50, 50, 50], gap: 10, availableWidth: 200}), + ).toEqual({visibleCount: 3, rows: 1}); + }); + + it('hides items that do not fit and reserves indicator space', () => { + // 4×50, gap 10, indicator 30, container 150 → 2 fit + expect( + compute({ + widths: [50, 50, 50, 50], + gap: 10, + indicatorWidth: 30, + availableWidth: 150, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); + + it('shows zero when nothing fits and floor is 0', () => { + expect( + compute({ + widths: [100, 100, 100], + indicatorWidth: 30, + availableWidth: 50, + }), + ).toEqual({visibleCount: 0, rows: 0}); + }); + + it('exact fit with indicator not needed shows all', () => { + // 3×50, gap 10 → 170 == 170 + expect( + compute({ + widths: [50, 50, 50], + gap: 10, + indicatorWidth: 30, + availableWidth: 170, + }), + ).toEqual({visibleCount: 3, rows: 1}); + }); + + it('off-by-one: one pixel short drops the last item', () => { + expect( + compute({ + widths: [50, 50, 50], + gap: 10, + indicatorWidth: 30, + availableWidth: 169, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); + + it('works without an indicator', () => { + // 3×50, gap 10, container 130 → 2 fit + expect( + compute({widths: [50, 50, 50], gap: 10, availableWidth: 130}), + ).toEqual({visibleCount: 2, rows: 1}); + }); + + it('varied widths', () => { + expect( + compute({ + widths: [30, 80, 40, 60], + gap: 10, + indicatorWidth: 25, + availableWidth: 200, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); +}); + +describe('computeOverflow — floor (minVisibleItems)', () => { + it('respects the floor even when items do not fit', () => { + expect( + compute({ + widths: [100, 100, 100], + indicatorWidth: 30, + availableWidth: 50, + minVisibleItems: 2, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); + + it('floor larger than itemCount is clamped to itemCount', () => { + expect( + compute({ + widths: [100, 100], + availableWidth: 10, + minVisibleItems: 5, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); +}); + +describe('computeOverflow — cap (maxVisibleItems)', () => { + it('caps below fit even when everything fits', () => { + // all 5 fit in a wide container, cap at 2 + expect( + compute({ + widths: [40, 40, 40, 40, 40], + gap: 8, + availableWidth: 10000, + maxVisibleItems: 2, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); + + it('cap of 0 shows nothing', () => { + expect( + compute({ + widths: [40, 40, 40], + availableWidth: 10000, + maxVisibleItems: 0, + }), + ).toEqual({visibleCount: 0, rows: 0}); + }); + + it('cap greater than itemCount is a no-op', () => { + expect( + compute({ + widths: [40, 40, 40], + gap: 8, + availableWidth: 10000, + maxVisibleItems: 99, + }), + ).toEqual({visibleCount: 3, rows: 1}); + }); + + it('cap does not raise a fit-limited count', () => { + // container only fits 2, cap is 4 → still 2 + expect( + compute({ + widths: [50, 50, 50, 50], + gap: 10, + indicatorWidth: 30, + availableWidth: 150, + maxVisibleItems: 4, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); +}); + +describe('computeOverflow — cap + floor composition', () => { + it('both honored when min <= max', () => { + // fit would be 2; floor 1, cap 3 → 2 + expect( + compute({ + widths: [50, 50, 50, 50], + gap: 10, + indicatorWidth: 30, + availableWidth: 150, + minVisibleItems: 1, + maxVisibleItems: 3, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); + + it('D1: when max < min, min wins', () => { + // wide container fits all 5; floor 3, cap 1 → floor wins → 3 + expect( + compute({ + widths: [40, 40, 40, 40, 40], + gap: 8, + availableWidth: 10000, + minVisibleItems: 3, + maxVisibleItems: 1, + }), + ).toEqual({visibleCount: 3, rows: 1}); + }); +}); + +describe('computeOverflow — collapseFrom start vs end + cap', () => { + it('single-line collapseFrom start keeps the tail', () => { + // Items: 30, 80, 40. gap 10, indicator 25, container 100. + // reversed: [40, 80, 30] → only 40 fits → visibleCount 1 + expect( + compute({ + widths: [30, 80, 40], + gap: 10, + indicatorWidth: 25, + availableWidth: 100, + collapseFrom: 'start', + }), + ).toEqual({visibleCount: 1, rows: 1}); + }); + + it('collapseFrom start + cap trims from the correct end', () => { + expect( + compute({ + widths: [40, 40, 40, 40], + gap: 8, + availableWidth: 10000, + collapseFrom: 'start', + maxVisibleItems: 2, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); +}); + +describe('computeOverflow — multi-row (maxRows)', () => { + it('maxRows 1 is equivalent to single line', () => { + const single = compute({ + widths: [50, 50, 50, 50], + gap: 10, + indicatorWidth: 30, + availableWidth: 150, + }); + const withMaxRows1 = compute({ + widths: [50, 50, 50, 50], + gap: 10, + indicatorWidth: 30, + availableWidth: 150, + maxRows: 1, + }); + expect(withMaxRows1).toEqual(single); + expect(withMaxRows1).toEqual({visibleCount: 2, rows: 1}); + }); + + it('packs items onto exactly maxRows rows when all fit', () => { + // 6×50, gap 10 → each row of width 170 holds 3 (50+10+50+10+50=170). + // availableWidth 170, maxRows 2 → all 6 fit on 2 rows. + expect( + compute({ + widths: [50, 50, 50, 50, 50, 50], + gap: 10, + availableWidth: 170, + maxRows: 2, + }), + ).toEqual({visibleCount: 6, rows: 2}); + }); + + it('collapses overflow past maxRows into the indicator (reserved on last row)', () => { + // 8×50, gap 10, width 170 → 3 per row. maxRows 2. + // Row 1: 3 items (170). Row 2 (last): reserve indicator 40 + gap 10 = 50. + // available for items on row 2 = 170; place 50 (ok, rowWidth 50), + // next 50 → 50+10+50=110 + reserve 50 = 160 ≤ 170 ok (2 items), + // next 50 → 110+10+50=170 + 50 = 220 > 170 stop. Row 2 holds 2. + // → 3 + 2 = 5 visible, 2 rows. + expect( + compute({ + widths: [50, 50, 50, 50, 50, 50, 50, 50], + gap: 10, + indicatorWidth: 40, + availableWidth: 170, + maxRows: 2, + }), + ).toEqual({visibleCount: 5, rows: 2}); + }); + + it('all items on a single row still reports rows: 1 under maxRows 2', () => { + expect( + compute({ + widths: [30, 30, 30], + gap: 5, + availableWidth: 500, + maxRows: 2, + }), + ).toEqual({visibleCount: 3, rows: 1}); + }); + + it('cap wins when it is stricter than the rows allow', () => { + // 6 items fit on 2 rows, but cap at 4 → 4 visible. + expect( + compute({ + widths: [50, 50, 50, 50, 50, 50], + gap: 10, + indicatorWidth: 40, + availableWidth: 170, + maxRows: 2, + maxVisibleItems: 4, + }), + ).toEqual({visibleCount: 4, rows: 2}); + }); + + it('rows win when they are stricter than the cap', () => { + // maxRows 2 admits 5 (see above); cap 8 is looser → rows win → 5. + expect( + compute({ + widths: [50, 50, 50, 50, 50, 50, 50, 50], + gap: 10, + indicatorWidth: 40, + availableWidth: 170, + maxRows: 2, + maxVisibleItems: 8, + }), + ).toEqual({visibleCount: 5, rows: 2}); + }); + + it('multi-row collapseFrom start keeps the tail items', () => { + // 8 items, reversed packing; same geometry as the end case → 5 visible. + const res = compute({ + widths: [50, 50, 50, 50, 50, 50, 50, 50], + gap: 10, + indicatorWidth: 40, + availableWidth: 170, + maxRows: 2, + collapseFrom: 'start', + }); + expect(res).toEqual({visibleCount: 5, rows: 2}); + }); + + it('maxRows taller than content does not add phantom rows', () => { + // 3 items fit on one row; maxRows 5 → rows 1, all visible. + expect( + compute({ + widths: [40, 40, 40], + gap: 10, + availableWidth: 500, + maxRows: 5, + }), + ).toEqual({visibleCount: 3, rows: 1}); + }); + + it('floor is honored in multi-row even when nothing fits', () => { + // Very narrow container, floor 2. + expect( + compute({ + widths: [500, 500, 500], + gap: 10, + indicatorWidth: 40, + availableWidth: 100, + maxRows: 2, + minVisibleItems: 2, + }), + ).toEqual({visibleCount: 2, rows: 2}); + }); +}); + +describe('computeOverflow — resize behavior (shrink/grow re-clamps, never exceeds cap)', () => { + const base = { + widths: [50, 50, 50, 50, 50], + gap: 10, + indicatorWidth: 30, + maxVisibleItems: 3, + } as const; + + it('grows back only up to the cap', () => { + // Huge container would fit all 5, cap holds at 3. + expect(compute({...base, availableWidth: 10000})).toEqual({ + visibleCount: 3, + rows: 1, + }); + }); + + it('shrinks below the cap when space is tight', () => { + // Narrow container fits fewer than the cap. + expect(compute({...base, availableWidth: 150})).toEqual({ + visibleCount: 2, + rows: 1, + }); + }); + + it('never exceeds the cap across a shrink→grow cycle', () => { + const shrunk = compute({...base, availableWidth: 120}); + const grown = compute({...base, availableWidth: 10000}); + expect(grown.visibleCount).toBeLessThanOrEqual(3); + expect(shrunk.visibleCount).toBeLessThanOrEqual(grown.visibleCount); + }); +}); + +describe('computeOverflow — degenerate inputs', () => { + it('empty list', () => { + expect(compute({widths: []})).toEqual({visibleCount: 0, rows: 0}); + }); + + it('single item that fits', () => { + expect(compute({widths: [50], gap: 10, availableWidth: 100})).toEqual({ + visibleCount: 1, + rows: 1, + }); + }); + + it('single item that does not fit, no floor', () => { + expect( + compute({widths: [200], indicatorWidth: 30, availableWidth: 100}), + ).toEqual({visibleCount: 0, rows: 0}); + }); + + it('maxRows 0 falls back to single-line semantics', () => { + // maxRows 0 is degenerate; treated as single line (not multi-row). + expect( + compute({ + widths: [50, 50, 50], + gap: 10, + availableWidth: 130, + maxRows: 0, + }), + ).toEqual({visibleCount: 2, rows: 1}); + }); + + it('cap 0 with floor 0 and multi-row shows nothing', () => { + expect( + compute({ + widths: [50, 50], + gap: 10, + availableWidth: 500, + maxRows: 2, + maxVisibleItems: 0, + }), + ).toEqual({visibleCount: 0, rows: 0}); + }); +}); diff --git a/packages/core/src/hooks/computeOverflow.ts b/packages/core/src/hooks/computeOverflow.ts new file mode 100644 index 000000000000..3f88af50da16 --- /dev/null +++ b/packages/core/src/hooks/computeOverflow.ts @@ -0,0 +1,290 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file computeOverflow.ts + * @input Plain measurements (item widths, gap, available width, indicator width, limits) + * @output Pure overflow computation: how many items are visible and their row layout + * @position Pure helper consumed by useOverflow; contains no DOM or React code so the + * fit/clamp math and the multi-row packing can be unit-tested in isolation. + * + * There is a single source of truth for `visibleCount`: + * visibleCount = clamp(fitCount, minVisibleItems, maxVisibleItems ?? itemCount) + * + * SYNC: When modified, update: + * - /packages/core/src/hooks/useOverflow.ts (the only caller) + */ + +export interface ComputeOverflowInput { + /** Measured widths of each item, in original DOM order. */ + widths: number[]; + /** Gap between items, in pixels. */ + gap: number; + /** Width available to lay items out, in pixels. */ + availableWidth: number; + /** Measured width of the overflow indicator (0 if none). */ + indicatorWidth: number; + /** Floor — always show at least this many items. */ + minVisibleItems: number; + /** Ceiling — never show more than this many items. `undefined` = no cap. */ + maxVisibleItems?: number; + /** + * Bounded multi-row: wrap items across up to this many rows, then collapse + * the rest into the overflow indicator. `undefined` (or `1`) = single line. + */ + maxRows?: number; + /** Which end items collapse from. */ + collapseFrom: 'start' | 'end'; +} + +export interface ComputeOverflowResult { + /** Number of items that should be rendered in the visible container. */ + visibleCount: number; + /** Number of rows the visible items occupy (always 1 for the single-line path). */ + rows: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(Math.min(value, max), min); +} + +/** + * Resolve the ceiling and floor into a single [floor, ceiling] pair. + * + * `maxVisibleItems` is clamped to `itemCount` (a cap larger than the list is a + * no-op). When `maxVisibleItems < minVisibleItems`, the floor wins (D1) via the + * final clamp — the hook is responsible for the dev-only warning. + */ +function resolveBounds( + itemCount: number, + minVisibleItems: number, + maxVisibleItems: number | undefined, +): {floor: number; ceiling: number} { + const floor = Math.max(0, Math.min(minVisibleItems, itemCount)); + const rawCeiling = maxVisibleItems == null ? itemCount : maxVisibleItems; + const ceiling = Math.max(0, Math.min(rawCeiling, itemCount)); + return {floor, ceiling}; +} + +/** + * Single-line greedy fit: how many items fit in one row of `availableWidth`, + * reserving indicator space for every non-final admitted item. Mirrors the + * original in-hook loop so single-line behavior is unchanged. + */ +function computeSingleLineFit( + orderedWidths: number[], + gap: number, + availableWidth: number, + indicatorWidth: number, + floor: number, + ceiling: number, +): number { + let totalWidth = 0; + let count = 0; + + for (let i = 0; i < orderedWidths.length; i++) { + if (count >= ceiling) { + break; + } + + const itemWidth = orderedWidths[i]; + const gapWidth = i > 0 ? gap : 0; + const candidateWidth = totalWidth + itemWidth + gapWidth; + + const isLastItem = i === orderedWidths.length - 1; + const reservedWidth = isLastItem + ? 0 + : indicatorWidth + (count > 0 || indicatorWidth > 0 ? gap : 0); + + if (candidateWidth + reservedWidth > availableWidth && count >= floor) { + break; + } + + totalWidth = candidateWidth; + count++; + } + + return count; +} + +/** + * Pack items into rows, wrapping when the next item would exceed + * `availableWidth`. On the last allowed row, keep `indicatorReserve` px free so + * the overflow indicator fits. Stops admitting once no further item can be + * placed within `maxRows`. + */ +function packRows( + orderedWidths: number[], + gap: number, + availableWidth: number, + indicatorReserve: number, + maxRows: number, +): {placed: number; rows: number} { + let placed = 0; + let row = 1; + let rowWidth = 0; + + for (let i = 0; i < orderedWidths.length; i++) { + const w = orderedWidths[i]; + const isFirstInRow = rowWidth === 0; + const candidate = isFirstInRow ? w : rowWidth + gap + w; + + const onLastRow = row === maxRows; + const reserve = + onLastRow && indicatorReserve > 0 ? indicatorReserve + gap : 0; + + if (candidate + reserve <= availableWidth) { + rowWidth = candidate; + placed++; + continue; + } + + if (isFirstInRow) { + // A single item wider than the row occupies this row alone (it will be + // clipped visually). But if it can't coexist with the reserved indicator + // on the last row, stop here. + if (onLastRow && reserve > 0) { + break; + } + rowWidth = candidate; + placed++; + continue; + } + + if (row >= maxRows) { + break; + } + row++; + rowWidth = 0; + i--; // re-attempt this item as the first on the new row + } + + return {placed, rows: row}; +} + +/** Count how many rows a set of items occupies when wrapped at availableWidth. */ +function countRows( + orderedWidths: number[], + gap: number, + availableWidth: number, +): number { + if (orderedWidths.length === 0) { + return 0; + } + let rows = 1; + let rowWidth = 0; + for (let i = 0; i < orderedWidths.length; i++) { + const w = orderedWidths[i]; + const isFirstInRow = rowWidth === 0; + const candidate = isFirstInRow ? w : rowWidth + gap + w; + if (candidate <= availableWidth || isFirstInRow) { + rowWidth = candidate; + } else { + rows++; + rowWidth = w; + } + } + return rows; +} + +/** + * Multi-row packing (Strategy A): simulate wrapping items into rows of + * `availableWidth`, admitting items until a new item would require a row beyond + * `maxRows`, reserving indicator space on the final row when items overflow. + * Returns the admitted count and how many rows those items occupy. Assumes + * uniform row height. + */ +function computeMultiRowFit( + orderedWidths: number[], + gap: number, + availableWidth: number, + indicatorWidth: number, + maxRows: number, +): {count: number; rows: number} { + const n = orderedWidths.length; + if (n === 0) { + return {count: 0, rows: 0}; + } + + // If everything fits within maxRows without reserving indicator space, there + // is no overflow and no indicator is needed. + const packAll = packRows(orderedWidths, gap, availableWidth, 0, maxRows); + if (packAll.placed === n) { + return {count: n, rows: packAll.rows}; + } + + // Overflow → reserve indicator space on the final row and re-pack. + const packWithIndicator = packRows( + orderedWidths, + gap, + availableWidth, + indicatorWidth, + maxRows, + ); + + const count = packWithIndicator.placed; + const rows = countRows(orderedWidths.slice(0, count), gap, availableWidth); + + return {count, rows: Math.max(count > 0 ? 1 : 0, rows)}; +} + +/** + * Pure overflow computation. Given plain measurements and the configured + * limits, return how many items should be visible and how many rows they + * occupy. No DOM, no React — safe to unit-test directly. + */ +export function computeOverflow( + input: ComputeOverflowInput, +): ComputeOverflowResult { + const { + widths, + gap, + availableWidth, + indicatorWidth, + minVisibleItems, + maxVisibleItems, + maxRows, + collapseFrom, + } = input; + + const itemCount = widths.length; + if (itemCount === 0) { + return {visibleCount: 0, rows: 0}; + } + + const {floor, ceiling} = resolveBounds( + itemCount, + minVisibleItems, + maxVisibleItems, + ); + + const orderedWidths = collapseFrom === 'end' ? widths : [...widths].reverse(); + + const multiRow = maxRows != null && maxRows > 1; + + if (!multiRow) { + const fitCount = computeSingleLineFit( + orderedWidths, + gap, + availableWidth, + indicatorWidth, + floor, + ceiling, + ); + const visibleCount = clamp(fitCount, floor, ceiling); + return {visibleCount, rows: visibleCount > 0 ? 1 : 0}; + } + + const {count, rows} = computeMultiRowFit( + orderedWidths, + gap, + availableWidth, + indicatorWidth, + maxRows, + ); + const visibleCount = clamp(count, floor, ceiling); + const resolvedRows = + visibleCount === count + ? rows + : countRows(orderedWidths.slice(0, visibleCount), gap, availableWidth); + return {visibleCount, rows: visibleCount > 0 ? Math.max(1, resolvedRows) : 0}; +} diff --git a/packages/core/src/hooks/useOverflow.test.ts b/packages/core/src/hooks/useOverflow.test.ts index e33a4968d0af..124091f42517 100644 --- a/packages/core/src/hooks/useOverflow.test.ts +++ b/packages/core/src/hooks/useOverflow.test.ts @@ -14,12 +14,15 @@ function mockContainer(width: number): HTMLElement { function mockMeasure( itemWidths: number[], indicatorWidth?: number, + itemHeight = 0, ): HTMLElement { - const children: {offsetWidth: number}[] = itemWidths.map(w => ({ - offsetWidth: w, - })); + const children: {offsetWidth: number; offsetHeight: number}[] = + itemWidths.map(w => ({ + offsetWidth: w, + offsetHeight: itemHeight, + })); if (indicatorWidth != null) { - children.push({offsetWidth: indicatorWidth}); + children.push({offsetWidth: indicatorWidth, offsetHeight: itemHeight}); } return {children} as unknown as HTMLElement; } @@ -332,6 +335,95 @@ describe('useOverflow', () => { }); }); +describe('useOverflow with maxVisibleItems (cap)', () => { + it('caps visible items even when they all fit', () => { + const {result} = renderHook(() => + useOverflow(5, {gap: 10, maxVisibleItems: 3}), + ); + + act(() => { + result.current.measureRef(mockMeasure([50, 50, 50, 50, 50], 30)); + result.current.containerRef(mockContainer(10000)); + }); + + expect(result.current.visibleCount).toBe(3); + expect(result.current.hasOverflow).toBe(true); + }); + + it('cap does not raise a fit-limited count', () => { + const {result} = renderHook(() => + useOverflow(4, {gap: 10, maxVisibleItems: 4}), + ); + + act(() => { + result.current.measureRef(mockMeasure([50, 50, 50, 50], 30)); + result.current.containerRef(mockContainer(150)); + }); + + expect(result.current.visibleCount).toBe(2); + }); + + it('min wins when maxVisibleItems < minVisibleItems and warns in dev', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const {result} = renderHook(() => + useOverflow(5, {gap: 10, minVisibleItems: 3, maxVisibleItems: 1}), + ); + + act(() => { + result.current.measureRef(mockMeasure([50, 50, 50, 50, 50], 30)); + result.current.containerRef(mockContainer(10000)); + }); + + expect(result.current.visibleCount).toBe(3); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('useOverflow with maxRows (multi-row)', () => { + it('exposes rows and rowHeight', () => { + const {result} = renderHook(() => useOverflow(6, {gap: 10, maxRows: 2})); + + act(() => { + result.current.measureRef(mockMeasure([50, 50, 50, 50, 50, 50], 40, 24)); + result.current.containerRef(mockContainer(170)); + }); + + // 3 items per row (170), 2 rows → all 6 fit + expect(result.current.visibleCount).toBe(6); + expect(result.current.rows).toBe(2); + expect(result.current.rowHeight).toBe(24); + expect(result.current.hasOverflow).toBe(false); + }); + + it('collapses items beyond maxRows into the indicator', () => { + const {result} = renderHook(() => useOverflow(8, {gap: 10, maxRows: 2})); + + act(() => { + result.current.measureRef( + mockMeasure([50, 50, 50, 50, 50, 50, 50, 50], 40, 24), + ); + result.current.containerRef(mockContainer(170)); + }); + + expect(result.current.visibleCount).toBe(5); + expect(result.current.rows).toBe(2); + expect(result.current.hasOverflow).toBe(true); + }); + + it('maxRows=1 matches single-line behavior', () => { + const {result} = renderHook(() => useOverflow(4, {gap: 10, maxRows: 1})); + + act(() => { + result.current.measureRef(mockMeasure([50, 50, 50, 50], 30, 24)); + result.current.containerRef(mockContainer(150)); + }); + + expect(result.current.visibleCount).toBe(2); + expect(result.current.rows).toBe(1); + }); +}); + describe('useOverflow with behavior=observeParent', () => { function mockContainerWithParent( parentWidth: number, diff --git a/packages/core/src/hooks/useOverflow.ts b/packages/core/src/hooks/useOverflow.ts index ff0269bb8642..14d9e17f846c 100644 --- a/packages/core/src/hooks/useOverflow.ts +++ b/packages/core/src/hooks/useOverflow.ts @@ -10,15 +10,19 @@ * * Measures children rendered in a hidden container to determine how many fit * in the available width, without flickering. Uses ResizeObserver to react - * to container size changes. + * to container size changes. Supports an optional item cap (`maxVisibleItems`) + * and bounded multi-row wrapping (`maxRows`); the fit/clamp/row-packing math + * lives in the pure `computeOverflow` helper. * * SYNC: When modified, update: * - /packages/core/src/hooks/index.ts + * - /packages/core/src/hooks/computeOverflow.ts (the pure computation) */ import {useState, useCallback, useRef} from 'react'; import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; import {observeResize, unobserveResize} from '../utils/sharedResizeObserver'; +import {computeOverflow} from './computeOverflow'; export interface UseOverflowOptions { /** @@ -33,6 +37,23 @@ export interface UseOverflowOptions { */ minVisibleItems?: number; + /** + * Maximum number of items to ever show, even if they all fit. The ceiling + * partner to `minVisibleItems`. `undefined` means no cap. When it is less + * than `minVisibleItems`, the floor wins and a dev-only warning is emitted. + * @default undefined + */ + maxVisibleItems?: number; + + /** + * Wrap items across up to this many rows before collapsing the rest into the + * overflow indicator. `undefined` (or `1`) keeps the single-line behavior. + * A number, not a boolean: unbounded wrapping is a plain flex-wrap layout, + * not overflow collapse. Assumes uniform row height. + * @default undefined + */ + maxRows?: number; + /** * Which end to collapse items from. * @default 'end' @@ -61,6 +82,10 @@ export interface UseOverflowReturn { visibleCount: number; /** Whether any items are overflowing */ hasOverflow: boolean; + /** Number of rows the visible items occupy (1 for the single-line path) */ + rows: number; + /** Measured max item height in pixels; used to size the multi-row container */ + rowHeight: number; } /** @@ -88,13 +113,29 @@ export function useOverflow( const { gap = 0, minVisibleItems = 0, + maxVisibleItems, + maxRows, collapseFrom = 'end', behavior = 'observeSelf', } = options; + if ( + process.env.NODE_ENV !== 'production' && + maxVisibleItems != null && + maxVisibleItems < minVisibleItems + ) { + console.warn( + `useOverflow: maxVisibleItems (${maxVisibleItems}) is less than ` + + `minVisibleItems (${minVisibleItems}); the floor wins and ` + + `minVisibleItems items will be shown.`, + ); + } + const observeParent = behavior === 'observeParent'; const [visibleCount, setVisibleCount] = useState(itemCount); + const [rows, setRows] = useState(1); + const [rowHeight, setRowHeight] = useState(0); const containerElRef = useRef(null); const measureElRef = useRef(null); const observedElRef = useRef(null); @@ -134,44 +175,45 @@ export function useOverflow( if (children.length === 0) { // eslint-disable-next-line @eslint-react/set-state-in-effect -- overflow count is derived from measured DOM widths setVisibleCount(0); + // eslint-disable-next-line @eslint-react/set-state-in-effect -- derived from measured DOM + setRows(0); return; } - // Collect widths of all children const widths = children.map(child => child.offsetWidth); - - // Determine how many items fit - let totalWidth = 0; - let count = 0; - - const orderedWidths = - collapseFrom === 'end' ? widths : [...widths].reverse(); - - for (let i = 0; i < orderedWidths.length; i++) { - const itemWidth = orderedWidths[i]; - const gapWidth = i > 0 ? gap : 0; - const candidateWidth = totalWidth + itemWidth + gapWidth; - - // If this isn't the last item, we need to reserve space for the indicator - const isLastItem = i === orderedWidths.length - 1; - const reservedWidth = isLastItem - ? 0 - : indicatorWidth + (count > 0 || indicatorWidth > 0 ? gap : 0); - - if ( - candidateWidth + reservedWidth > availableWidth && - count >= minVisibleItems - ) { - break; - } - - totalWidth = candidateWidth; - count++; - } + const measuredRowHeight = children.reduce( + (max, child) => Math.max(max, child.offsetHeight || 0), + 0, + ); + + const {visibleCount: nextVisible, rows: nextRows} = computeOverflow({ + widths, + gap, + availableWidth, + indicatorWidth, + minVisibleItems, + maxVisibleItems, + maxRows, + collapseFrom, + }); // eslint-disable-next-line @eslint-react/set-state-in-effect -- overflow count is derived from measured DOM widths - setVisibleCount(Math.max(Math.min(count, itemCount), minVisibleItems)); - }, [itemCount, gap, minVisibleItems, collapseFrom, observeParent]); + setVisibleCount(nextVisible); + // eslint-disable-next-line @eslint-react/set-state-in-effect -- derived from measured DOM + setRows(prev => (prev === nextRows ? prev : nextRows)); + // eslint-disable-next-line @eslint-react/set-state-in-effect -- derived from measured DOM + setRowHeight(prev => + prev === measuredRowHeight ? prev : measuredRowHeight, + ); + }, [ + itemCount, + gap, + minVisibleItems, + maxVisibleItems, + maxRows, + collapseFrom, + observeParent, + ]); const containerRef = useCallback( (el: HTMLElement | null) => { @@ -217,5 +259,7 @@ export function useOverflow( measureRef, visibleCount, hasOverflow, + rows, + rowHeight, }; } From b4b2f60a09c0726c40423441078beeddcdd99b47 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Fri, 24 Jul 2026 03:05:35 +0000 Subject: [PATCH 2/2] fix(OverflowList): use mutable widths type in computeOverflow test fixture The shared test fixture was declared with `as const`, which typed `widths` as a readonly tuple. That is not assignable to the mutable `number[]` in `ComputeOverflowInput`, so `tsc` failed the core typecheck (which includes tests). Type the fixture as `Partial` instead so the spreads type-check. --- packages/core/src/hooks/computeOverflow.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/hooks/computeOverflow.test.ts b/packages/core/src/hooks/computeOverflow.test.ts index 9a43c0306d04..9067ca6e9374 100644 --- a/packages/core/src/hooks/computeOverflow.test.ts +++ b/packages/core/src/hooks/computeOverflow.test.ts @@ -347,12 +347,12 @@ describe('computeOverflow — multi-row (maxRows)', () => { }); describe('computeOverflow — resize behavior (shrink/grow re-clamps, never exceeds cap)', () => { - const base = { + const base: Partial = { widths: [50, 50, 50, 50, 50], gap: 10, indicatorWidth: 30, maxVisibleItems: 3, - } as const; + }; it('grows back only up to the cap', () => { // Huge container would fit all 5, cap holds at 3.