From c7c256119d12841481bb947727bf8ac2ef3fe7b6 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Sun, 30 Aug 2026 13:11:34 +0800 Subject: [PATCH 1/3] feat: support scrollWidth for horizontal scrolling in virtual mode Forward `scrollWidth` to the underlying virtual list so a horizontal scrollbar shows up, and widen both row kinds to the content width. The sticky group header is portalled into the holder, outside the inner container that virtual-list shifts by `-offsetX`, so it has to mirror that shift itself or it stays pinned while rows scroll underneath it. Mirrored from the opposite edge in rtl. `scrollWidth` is virtual-only: native scrolling derives the content width from the rendered rows, so non-virtual lists need no hint and are left untouched. Row width is derived from our own prop rather than the render function's third argument, which is `{ width: undefined }` when unset and would clobber a user-supplied `styles.item.width`. --- README.md | 1 + README.zh-CN.md | 1 + docs/demos/horizontal.md | 8 ++ docs/examples/horizontal.tsx | 165 +++++++++++++++++++++++ src/List.tsx | 2 + src/VirtualList/index.tsx | 14 +- src/VirtualList/useStickyGroupHeader.tsx | 16 ++- tests/hooks.test.tsx | 87 ++++++++++++ tests/listy.test.tsx | 56 ++++++++ 9 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 docs/demos/horizontal.md create mode 100644 docs/examples/horizontal.tsx diff --git a/README.md b/README.md index d6292f4..a47dc16 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ export default () => ( | `group` | Group configuration. | `{ key: (item: T) => K; title: (groupKey: K, items: T[]) => React.ReactNode }` | - | | `sticky` | Enable sticky group headers. | `boolean` | `false` | | `virtual` | Enable virtual scrolling. | `boolean` | `true` | +| `scrollWidth` | Real content width, for virtual scrolling only. When set, a horizontal scrollbar shows up and virtual scrolling is always enabled. | `number` | - | | `direction` | Layout direction; set `rtl` for right-to-left. | `'ltr' \| 'rtl'` | `'ltr'` | | `onScroll` | Triggered when the inner scroll container scrolls. | `React.UIEventHandler` | - | | `prefixCls` | Component class name prefix. | `string` | `rc-listy` | diff --git a/README.zh-CN.md b/README.zh-CN.md index bd4ab4e..9bf2237 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,6 +60,7 @@ export default () => ( | `group` | 分组配置。 | `{ key: (item: T) => K; title: (groupKey: K, items: T[]) => React.ReactNode }` | - | | `sticky` | 启用粘性组头。 | `boolean` | `false` | | `virtual` | 启用虚拟滚动。 | `boolean` | `true` | +| `scrollWidth` | 内容实际宽度,仅用于虚拟滚动。设置后会展示横向滚动条,并强制启用虚拟滚动。 | `number` | - | | `direction` | 布局方向,设为 `rtl` 时启用从右到左布局。 | `'ltr' \| 'rtl'` | `'ltr'` | | `onScroll` | 内部滚动容器滚动时触发。 | `React.UIEventHandler` | - | | `prefixCls` | 组件样式前缀。 | `string` | `rc-listy` | diff --git a/docs/demos/horizontal.md b/docs/demos/horizontal.md new file mode 100644 index 0000000..e7c9b79 --- /dev/null +++ b/docs/demos/horizontal.md @@ -0,0 +1,8 @@ +--- +title: Horizontal Scroll +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/horizontal.tsx b/docs/examples/horizontal.tsx new file mode 100644 index 0000000..0b1712d --- /dev/null +++ b/docs/examples/horizontal.tsx @@ -0,0 +1,165 @@ +import React, { useRef, useState } from 'react'; +import Listy, { type ListyRef } from '@rc-component/listy'; +import '../../assets/index.less'; + +const COLUMNS = [ + { key: 'id', title: 'ID', width: 80 }, + { key: 'name', title: 'Name', width: 200 }, + { key: 'email', title: 'Email', width: 260 }, + { key: 'address', title: 'Address', width: 320 }, + { key: 'note', title: 'Note', width: 240 }, +]; + +const SCROLL_WIDTH = COLUMNS.reduce((acc, col) => acc + col.width, 0); +const VIEWPORT_WIDTH = 480; +const GROUP_SIZE = 10; +const TOTAL = 200; + +const items = Array.from({ length: TOTAL }, (_, index) => ({ + id: index + 1, + index, + groupIndex: Math.floor(index / GROUP_SIZE), +})); + +const cellStyle: React.CSSProperties = { + flex: 'none', + padding: '0 12px', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + borderInlineEnd: '1px solid #f0f0f0', + boxSizing: 'border-box', +}; + +export default () => { + const listRef = useRef(null); + const [virtual, setVirtual] = useState(true); + const [sticky, setSticky] = useState(true); + const [grouped, setGrouped] = useState(true); + const [direction, setDirection] = useState<'ltr' | 'rtl'>('ltr'); + const [scrollWidth, setScrollWidth] = useState(SCROLL_WIDTH); + + const renderRow = (cells: React.ReactNode[], background: string) => ( +
+ {COLUMNS.map((col, i) => ( +
+ {cells[i]} +
+ ))} +
+ ); + + return ( +
+
+ + + + + +
+ +
+ + + + +
+ +
+ item.groupIndex, + title: (groupKey) => + renderRow( + [ + `G${groupKey}`, + `Group ${groupKey}`, + '', + 'header should follow horizontal scroll', + '', + ], + '#f5f5f5', + ), + } + : undefined + } + itemRender={(item) => + renderRow( + [ + item.id, + `User ${item.index}`, + `user${item.index}@example.com`, + `No.${item.index} Some Long Street, Some City`, + `note-${item.index}`, + ], + '#fff', + ) + } + /> +
+
+ ); +}; diff --git a/src/List.tsx b/src/List.tsx index d9ba9da..b6bedcb 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -52,6 +52,7 @@ export interface ListyProps { height?: number; group?: Group; virtual?: boolean; + scrollWidth?: number; direction?: 'ltr' | 'rtl'; prefixCls?: string; rowKey: RowKey; @@ -67,6 +68,7 @@ export interface ListComponentProps { itemHeight?: number; height?: number; group?: Group; + scrollWidth?: number; direction?: 'ltr' | 'rtl'; prefixCls: string; rowKey: RowKey; diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index 03b8c5b..590e6ba 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -36,6 +36,7 @@ function VirtualList( prefixCls, rowKey, sticky, + scrollWidth, direction, classNames, styles, @@ -137,6 +138,12 @@ function VirtualList( [scrollTo], ); + // ============================== Width =============================== + const rowStyle = React.useMemo( + () => (scrollWidth ? { width: scrollWidth } : undefined), + [scrollWidth], + ); + // ============================== Sticky ============================== const extraRender = useStickyGroupHeader({ enabled: !!(sticky && group), @@ -145,6 +152,7 @@ function VirtualList( groupKeyToItems, prefixCls, listRef, + scrollWidth, headerClassName: classNames?.groupHeader, headerStyle: styles?.groupHeader, }); @@ -161,7 +169,7 @@ function VirtualList( groupItems={groupItems} prefixCls={prefixCls} className={classNames?.groupHeader} - style={styles?.groupHeader} + style={{ ...styles?.groupHeader, ...rowStyle }} /> ); }, @@ -170,6 +178,7 @@ function VirtualList( group, groupKeyToItems, prefixCls, + rowStyle, styles?.groupHeader, ], ); @@ -185,6 +194,7 @@ function VirtualList( itemHeight={itemHeight} itemKey="taggedKey" onScroll={onScroll} + scrollWidth={scrollWidth} prefixCls={prefixCls} virtual extraRender={extraRender} @@ -197,7 +207,7 @@ function VirtualList( ) : (
{itemRender(row.item, row.index)}
diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index d742a23..f39474f 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -47,6 +47,7 @@ export interface StickyHeaderParams { groupKeyToItems: Map; prefixCls: string; listRef: React.RefObject; + scrollWidth?: number; headerClassName?: string; headerStyle?: React.CSSProperties; } @@ -63,6 +64,7 @@ export default function useStickyGroupHeader< groupKeyToItems, prefixCls, listRef, + scrollWidth, headerClassName, headerStyle, } = params; @@ -70,7 +72,7 @@ export default function useStickyGroupHeader< // ============================ Extra Render ========================== const extraRender = React.useCallback( (info: ExtraRenderInfo) => { - const { getSize, scrollTop, virtual } = info; + const { getSize, scrollTop, virtual, offsetX, rtl } = info; if (!enabled || !group || !groupKeys.length || !virtual) { return null; @@ -106,6 +108,15 @@ export default function useStickyGroupHeader< ) : 0; + const horizontalStyle: React.CSSProperties | undefined = scrollWidth + ? { + width: scrollWidth, + left: rtl ? 'auto' : 0, + right: rtl ? 0 : 'auto', + transform: `translateX(${rtl ? offsetX : -offsetX}px)`, + } + : undefined; + // Render a cloned header pinned over the virtual list. return ( container}> @@ -119,7 +130,7 @@ export default function useStickyGroupHeader< className={headerClassName} // `top` is the computed sticky-push offset and must win over any // user-supplied top in headerStyle, or the sticky behavior breaks. - style={{ ...headerStyle, top }} + style={{ ...headerStyle, ...horizontalStyle, top }} /> @@ -132,6 +143,7 @@ export default function useStickyGroupHeader< groupKeyToItems, prefixCls, listRef, + scrollWidth, headerClassName, headerStyle, ], diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 52a184d..e27bc22 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -500,4 +500,91 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).not.toBeNull(); expect(stickyHeader).toHaveTextContent('Group 2'); }); + + it('offsets the fixed header horizontally to track scrollWidth scrolling', () => { + const info = createRenderInfo({ offsetX: 120 }); + const container = document.createElement('div'); + document.body.appendChild(container); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title: () => title, + }, + groupKeys, + groupKeyToItems: baseItemsMap, + prefixCls: PREFIX_CLS, + listRef: createListRef(container), + scrollWidth: 800, + }; + + render(); + + const stickyHeader = container.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ) as HTMLElement; + + expect(stickyHeader).toHaveStyle({ + width: '800px', + left: '0px', + right: 'auto', + transform: 'translateX(-120px)', + }); + }); + + it('mirrors the horizontal offset from the other edge in rtl', () => { + const info = createRenderInfo({ offsetX: 120, rtl: true }); + const container = document.createElement('div'); + document.body.appendChild(container); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title: () => title, + }, + groupKeys, + groupKeyToItems: baseItemsMap, + prefixCls: PREFIX_CLS, + listRef: createListRef(container), + scrollWidth: 800, + }; + + render(); + + const stickyHeader = container.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ) as HTMLElement; + expect(stickyHeader).toHaveStyle({ + left: 'auto', + right: '0px', + transform: 'translateX(120px)', + }); + }); + + it('leaves header positioning untouched without scrollWidth', () => { + const info = createRenderInfo({ offsetX: 120 }); + const container = document.createElement('div'); + document.body.appendChild(container); + const params: StickyHeaderParams = { + enabled: true, + group: { + key: (item) => item.group, + title: () => title, + }, + groupKeys, + groupKeyToItems: baseItemsMap, + prefixCls: PREFIX_CLS, + listRef: createListRef(container), + headerStyle: { width: 42 }, + }; + + render(); + + const stickyHeader = container.querySelector( + `.${PREFIX_CLS}-group-header-fixed`, + ) as HTMLElement; + // No horizontal scroll: the stylesheet stays in charge and a user width survives. + expect(stickyHeader.style.transform).toBe(''); + expect(stickyHeader).toHaveStyle({ width: '42px' }); + }); }); diff --git a/tests/listy.test.tsx b/tests/listy.test.tsx index 278a20e..2c8c1a1 100644 --- a/tests/listy.test.tsx +++ b/tests/listy.test.tsx @@ -80,6 +80,62 @@ describe('Listy', () => { }); }); +describe('scrollWidth', () => { + const renderWide = ( + scrollWidth?: number, + styles?: React.ComponentProps['styles'], + ) => + render( + item.group, + title: (key) => {key}, + }} + itemRender={(item) =>
{item.id}
} + />, + ); + + it('shows a horizontal scrollbar only when scrollWidth is set', () => { + const { container, unmount } = renderWide(800); + expect( + container.querySelector('.rc-listy-scrollbar-horizontal'), + ).toBeInTheDocument(); + unmount(); + + expect( + renderWide().container.querySelector('.rc-listy-scrollbar-horizontal'), + ).not.toBeInTheDocument(); + }); + + it('widens both row kinds to the content width', () => { + const { container } = renderWide(800); + + expect(container.querySelector('.rc-listy-item')).toHaveStyle({ + width: '800px', + }); + expect(container.querySelector('.rc-listy-group-header')).toHaveStyle({ + width: '800px', + }); + }); + + it('keeps user widths when scrollWidth is unset', () => { + const { container } = renderWide(undefined, { item: { width: 42 } }); + + expect(container.querySelector('.rc-listy-item')).toHaveStyle({ + width: '42px', + }); + }); +}); + describe('package entry point', () => { it('re-exports the Listy implementation', () => { expect(ListyEntry).toBe(Listy); From 6c74d6586056411fe95b131e323a0d4501c5326d Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Sun, 30 Aug 2026 13:39:18 +0800 Subject: [PATCH 2/3] refactor: drop the redundant left/right reset on the sticky header Setting `left`/`right` alongside `width` on the fixed header made no difference to its position in either direction: an absolutely positioned box given left, right and width is over-constrained, so the trailing offset is already dropped per direction. Measured in Chrome, ltr and rtl land identically with and without them. Drops the assertions that mirrored those two lines as well. --- src/VirtualList/useStickyGroupHeader.tsx | 2 -- tests/hooks.test.tsx | 8 +------- tests/listy.test.tsx | 8 -------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index f39474f..d52a001 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -111,8 +111,6 @@ export default function useStickyGroupHeader< const horizontalStyle: React.CSSProperties | undefined = scrollWidth ? { width: scrollWidth, - left: rtl ? 'auto' : 0, - right: rtl ? 0 : 'auto', transform: `translateX(${rtl ? offsetX : -offsetX}px)`, } : undefined; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index e27bc22..a187eb8 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -526,8 +526,6 @@ describe('useStickyGroupHeader', () => { expect(stickyHeader).toHaveStyle({ width: '800px', - left: '0px', - right: 'auto', transform: 'translateX(-120px)', }); }); @@ -554,11 +552,7 @@ describe('useStickyGroupHeader', () => { const stickyHeader = container.querySelector( `.${PREFIX_CLS}-group-header-fixed`, ) as HTMLElement; - expect(stickyHeader).toHaveStyle({ - left: 'auto', - right: '0px', - transform: 'translateX(120px)', - }); + expect(stickyHeader).toHaveStyle({ transform: 'translateX(120px)' }); }); it('leaves header positioning untouched without scrollWidth', () => { diff --git a/tests/listy.test.tsx b/tests/listy.test.tsx index 2c8c1a1..1c93cdc 100644 --- a/tests/listy.test.tsx +++ b/tests/listy.test.tsx @@ -126,14 +126,6 @@ describe('scrollWidth', () => { width: '800px', }); }); - - it('keeps user widths when scrollWidth is unset', () => { - const { container } = renderWide(undefined, { item: { width: 42 } }); - - expect(container.querySelector('.rc-listy-item')).toHaveStyle({ - width: '42px', - }); - }); }); describe('package entry point', () => { From 64342da52e811612e85cf87e4ccf5c9551f5a2fd Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Sun, 30 Aug 2026 21:38:55 +0800 Subject: [PATCH 3/3] docs: scrollWidth does not force virtual scrolling scrollWidth is only read by VirtualList; List.tsx routes to RawList whenever virtual is false, so the prop cannot switch virtual back on. --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a47dc16..870900c 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ export default () => ( | `group` | Group configuration. | `{ key: (item: T) => K; title: (groupKey: K, items: T[]) => React.ReactNode }` | - | | `sticky` | Enable sticky group headers. | `boolean` | `false` | | `virtual` | Enable virtual scrolling. | `boolean` | `true` | -| `scrollWidth` | Real content width, for virtual scrolling only. When set, a horizontal scrollbar shows up and virtual scrolling is always enabled. | `number` | - | +| `scrollWidth` | Real content width. Only takes effect when `virtual` is enabled; a horizontal scrollbar shows up once set. | `number` | - | | `direction` | Layout direction; set `rtl` for right-to-left. | `'ltr' \| 'rtl'` | `'ltr'` | | `onScroll` | Triggered when the inner scroll container scrolls. | `React.UIEventHandler` | - | | `prefixCls` | Component class name prefix. | `string` | `rc-listy` | diff --git a/README.zh-CN.md b/README.zh-CN.md index 9bf2237..99316c5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -60,7 +60,7 @@ export default () => ( | `group` | 分组配置。 | `{ key: (item: T) => K; title: (groupKey: K, items: T[]) => React.ReactNode }` | - | | `sticky` | 启用粘性组头。 | `boolean` | `false` | | `virtual` | 启用虚拟滚动。 | `boolean` | `true` | -| `scrollWidth` | 内容实际宽度,仅用于虚拟滚动。设置后会展示横向滚动条,并强制启用虚拟滚动。 | `number` | - | +| `scrollWidth` | 内容实际宽度,仅在 `virtual` 开启时生效。设置后会展示横向滚动条。 | `number` | - | | `direction` | 布局方向,设为 `rtl` 时启用从右到左布局。 | `'ltr' \| 'rtl'` | `'ltr'` | | `onScroll` | 内部滚动容器滚动时触发。 | `React.UIEventHandler` | - | | `prefixCls` | 组件样式前缀。 | `string` | `rc-listy` |