Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/overflowlist-cap-multirow.md
Original file line number Diff line number Diff line change
@@ -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
119 changes: 114 additions & 5 deletions apps/storybook/stories/OverflowList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const meta: Meta<typeof OverflowList> = {
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'],
Expand Down Expand Up @@ -270,11 +278,7 @@ export const DynamicItems: Story = {
size="sm"
onClick={() => setCount(c => Math.max(1, c - 1))}
/>
<Button
label="Add"
size="sm"
onClick={() => setCount(c => c + 1)}
/>
<Button label="Add" size="sm" onClick={() => setCount(c => c + 1)} />
<span>{count} items</span>
</div>
<div
Expand Down Expand Up @@ -305,3 +309,108 @@ export const DynamicItems: Story = {
);
},
};

// Cap the number of visible items with maxVisibleItems — even in a wide
// container only three actions show; the rest move to the overflow dropdown.
export const CappedItems: Story = {
render: () => {
const actions = ['Save', 'Edit', 'Duplicate', 'Share', 'Archive', 'Delete'];
return (
<div style={{maxWidth: 700, border: '1px dashed #ccc', padding: 8}}>
<OverflowList
gap={2}
maxVisibleItems={3}
overflowRenderer={overflowItems => (
<DropdownMenu
button={{
label: `+${overflowItems.length}`,
variant: 'ghost',
size: 'sm',
}}
items={overflowItems.map(({index}) => ({
label: actions[index],
}))}
/>
)}>
{actions.map(action => (
<Button key={action} label={action} size="sm" />
))}
</OverflowList>
</div>
);
},
};

// Multi-row wrapping — tags flow onto up to two rows, then collapse the rest
// into a count badge. Resize the container to see rows fill and collapse.
export const MultiRow: Story = {
render: () => {
const tags = [
'React',
'TypeScript',
'StyleX',
'Storybook',
'Vitest',
'Playwright',
'ESLint',
'Prettier',
'Vite',
'pnpm',
];
return (
<div
style={{
resize: 'horizontal',
overflow: 'hidden',
border: '1px dashed #ccc',
padding: 8,
width: 280,
minWidth: 120,
maxWidth: '100%',
}}>
<OverflowList
gap={1}
maxRows={2}
overflowRenderer={overflowItems => (
<Badge variant="neutral" label={`+${overflowItems.length}`} />
)}>
{tags.map(tag => (
<Badge key={tag} variant="info" label={tag} />
))}
</OverflowList>
</div>
);
},
};

// Cap + multi-row together — whichever limit is hit first wins. Here the cap
// of five items applies even though two rows could hold more.
export const CappedMultiRow: Story = {
render: () => {
const tags = [
'React',
'TypeScript',
'StyleX',
'Storybook',
'Vitest',
'Playwright',
'ESLint',
'Prettier',
];
return (
<div style={{maxWidth: 520, border: '1px dashed #ccc', padding: 8}}>
<OverflowList
gap={1}
maxRows={2}
maxVisibleItems={5}
overflowRenderer={overflowItems => (
<Badge variant="neutral" label={`+${overflowItems.length}`} />
)}>
{tags.map(tag => (
<Badge key={tag} variant="info" label={tag} />
))}
</OverflowList>
</div>
);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('../../../../../core/src/docs-types').TemplateDoc} */
export const doc = {
type: 'block',
exampleFor: 'OverflowList',
name: 'OverflowList — Capped Toolbar',
displayName: 'OverflowList — Capped Toolbar',
description:
'maxVisibleItems caps the row at three actions even when more would fit; the rest move to a dropdown',
isReady: true,
aspectRatio: 4 / 3,
componentsUsed: ['OverflowList', 'Button', 'DropdownMenu', 'Card', 'Center'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

import {OverflowList} from '@astryxdesign/core/OverflowList';
import {Button} from '@astryxdesign/core/Button';
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
import {Card} from '@astryxdesign/core/Card';
import {Center} from '@astryxdesign/core/Center';

const actions = ['Save', 'Edit', 'Duplicate', 'Share', 'Archive', 'Delete'];

export default function OverflowListCappedToolbar() {
return (
<Center width={420}>
<Card padding={2}>
<OverflowList
gap={2}
maxVisibleItems={3}
overflowRenderer={overflowItems => (
<DropdownMenu
button={{
label: `+${overflowItems.length}`,
variant: 'ghost',
size: 'sm',
}}
items={overflowItems.map(({index}) => ({
label: actions[index],
}))}
/>
)}>
{actions.map(action => (
<Button key={action} label={action} size="sm" />
))}
</OverflowList>
</Card>
</Center>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('../../../../../core/src/docs-types').TemplateDoc} */
export const doc = {
type: 'block',
exampleFor: 'OverflowList',
name: 'OverflowList — Multi-row Tags',
displayName: 'OverflowList — Multi-row Tags',
description:
'Tags wrap onto up to two rows with maxRows, then collapse the rest into a count badge',
isReady: true,
aspectRatio: 4 / 3,
componentsUsed: ['OverflowList', 'Badge', 'Card'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

import {OverflowList} from '@astryxdesign/core/OverflowList';
import {Badge} from '@astryxdesign/core/Badge';
import {Card} from '@astryxdesign/core/Card';

const tags = [
'React',
'TypeScript',
'StyleX',
'Storybook',
'Vitest',
'Playwright',
'ESLint',
'Prettier',
'Vite',
'pnpm',
];

export default function OverflowListMultiRowTags() {
return (
<Card
padding={2}
style={{
resize: 'horizontal',
overflow: 'hidden',
minWidth: 120,
width: 260,
}}>
<OverflowList
gap={1}
maxRows={2}
overflowRenderer={overflowItems => (
<Badge variant="neutral" label={`+${overflowItems.length}`} />
)}>
{tags.map(tag => (
<Badge key={tag} variant="info" label={tag} />
))}
</OverflowList>
</Card>
);
}
45 changes: 39 additions & 6 deletions packages/core/src/OverflowList/OverflowList.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ export const docs = {
description: 'Minimum number of items to always show, even when overflowing.',
default: '0',
},
{
name: 'maxVisibleItems',
type: 'number',
description:
'Maximum number of items to ever show, even when they all fit. The ceiling partner to minVisibleItems; extra items collapse into the overflow indicator. If less than minVisibleItems, the floor wins.',
default: 'undefined (no cap)',
},
{
name: 'maxRows',
type: 'number',
description:
'Wrap items across up to this many rows before collapsing the rest into the overflow indicator. A number, not a boolean. Leave undefined (or set 1) for single-line behavior. Assumes uniform row height.',
default: 'undefined (single line)',
},
{
name: 'collapseFrom',
type: "'start' | 'end'",
Expand Down Expand Up @@ -91,8 +105,9 @@ export const docs = {
'A horizontal list that automatically hides items when they exceed the available width. Use OverflowList for breadcrumbs, toolbars, tag lists, or any row that needs to collapse gracefully at smaller sizes.',
bestPractices: [
{ guidance: true, description: 'Provide a meaningful overflowRenderer: a "+N more" badge, a dropdown, or a count indicator.' },
{ guidance: true, description: 'Set minVisibleItems to keep key items visible regardless of container size.' },
{ guidance: false, description: 'Use OverflowList for vertical layouts; it only works with horizontal rows.' },
{ guidance: true, description: 'Set minVisibleItems to keep key items visible, and maxVisibleItems to cap the row at a fixed count regardless of available width.' },
{ guidance: true, description: 'Use maxRows to let items wrap onto a bounded number of rows (e.g. a two-row tag cloud) before collapsing the rest into the indicator.' },
{ guidance: false, description: 'Use OverflowList for a vertical stack; horizontal multi-row wrap is supported via maxRows, but items still flow left-to-right, not top-to-bottom.' },
],
},
};
Expand Down Expand Up @@ -127,6 +142,20 @@ export const docsZh = {
description: '溢出时始终显示的最小项目数。',
default: '0',
},
{
name: 'maxVisibleItems',
type: 'number',
description:
'始终显示的最大项目数(即使全部都能放下)。它是 minVisibleItems 的上限伙伴;多余项目会折叠进溢出指示器。若小于 minVisibleItems,则以下限为准。',
default: 'undefined(无上限)',
},
{
name: 'maxRows',
type: 'number',
description:
'在将其余项目折叠进溢出指示器之前,允许项目换行到的最大行数。是数字而非布尔值。留空(或设为 1)表示单行。假定行高一致。',
default: 'undefined(单行)',
},
{
name: 'collapseFrom',
type: "'start' | 'end'",
Expand All @@ -152,8 +181,9 @@ export const docsZh = {
'A horizontal list that automatically hides items when they exceed the available width. Use OverflowList for breadcrumbs, toolbars, tag lists, or any row that needs to collapse gracefully at smaller sizes.',
bestPractices: [
{ guidance: true, description: 'Provide a meaningful overflowRenderer: a "+N more" badge, a dropdown, or a count indicator.' },
{ guidance: true, description: 'Set minVisibleItems to keep key items visible regardless of container size.' },
{ guidance: false, description: 'Use OverflowList for vertical layouts; it only works with horizontal rows.' },
{ guidance: true, description: 'Set minVisibleItems to keep key items visible, and maxVisibleItems to cap the row at a fixed count regardless of available width.' },
{ guidance: true, description: 'Use maxRows to let items wrap onto a bounded number of rows (e.g. a two-row tag cloud) before collapsing the rest into the indicator.' },
{ guidance: false, description: 'Use OverflowList for a vertical stack; horizontal multi-row wrap is supported via maxRows, but items still flow left-to-right, not top-to-bottom.' },
],
},
};
Expand All @@ -166,15 +196,18 @@ export const docsDense = {
'A horizontal list that automatically hides items when they exceed the available width. Use OverflowList for breadcrumbs, toolbars, tag lists, or any row that needs to collapse gracefully at smaller sizes.',
bestPractices: [
{ guidance: true, description: 'Provide a meaningful overflowRenderer: a "+N more" badge, a dropdown, or a count indicator.' },
{ guidance: true, description: 'Set minVisibleItems to keep key items visible regardless of container size.' },
{ guidance: false, description: 'Use OverflowList for vertical layouts; it only works with horizontal rows.' },
{ guidance: true, description: 'Set minVisibleItems to keep key items visible, and maxVisibleItems to cap the row at a fixed count regardless of available width.' },
{ guidance: true, description: 'Use maxRows to let items wrap onto a bounded number of rows (e.g. a two-row tag cloud) before collapsing the rest into the indicator.' },
{ guidance: false, description: 'Use OverflowList for a vertical stack; horizontal multi-row wrap is supported via maxRows, but items still flow left-to-right, not top-to-bottom.' },
],
},
propDescriptions: {
children: 'items to render, each child should be a single element',
overflowRenderer: 'renders overflow indicator, receives hidden items w/ index',
gap: 'item gap as spacing step',
minVisibleItems: 'min items always shown even when overflowing',
maxVisibleItems: 'max items ever shown (cap); extra items collapse to indicator; min wins if smaller',
maxRows: 'wrap items across up to N rows then collapse rest (number, not boolean); undefined = single line',
collapseFrom: 'which end to collapse from',
behavior: 'observeSelf (default) or observeParent for content-sized containers',
xstyle: 'StyleX styles, use stylex.create() values only',
Expand Down
Loading
Loading