@kne/overflow-items
v0.1.0
Published
测量剩余宽度并自动决定露出若干 item 的 React 溢出收起库,提供组件、Hook 与纯函数,支持更多插槽与防抖。
Readme
overflow-items
描述
测量剩余宽度并自动决定露出若干 item 的 React 溢出收起库,提供组件、Hook 与纯函数,支持更多插槽与防抖。
安装
npm i --save @kne/overflow-items概述
OverflowItems 帮你在有限宽度里「尽量多露、装不下就收起」。它测量容器剩余空间,预留「更多」按钮宽度与间距,自动算出能放出几个 item——按钮组、筛选条、标签行都可以复用同一套能力。
核心能力拆成三层,按需选用:纯函数 computeVisibleCount 做算法;useOverflowItems 负责测宽、Resize 防抖与稳定裁切;组件 OverflowItems 用 render props 接入你自己的 item / more UI,不绑 antd。表格等「列宽一致、item 多数相同」场景可用 OverflowItems.Share 共享测宽。
特点:
- 首屏可先全量或最少占位,宽度稳定后再一次性裁切,减少闪烁
- Resize 路径防抖(
debounce可配),拖拽窗口时不会疯狂重算 - 支持 asc / desc 两种可见数策略,以及自定义 more 渲染
- Headless 友好:业务只接插槽,Dropdown、展开收起等交互留在业务侧
OverflowItems.Share:同签名多实例只测一次,适合表格操作列
示例
示例代码
- OverflowItems
- 横向工具栏 / 筛选条 / 表格行操作等业务场景下,按容器宽度单行排布 item,装不下收入「更多」。覆盖 gap、debounce、beforeReady、strategy、renderMore(含 Dropdown)及 enabled 展开全部。
- _OverflowItems(@kne/current-lib_overflow-items)[import * as _OverflowItems from "@kne/overflow-items"],(@kne/current-lib_overflow-items/dist/index.css),antd(antd),remoteLoader(@kne/remote-loader)
const { default: OverflowItems } = _OverflowItems;
const { createWithRemoteLoader } = remoteLoader;
const { Button, Card, Dropdown, Flex, Slider, Space, Switch, Tag, Typography } = antd;
const { Text } = Typography;
const { useState } = React;
/** 列表页操作栏按钮(业务场景) */
const LIST_TOOLBAR_ACTIONS = [
{ key: 'create', type: 'primary', children: '新建候选人' },
{ key: 'import', children: '批量导入' },
{ key: 'export', children: '导出 Excel' },
{ key: 'assign', children: '分配顾问' },
{ key: 'tag', children: '打标签' },
{ key: 'archive', children: '归档' },
{ key: 'delete', danger: true, children: '批量删除' }
];
/** 筛选条标签 */
const FILTER_TAGS = [
{ key: 'status', label: '状态:在职' },
{ key: 'city', label: '城市:上海 / 杭州' },
{ key: 'level', label: '职级:P6-P8' },
{ key: 'dept', label: '部门:技术研发中心' },
{ key: 'join', label: '入职:近 90 天' },
{ key: 'source', label: '来源:猎头推荐' },
{ key: 'owner', label: '负责人:张敏' }
];
/** 表格行操作 */
const ROW_ACTIONS = [
{ key: 'view', type: 'link', children: '查看' },
{ key: 'edit', type: 'link', children: '编辑' },
{ key: 'follow', type: 'link', children: '跟进' },
{ key: 'transfer', type: 'link', children: '转交' },
{ key: 'reject', type: 'link', children: '淘汰' },
{ key: 'delete', type: 'link', danger: true, children: '删除' }
];
const toolbarBox = width => ({
width,
maxWidth: '100%',
padding: '8px 12px',
background: '#fff',
border: '1px solid #f0f0f0',
borderRadius: 8
});
const BaseExample = createWithRemoteLoader({
modules: ['components-core:Global@PureGlobal', 'components-core:InfoPage']
})(({ remoteModules }) => {
const [PureGlobal, InfoPage] = remoteModules;
const [toolbarWidth, setToolbarWidth] = useState(420);
const [strategyWidth, setStrategyWidth] = useState(260);
const [tagWidth, setTagWidth] = useState(320);
const [rowWidth, setRowWidth] = useState(280);
const [gap, setGap] = useState(8);
const [debounce, setDebounce] = useState(100);
const [beforeReadyMin, setBeforeReadyMin] = useState(false);
const [expanded, setExpanded] = useState(false);
return (
<PureGlobal>
<InfoPage>
<InfoPage.Part title="列表工具栏(横向收起)" subtitle="拖动滑块改变容器宽度,按钮单行排布,装不下收入「更多」">
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 640 }}>
<Slider min={180} max={640} step={10} value={toolbarWidth} onChange={setToolbarWidth} tooltip={{ formatter: v => `${v}px` }} />
<div style={toolbarBox(toolbarWidth)}>
<OverflowItems
items={LIST_TOOLBAR_ACTIONS}
gap={gap}
debounce={debounce}
beforeReady={beforeReadyMin ? 'min' : 'all'}
renderItem={item => <Button size="small" type={item.type} danger={item.danger}>{item.children}</Button>}
renderMore={({ hiddenItems }) => (
<Dropdown
menu={{
items: hiddenItems.map(item => ({
key: item.key,
danger: item.danger,
label: item.children
}))
}}
>
<Button size="small">更多</Button>
</Dropdown>
)}
/>
</div>
<Flex gap={16} wrap align="center">
<Text type="secondary">间距 gap:</Text>
<Slider min={0} max={16} style={{ width: 120 }} value={gap} onChange={setGap} />
<Text type="secondary">防抖 debounce:</Text>
<Slider min={0} max={300} step={50} style={{ width: 140 }} value={debounce} onChange={setDebounce} />
<Text type="secondary">beforeReady=min</Text>
<Switch checked={beforeReadyMin} onChange={setBeforeReadyMin} />
</Flex>
</Space>
</InfoPage.Part>
<InfoPage.Part title="strategy 左右对比" subtitle="同一宽度下并排对比 asc / desc,观察不等宽 Tag 的横向可见数量">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Slider min={160} max={400} step={10} value={strategyWidth} onChange={setStrategyWidth} tooltip={{ formatter: v => `${v}px` }} style={{ maxWidth: 480 }} />
<Flex gap={16} wrap="nowrap">
<Card size="small" title="asc · 从少往多" style={{ width: strategyWidth, flex: 'none' }}>
<OverflowItems
items={FILTER_TAGS}
strategy="asc"
gap={8}
renderItem={item => <Tag>{item.label}</Tag>}
renderMore={() => <Tag>更多</Tag>}
/>
</Card>
<Card size="small" title="desc · 从多往少" style={{ width: strategyWidth, flex: 'none' }}>
<OverflowItems
items={FILTER_TAGS}
strategy="desc"
gap={8}
renderItem={item => <Tag>{item.label}</Tag>}
renderMore={() => <Tag>更多</Tag>}
/>
</Card>
</Flex>
</Space>
</InfoPage.Part>
<InfoPage.Part title="自定义 renderMore" subtitle="筛选条场景:+N 下拉查看隐藏条件,也可临时关闭裁切展开全部">
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}>
<Flex align="center" gap={12}>
<Slider min={180} max={560} step={10} value={tagWidth} onChange={setTagWidth} tooltip={{ formatter: v => `${v}px` }} style={{ flex: 1 }} />
<Button size="small" type="link" onClick={() => setExpanded(v => !v)}>
{expanded ? '恢复自适应' : '展开全部'}
</Button>
</Flex>
<div style={toolbarBox(tagWidth)}>
<OverflowItems
items={FILTER_TAGS}
gap={8}
enabled={!expanded}
renderItem={item => <Tag color="blue">{item.label}</Tag>}
renderMore={({ hiddenItems, hiddenCount }) => (
<Dropdown
menu={{
items: hiddenItems.map(item => ({
key: item.key,
label: item.label
}))
}}
>
<Tag style={{ cursor: 'pointer' }}>+{hiddenCount}</Tag>
</Dropdown>
)}
/>
</div>
</Space>
</InfoPage.Part>
<InfoPage.Part title="表格行操作(link)" subtitle="窄列场景下横向 link 按钮自动收起">
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 480 }}>
<Slider min={120} max={480} step={10} value={rowWidth} onChange={setRowWidth} tooltip={{ formatter: v => `${v}px` }} />
<div style={toolbarBox(rowWidth)}>
<OverflowItems
items={ROW_ACTIONS}
gap={0}
beforeReady="min"
renderItem={item => (
<Button type="link" size="small" danger={item.danger}>
{item.children}
</Button>
)}
renderMore={({ hiddenItems }) => (
<Dropdown
menu={{
items: hiddenItems.map(item => ({
key: item.key,
danger: item.danger,
label: item.children
}))
}}
>
<Button type="link" size="small">
更多
</Button>
</Dropdown>
)}
/>
</div>
</Space>
</InfoPage.Part>
</InfoPage>
</PureGlobal>
);
});
render(<BaseExample />);
- OverflowItems.Share
- 定高滚动列表(默认 100 行可调):开关 Share 对比测量层 DOM 数与挂载耗时;拖宽度观察同桶同步裁切与卡顿差异。
- _OverflowItems(@kne/current-lib_overflow-items)[import * as _OverflowItems from "@kne/overflow-items"],(@kne/current-lib_overflow-items/dist/index.css),antd(antd),remoteLoader(@kne/remote-loader)
const { default: OverflowItems, useOverflowItems } = _OverflowItems;
const { createWithRemoteLoader } = remoteLoader;
const { Alert, Button, Card, Dropdown, Flex, Slider, Space, Switch, Tag, Typography } = antd;
const { Text, Paragraph } = Typography;
const { useEffect, useMemo, useState } = React;
const ROW_ACTIONS = [
{ key: 'view', children: '查看' },
{ key: 'edit', children: '编辑' },
{ key: 'follow', children: '跟进' },
{ key: 'transfer', children: '转交' },
{ key: 'remark', children: '备注' },
{ key: 'assign', children: '分配' },
{ key: 'reject', children: '淘汰' },
{ key: 'delete', children: '删除', danger: true }
];
const ADMIN_ACTIONS = [
{ key: 'view', children: '查看' },
{ key: 'edit', children: '编辑' },
{ key: 'audit', children: '审核' },
{ key: 'export', children: '导出' },
{ key: 'permission', children: '权限' },
{ key: 'delete', children: '删除', danger: true }
];
const MEASURE_CLASS = 'demo-share-measure';
const LIST_HEIGHT = 360;
const ROW_HEIGHT = 40;
const roleTag = role => {
if (role === 'leader') {
return <Tag color="blue">Leader</Tag>;
}
if (role === 'follower') {
return <Tag color="green">Follower</Tag>;
}
return <Tag>独立</Tag>;
};
/** 用 Hook 搭一行,便于展示 role / shouldMeasure */
const ActionRow = ({ items, shareKey, width, gap = 0 }) => {
const { setContainerRef, setMeasureRef, setMoreMeasureRef, visibleCount, ready, hiddenCount, role, shouldMeasure } = useOverflowItems({
itemCount: items.length,
items,
shareKey,
gap,
beforeReady: 'min',
debounce: 80
});
const visibleItems = items.slice(0, visibleCount);
const hiddenItems = items.slice(visibleCount);
return (
<Flex align="center" gap={8} style={{ width: '100%', height: ROW_HEIGHT }}>
<div style={{ width, maxWidth: '100%', minWidth: 0, flex: 'none', border: '1px dashed #d9d9d9', borderRadius: 6, padding: '2px 8px', background: '#fafafa' }}>
<div style={{ position: 'relative' }}>
{shouldMeasure ? (
<div
ref={setMeasureRef}
className={MEASURE_CLASS}
aria-hidden
style={{
position: 'absolute',
visibility: 'hidden',
height: 0,
overflow: 'hidden',
display: 'flex',
flexWrap: 'nowrap',
alignItems: 'center',
gap,
pointerEvents: 'none'
}}
>
{items.map(item => (
<div key={item.key} data-overflow-item>
<Button type="link" size="small" danger={item.danger}>
{item.children}
</Button>
</div>
))}
<div ref={setMoreMeasureRef} data-overflow-more>
<Button type="link" size="small">
更多
</Button>
</div>
</div>
) : null}
<div
ref={setContainerRef}
style={{
display: 'flex',
flexWrap: 'nowrap',
alignItems: 'center',
gap,
overflow: 'hidden',
width: '100%'
}}
>
{visibleItems.map(item => (
<Button key={item.key} type="link" size="small" danger={item.danger} style={{ flexShrink: 0, paddingInline: 4 }}>
{item.children}
</Button>
))}
{ready && hiddenCount > 0 ? (
<Dropdown
menu={{
items: hiddenItems.map(item => ({
key: item.key,
danger: item.danger,
label: item.children
}))
}}
>
<Button type="link" size="small" style={{ flexShrink: 0, paddingInline: 4 }}>
更多
</Button>
</Dropdown>
) : null}
</div>
</div>
</div>
<Space size={4} wrap>
{roleTag(role)}
<Tag color={shouldMeasure ? 'orange' : 'default'} style={{ marginInlineEnd: 0 }}>
{shouldMeasure ? '测' : '无测'}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{visibleCount}/{items.length}
</Text>
</Space>
</Flex>
);
};
const RowList = ({ rows, width, enableShare, listKey }) => {
const body = (
<div key={listKey}>
{rows.map(row => {
const actions = row.isAdmin ? ADMIN_ACTIONS : ROW_ACTIONS;
return (
<Flex
key={row.id}
align="center"
gap={12}
style={{
height: ROW_HEIGHT,
paddingInline: 8,
borderBottom: '1px solid #f0f0f0',
boxSizing: 'border-box'
}}
>
<Text style={{ width: 120, flex: 'none' }} ellipsis>
{row.name}
</Text>
<ActionRow items={actions} shareKey={row.isAdmin ? 'actions-admin' : undefined} width={width} />
</Flex>
);
})}
</div>
);
return enableShare ? <OverflowItems.Share>{body}</OverflowItems.Share> : body;
};
const BaseExample = createWithRemoteLoader({
modules: ['components-core:Global@PureGlobal', 'components-core:InfoPage']
})(({ remoteModules }) => {
const [PureGlobal, InfoPage] = remoteModules;
const [colWidth, setColWidth] = useState(200);
const [rowCount, setRowCount] = useState(100);
const [enableShare, setEnableShare] = useState(true);
const [measureCount, setMeasureCount] = useState(0);
const [mountMs, setMountMs] = useState(null);
const [remountToken, setRemountToken] = useState(0);
const rows = useMemo(
() =>
Array.from({ length: rowCount }, (_, index) => {
const isAdmin = index === rowCount - 1;
return {
id: index + 1,
name: isAdmin ? '管理员(独立桶)' : `候选人 ${index + 1}`,
isAdmin
};
}),
[rowCount]
);
const expectedBuckets = 2;
// 切换 Share / 行数时强制整表重挂,用 rAF 粗测挂载耗时
useEffect(() => {
const start = performance.now();
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => {
setMountMs(Math.round(performance.now() - start));
});
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, [enableShare, rowCount, remountToken]);
useEffect(() => {
const sync = () => {
setMeasureCount(document.querySelectorAll(`.${MEASURE_CLASS}`).length);
};
sync();
const id = window.setInterval(sync, 300);
return () => window.clearInterval(id);
}, [enableShare, colWidth, rowCount, remountToken]);
const onToggleShare = checked => {
setEnableShare(checked);
setRemountToken(v => v + 1);
};
return (
<PureGlobal>
<InfoPage>
<InfoPage.Part
title="OverflowItems.Share 共享测宽"
subtitle="定高滚动列表:调行数、开关 Share,对比测量层数量与挂载耗时;拖动宽度看同桶同步裁切。"
>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 920 }}>
<Alert
type="info"
showIcon
message="怎么测性能差距?"
description={
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
<li>
把行数拉到 100+,开关 <Text code>Share</Text>:看「测量层 DOM 数」和「挂载耗时」。
</li>
<li>
开 Share:测量层 ≈ 2(普通操作桶 + 管理员桶);关 Share:测量层 ≈ 行数。
</li>
<li>列表定高 {LIST_HEIGHT}px 可滚动;拖操作区宽度时,关 Share 更容易感到卡顿。</li>
</ul>
}
/>
<Card size="small">
<Flex wrap gap={24} align="center">
<Space>
<Text type="secondary">启用 Share</Text>
<Switch checked={enableShare} onChange={onToggleShare} checkedChildren="开" unCheckedChildren="关" />
</Space>
<Space>
<Text type="secondary">行数</Text>
<Slider min={20} max={300} step={20} value={rowCount} onChange={setRowCount} style={{ width: 180 }} />
<Text type="secondary">{rowCount}</Text>
</Space>
<Space>
<Text type="secondary">操作区宽度</Text>
<Slider min={100} max={360} step={10} value={colWidth} onChange={setColWidth} style={{ width: 180 }} tooltip={{ formatter: v => `${v}px` }} />
<Text type="secondary">{colWidth}px</Text>
</Space>
<Button
size="small"
onClick={() => {
setRemountToken(v => v + 1);
}}
>
重新挂载计时
</Button>
</Flex>
</Card>
<Flex gap={16} wrap>
<Card size="small" style={{ minWidth: 140 }}>
<Text type="secondary">测量层 DOM</Text>
<div>
<Text strong style={{ fontSize: 28, color: enableShare ? '#1677ff' : '#fa8c16' }}>
{measureCount}
</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
{enableShare ? `期望 ≈ ${expectedBuckets}` : `期望 ≈ ${rowCount}`}
</Text>
</Card>
<Card size="small" style={{ minWidth: 140 }}>
<Text type="secondary">挂载耗时(约)</Text>
<div>
<Text strong style={{ fontSize: 28 }}>
{mountMs == null ? '—' : mountMs}
</Text>
<Text type="secondary"> ms</Text>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
切换 Share / 行数 / 点重挂载后更新
</Text>
</Card>
<Card size="small" style={{ flex: 1, minWidth: 220 }}>
<Paragraph style={{ marginBottom: 0 }} type="secondary">
{enableShare
? `共享模式:${rowCount} 行里绝大多数是 Follower,只渲染可见按钮,测宽成本接近常数。`
: `独立模式:${rowCount} 行各挂一套测量 DOM + ResizeObserver,行数上去后拖宽度更容易卡。`}
</Paragraph>
</Card>
</Flex>
<Card
size="small"
title={enableShare ? `Share 开 · ${rowCount} 行` : `Share 关 · ${rowCount} 行独立测宽`}
styles={{ body: { padding: 0 } }}
>
<div style={{ height: LIST_HEIGHT, overflow: 'auto' }}>
<RowList rows={rows} width={colWidth} enableShare={enableShare} listKey={`${enableShare}-${rowCount}-${remountToken}`} />
</div>
</Card>
</Space>
</InfoPage.Part>
</InfoPage>
</PureGlobal>
);
});
render(<BaseExample />);
- useOverflowItems
- 无 UI 测宽 Hook:自行搭建测量层与可见层 DOM,获取 visibleCount / ready / hiddenCount。示例模拟候选人详情页操作条,并展示测量态与可见数量。
- _OverflowItems(@kne/current-lib_overflow-items)[import * as _OverflowItems from "@kne/overflow-items"],(@kne/current-lib_overflow-items/dist/index.css),antd(antd),remoteLoader(@kne/remote-loader)
const { useOverflowItems } = _OverflowItems;
const { createWithRemoteLoader } = remoteLoader;
const { Button, Dropdown, Flex, Slider, Space, Tag, Typography } = antd;
const { Text } = Typography;
const { useMemo, useState } = React;
const CANDIDATE_ACTIONS = [
{ key: 'resume', label: '查看简历' },
{ key: 'interview', label: '安排面试' },
{ key: 'offer', label: '发送 Offer' },
{ key: 'remark', label: '添加备注' },
{ key: 'share', label: '分享给同事' },
{ key: 'blacklist', label: '加入黑名单' }
];
const HeadlessToolbar = ({ items, width, gap = 8 }) => {
const { setContainerRef, setMeasureRef, setMoreMeasureRef, visibleCount, ready, hiddenCount } = useOverflowItems({
itemCount: items.length,
gap,
debounce: 100,
beforeReady: 'all'
});
const visibleItems = items.slice(0, visibleCount);
const hiddenItems = items.slice(visibleCount);
return (
<div style={{ width, maxWidth: '100%', padding: '8px 12px', background: '#fff', border: '1px solid #f0f0f0', borderRadius: 8 }}>
<div style={{ position: 'relative' }}>
<div
ref={setMeasureRef}
aria-hidden
style={{
position: 'absolute',
visibility: 'hidden',
height: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
alignItems: 'center',
gap,
pointerEvents: 'none'
}}
>
{items.map(item => (
<div key={item.key} data-overflow-item>
<Button size="small">{item.label}</Button>
</div>
))}
<div ref={setMoreMeasureRef} data-overflow-more>
<Button size="small">更多</Button>
</div>
</div>
<div
ref={setContainerRef}
style={{
display: 'flex',
flexDirection: 'row',
flexWrap: 'nowrap',
alignItems: 'center',
gap,
overflow: 'hidden',
width: '100%'
}}
>
{visibleItems.map(item => (
<Button key={item.key} size="small" style={{ flexShrink: 0 }}>
{item.label}
</Button>
))}
{ready && hiddenCount > 0 ? (
<Dropdown
menu={{
items: hiddenItems.map(item => ({
key: item.key,
label: item.label
}))
}}
>
<Button size="small" style={{ flexShrink: 0 }}>
更多
</Button>
</Dropdown>
) : null}
</div>
</div>
<Flex gap={8} style={{ marginTop: 8 }}>
<Tag color={ready ? 'success' : 'processing'}>{ready ? 'ready' : 'measuring'}</Tag>
<Text type="secondary">
visibleCount={visibleCount} / hiddenCount={hiddenCount}
</Text>
</Flex>
</div>
);
};
const BaseExample = createWithRemoteLoader({
modules: ['components-core:Global@PureGlobal', 'components-core:InfoPage']
})(({ remoteModules }) => {
const [PureGlobal, InfoPage] = remoteModules;
const [width, setWidth] = useState(360);
const items = useMemo(() => CANDIDATE_ACTIONS, []);
return (
<PureGlobal>
<InfoPage>
<InfoPage.Part
title="useOverflowItems 无 UI 测宽"
subtitle="自行搭建测量层与可见层 DOM,Hook 只返回 visibleCount / ready;适合已有布局不想用 OverflowItems 组件的场景"
>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}>
<Text type="secondary">候选人详情页操作条:拖动宽度观察 Hook 输出的可见数量</Text>
<Slider min={160} max={560} step={10} value={width} onChange={setWidth} tooltip={{ formatter: v => `${v}px` }} />
<HeadlessToolbar items={items} width={width} gap={8} />
</Space>
</InfoPage.Part>
</InfoPage>
</PureGlobal>
);
});
render(<BaseExample />);
- computeVisibleCount
- 纯函数计算可见数量:传入容器宽、itemWidths、moreWidth、gap、safetyGap 与 strategy,对比 asc / desc 结果,便于在非 React 环境或单测中复用算法。
- _OverflowItems(@kne/current-lib_overflow-items)[import * as _OverflowItems from "@kne/overflow-items"],antd(antd),remoteLoader(@kne/remote-loader)
const { computeVisibleCount } = _OverflowItems;
const { createWithRemoteLoader } = remoteLoader;
const { Button, Card, Flex, InputNumber, Space, Table, Typography } = antd;
const { Text } = Typography;
const { useMemo, useState } = React;
const BaseExample = createWithRemoteLoader({
modules: ['components-core:Global@PureGlobal', 'components-core:InfoPage']
})(({ remoteModules }) => {
const [PureGlobal, InfoPage] = remoteModules;
const [containerWidth, setContainerWidth] = useState(280);
const [moreWidth, setMoreWidth] = useState(64);
const [gap, setGap] = useState(8);
const [safetyGap, setSafetyGap] = useState(2);
const [itemWidths, setItemWidths] = useState([72, 64, 88, 96, 80, 72]);
const ascCount = useMemo(
() =>
computeVisibleCount({
containerWidth,
itemWidths,
moreWidth,
gap,
safetyGap,
strategy: 'asc'
}),
[containerWidth, itemWidths, moreWidth, gap, safetyGap]
);
const descCount = useMemo(
() =>
computeVisibleCount({
containerWidth,
itemWidths,
moreWidth,
gap,
safetyGap,
strategy: 'desc'
}),
[containerWidth, itemWidths, moreWidth, gap, safetyGap]
);
const previewRows = itemWidths.map((w, index) => ({
key: index,
index: index + 1,
width: w,
ascVisible: index < ascCount,
descVisible: index < descCount
}));
return (
<PureGlobal>
<InfoPage>
<InfoPage.Part
title="computeVisibleCount 纯函数"
subtitle="无 DOM:给定容器宽、各 item 宽、更多按钮宽与间距,直接算出可见数量;可在非 React 环境复用"
>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 720 }}>
<Flex wrap gap={16}>
<Space>
<Text type="secondary">容器宽度</Text>
<InputNumber min={80} max={800} value={containerWidth} onChange={setContainerWidth} addonAfter="px" />
</Space>
<Space>
<Text type="secondary">更多按钮宽</Text>
<InputNumber min={0} max={200} value={moreWidth} onChange={setMoreWidth} addonAfter="px" />
</Space>
<Space>
<Text type="secondary">gap</Text>
<InputNumber min={0} max={24} value={gap} onChange={setGap} />
</Space>
<Space>
<Text type="secondary">safetyGap</Text>
<InputNumber min={0} max={16} value={safetyGap} onChange={setSafetyGap} />
</Space>
</Flex>
<Card size="small" title="itemWidths(模拟按钮实测宽度)">
<Space wrap>
{itemWidths.map((w, index) => (
<InputNumber
key={index}
min={24}
max={200}
value={w}
onChange={value => {
setItemWidths(list => list.map((item, i) => (i === index ? value || 24 : item)));
}}
addonBefore={`#${index + 1}`}
addonAfter="px"
/>
))}
<Button
onClick={() => setItemWidths(list => [...list, 72])}
disabled={itemWidths.length >= 10}
>
增加 item
</Button>
<Button
onClick={() => setItemWidths(list => list.slice(0, -1))}
disabled={itemWidths.length <= 1}
>
减少 item
</Button>
</Space>
</Card>
<Flex gap={24}>
<Card size="small" title="strategy=asc">
<Text strong style={{ fontSize: 24 }}>
{ascCount}
</Text>
<Text type="secondary"> 个可见</Text>
</Card>
<Card size="small" title="strategy=desc">
<Text strong style={{ fontSize: 24 }}>
{descCount}
</Text>
<Text type="secondary"> 个可见</Text>
</Card>
</Flex>
<Table
size="small"
pagination={false}
dataSource={previewRows}
columns={[
{ title: '序号', dataIndex: 'index', width: 64 },
{ title: '宽度', dataIndex: 'width', width: 96, render: v => `${v}px` },
{
title: 'asc 是否露出',
dataIndex: 'ascVisible',
render: v => (v ? '是' : '否')
},
{
title: 'desc 是否露出',
dataIndex: 'descVisible',
render: v => (v ? '是' : '否')
}
]}
/>
</Space>
</InfoPage.Part>
</InfoPage>
</PureGlobal>
);
});
render(<BaseExample />);
API
OverflowItems
根据容器宽度自动决定露出多少 item,并将剩余项收起到「更多」。无 UI 框架依赖,通过 renderItem / renderMore 自定义展示。
属性
| 属性 | 类型 | 默认值 | 说明 |
| ---------------- | --------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------- |
| items | any[] | [] | 数据源 |
| renderItem | (item, index, ctx) => ReactNode | — | 渲染单个 item;ctx 含 visible、ready |
| renderMore | (ctx) => ReactNode | 默认文案「更多」 | 渲染收起入口;ctx 含 hiddenItems、hiddenCount、visibleCount、ready;无隐藏项时不渲染 |
| gap | number | 8 | item 间距(同时用于测量计算与可见层 CSS gap) |
| strategy | 'asc' \| 'desc' | 'asc' | 可见数计算策略:asc 从少往多,desc 从多往少 |
| enabled | boolean | true | 为 false 时展示全部,不裁切 |
| beforeReady | 'all' \| 'min' | 'all' | 首次测量完成前:all 先全量,min 至少保留 1 个并隐藏可见区防闪 |
| debounce | number | 100 | Resize 触发重算的防抖毫秒;0 关闭防抖 |
| safetyGap | number | 2 | 计算时的宽度容差 |
| expandHysteresis | number | 16 | ready 后变宽再放出更多项的滞后像素 |
| settle | number | 100 | 首次裁切前容器宽度需连续稳定的时长 |
| maxSettle | number | 400 | 测量态最长等待,超时强制结束 |
| itemSelector | string | '[data-overflow-item]' | 测量层 item 选择器 |
| observeParent | boolean | true | 是否同时观察父节点宽度变化 |
| shareKey | string | — | 覆盖 Share 分桶签名;不传则从 items 的 key/id/label/children 推导 |
| className | string | — | 根节点 class |
| measureClassName | string | — | 测量层 class |
| contentClassName | string | — | 可见层 class |
| style | CSSProperties | — | 根节点行内样式 |
OverflowItems.Share
共享测宽上下文。包在表格等「同列宽、item 多数相同」场景外,组内同签名实例只保留一个 Leader 测宽,其余 Follower 不渲染测量层、不挂 ResizeObserver,只消费共享的 visibleCount / ready。
<OverflowItems.Share>
{rows.map(row => (
<OverflowItems key={row.id} items={row.actions} renderItem={...} renderMore={...} />
))}
</OverflowItems.Share>宽度约定:同 bucket 内各实例容器宽度须一致(如表格固定操作列)。行宽真不同时不要包进同一 Share,或使用不同 shareKey。
分桶:默认由 items 标识 + gap / strategy / safetyGap / expandHysteresis / itemCount 组成签名;可用 shareKey 覆盖。Leader 卸载后按注册顺序提升下一个成员。也可 import { OverflowItemsShare } from '@kne/overflow-items'。
useOverflowItems
无 UI 的测宽 Hook:绑定容器 / 测量层 / more 测量节点后,返回可见数量。Resize 走防抖,配置或 itemCount 变化立即重算。处于 OverflowItems.Share 内时自动参与共享。
参数
| 参数 | 类型 | 默认值 | 说明 |
| ---------------- | ----------------- | ------------------------ | --------------------------------------------------- |
| itemCount | number | 0 | item 总数 |
| items | any[] | — | 用于 Share 签名推导;组件场景会传入 |
| shareKey | string | — | 覆盖 Share 分桶签名 |
| enabled | boolean | true | 关闭时 visibleCount = itemCount 且 ready = true |
| strategy | 'asc' \| 'desc' | 'asc' | 传给 computeVisibleCount |
| gap | number | 8 | 间距 |
| safetyGap | number | 2 | 计算容差 |
| expandHysteresis | number | 16 | 变宽放出滞后 |
| settle | number | 100 | 首次裁切前稳定时长 |
| maxSettle | number | 400 | 强制结束测量态 |
| debounce | number | 100 | Resize 防抖;0 关闭 |
| beforeReady | 'all' \| 'min' | 'all' | 未 ready 时的占位策略 |
| itemSelector | string | '[data-overflow-item]' | 测量层 item 选择器 |
| observeParent | boolean | true | 是否观察父节点 |
返回值
| 字段 | 类型 | 说明 |
| ----------------- | ------------------------------ | ------------------------------------------------------------- |
| setContainerRef | (node) => void | 可见容器 / 宽度基准 |
| setMeasureRef | (node) => void | 隐藏测量层(需包含全量 item) |
| setMoreMeasureRef | (node) => void | 「更多」测量节点 |
| visibleCount | number | 当前应露出的数量 |
| ready | boolean | 是否已完成首次稳定裁切 |
| hiddenCount | number | itemCount - visibleCount |
| role | 'leader' \| 'follower' \| null | Share 内角色;未共享时为 null |
| shouldMeasure | boolean | 是否需要渲染测量层(Follower 为 false) |
测量 DOM 约定
测量层内每个 item 需带 data-overflow-item(或与 itemSelector 一致);「更多」节点通过 setMoreMeasureRef 绑定。shouldMeasure === false 时可省略测量层。
computeVisibleCount
纯函数:根据容器与各段宽度计算可见 item 数,无 DOM / React 依赖。
参数
| 参数 | 类型 | 默认值 | 说明 |
| -------------- | ----------------- | ------- | ------------------------------ |
| containerWidth | number | — | 容器可用宽度 |
| itemWidths | number[] | — | 各 item 宽度 |
| moreWidth | number | — | 「更多」按钮宽度(收起时预留) |
| gap | number | 8 | item 间距 |
| strategy | 'asc' \| 'desc' | 'asc' | 推算方向 |
| safetyGap | number | 2 | 宽度容差 |
返回值
| 类型 | 说明 | | ------ | -------------------------------------------------- | | number | 可见 item 数量;有 item 时至少为 1;无 item 时为 0 |
