From 375fc1fa7e6665caa42221554cc83be216ef80fe Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Jun 2026 15:07:18 +0800 Subject: [PATCH 1/6] feat: support classNames/styles semantic DOM API Add root/item/groupHeader semantic slots via `classNames` and `styles` props, wired through both RawList and VirtualList (including the sticky group header) so the two render modes stay at parity. --- src/GroupHeader.tsx | 14 +- src/List.tsx | 10 + src/RawList/index.tsx | 21 ++- src/VirtualList/index.tsx | 16 +- src/VirtualList/useStickyGroupHeader.tsx | 18 +- src/index.ts | 8 +- tests/semantic.test.tsx | 227 +++++++++++++++++++++++ 7 files changed, 299 insertions(+), 15 deletions(-) create mode 100644 tests/semantic.test.tsx diff --git a/src/GroupHeader.tsx b/src/GroupHeader.tsx index 6e5f806..6806f54 100644 --- a/src/GroupHeader.tsx +++ b/src/GroupHeader.tsx @@ -10,6 +10,7 @@ export interface GroupHeaderProps { prefixCls: string; fixed?: boolean; sticky?: boolean; + className?: string; style?: React.CSSProperties; } @@ -25,14 +26,19 @@ function GroupHeader( prefixCls, fixed, sticky, + className: customClassName, style, } = props; // ============================= Classes ============================= - const className = clsx(`${prefixCls}-group-header`, { - [`${prefixCls}-group-header-sticky`]: sticky, - [`${prefixCls}-group-header-fixed`]: fixed, - }); + const className = clsx( + `${prefixCls}-group-header`, + { + [`${prefixCls}-group-header-sticky`]: sticky, + [`${prefixCls}-group-header-fixed`]: fixed, + }, + customClassName, + ); // ============================== Render ============================== return ( diff --git a/src/List.tsx b/src/List.tsx index c6393c9..deb25d2 100644 --- a/src/List.tsx +++ b/src/List.tsx @@ -9,6 +9,12 @@ export type RowKey = keyof T | ((item: T) => React.Key); export type ScrollAlign = 'top' | 'bottom' | 'auto'; +export type ListySemanticName = 'root' | 'item' | 'groupHeader'; + +export type ListyClassNames = Partial>; + +export type ListyStyles = Partial>; + export interface GroupScrollToConfig { groupKey: React.Key; align?: ScrollAlign; @@ -46,6 +52,8 @@ export interface ListyProps { virtual?: boolean; prefixCls?: string; rowKey: RowKey; + classNames?: ListyClassNames; + styles?: ListyStyles; onScroll?: React.UIEventHandler; itemRender: (item: T, index: number) => React.ReactNode; } @@ -58,6 +66,8 @@ export interface ListComponentProps { group?: Group; prefixCls: string; rowKey: RowKey; + classNames?: ListyClassNames; + styles?: ListyStyles; onScroll?: React.UIEventHandler; itemRender: (item: T, index: number) => React.ReactNode; } diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 7cb4670..56932c5 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import clsx from 'clsx'; import { useEvent } from '@rc-component/util'; import GroupHeader from '../GroupHeader'; import useGroupSegments from '../hooks/useGroupSegments'; @@ -23,6 +24,8 @@ function RawList( prefixCls, rowKey, sticky, + classNames, + styles, } = props; // =============================== Refs =============================== @@ -55,14 +58,15 @@ function RawList( return (
{itemRender(item, index)} @@ -70,11 +74,13 @@ function RawList( ); }, [ + classNames?.item, getItemKey, getScrollTargetProps, itemRender, prefixCls, sticky, + styles?.item, ], ); @@ -95,6 +101,8 @@ function RawList( groupItems={currentGroupItems} prefixCls={prefixCls} sticky={sticky} + className={classNames?.groupHeader} + style={styles?.groupHeader} /> {groupItems.map(({ item, index }) => { return renderItem(item, index, groupKey); @@ -110,11 +118,12 @@ function RawList( return (
diff --git a/src/VirtualList/index.tsx b/src/VirtualList/index.tsx index a435e9d..dfa9eaa 100644 --- a/src/VirtualList/index.tsx +++ b/src/VirtualList/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import clsx from 'clsx'; import RcVirtualList, { type ListRef as RcVirtualListRef, type ScrollConfig, @@ -33,6 +34,8 @@ function VirtualList( prefixCls, rowKey, sticky, + classNames, + styles, } = props; // =============================== Refs =============================== @@ -139,6 +142,8 @@ function VirtualList( groupKeyToItems, prefixCls, listRef, + headerClassName: classNames?.groupHeader, + headerStyle: styles?.groupHeader, }); // ============================ Render Row ============================ @@ -152,10 +157,12 @@ function VirtualList( groupKey={groupKey} groupItems={groupItems} prefixCls={prefixCls} + className={classNames?.groupHeader} + style={styles?.groupHeader} /> ); }, - [group, groupKeyToItems, prefixCls], + [classNames?.groupHeader, group, groupKeyToItems, prefixCls, styles?.groupHeader], ); // ============================== Render ============================== @@ -171,12 +178,17 @@ function VirtualList( prefixCls={prefixCls} virtual extraRender={extraRender} + className={classNames?.root} + style={styles?.root} > {(row: Row) => row.type === 'header' ? renderHeaderRow(row.groupKey) : ( -
+
{itemRender(row.item, row.index)}
) diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index 2762277..da3c596 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -46,6 +46,8 @@ export interface StickyHeaderParams { groupKeyToItems: Map; prefixCls: string; listRef: React.RefObject; + headerClassName?: string; + headerStyle?: React.CSSProperties; } export default function useStickyGroupHeader< @@ -60,6 +62,8 @@ export default function useStickyGroupHeader< groupKeyToItems, prefixCls, listRef, + headerClassName, + headerStyle, } = params; // ============================ Extra Render ========================== @@ -103,13 +107,23 @@ export default function useStickyGroupHeader< groupKey={currGroupKey} groupItems={groupItems} prefixCls={prefixCls} - style={{ top }} + className={headerClassName} + style={{ top, ...headerStyle }} />
); }, - [enabled, group, groupKeys, groupKeyToItems, prefixCls, listRef], + [ + enabled, + group, + groupKeys, + groupKeyToItems, + prefixCls, + listRef, + headerClassName, + headerStyle, + ], ); // ============================== Return ============================== diff --git a/src/index.ts b/src/index.ts index 69fef22..5ae2135 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ import Listy from './List'; -export type { ListyRef, ListyProps } from './List'; +export type { + ListyRef, + ListyProps, + ListySemanticName, + ListyClassNames, + ListyStyles, +} from './List'; export default Listy; diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx new file mode 100644 index 0000000..f54316d --- /dev/null +++ b/tests/semantic.test.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Listy from '@rc-component/listy'; +import GroupHeader from '../src/GroupHeader'; +import useStickyGroupHeader from '../src/VirtualList/useStickyGroupHeader'; + +jest.mock('@rc-component/virtual-list', () => { + const ReactMock = require('react'); + const extraInfo = { + start: 0, + end: 0, + virtual: true, + offsetX: 0, + scrollTop: 0, + offsetY: 0, + rtl: false, + getSize: () => ({ top: 0, bottom: 0 }), + }; + + const MockVirtualList = ReactMock.forwardRef((props: any, ref: any) => { + ReactMock.useImperativeHandle(ref, () => ({ scrollTo: () => {} })); + return ( +
+ {props.extraRender ? props.extraRender(extraInfo) : null} + {props.data.map((row: any, index: number) => ( +
{props.children(row, index)}
+ ))} +
+ ); + }); + + return { __esModule: true, default: MockVirtualList }; +}); + +const GROUPED_ITEMS = [ + { id: 1, group: 'A' }, + { id: 2, group: 'A' }, + { id: 3, group: 'B' }, +]; + +const group = { + key: (item: { group: string }) => item.group, + title: (key: React.Key) => Group {String(key)}, +}; + +const CLASSNAMES = { root: 'my-root', item: 'my-item', groupHeader: 'my-header' }; +const STYLES = { + root: { background: 'rgb(1, 2, 3)' }, + item: { color: 'rgb(4, 5, 6)' }, + groupHeader: { color: 'rgb(7, 8, 9)' }, +}; + +describe('semantic DOM (classNames / styles)', () => { + // ============================ Shared part =========================== + describe('GroupHeader merges custom class with modifiers', () => { + it('keeps base + modifier classes and appends custom className/style', () => { + const { container } = render( + , + ); + + const node = container.querySelector('.rc-listy-group-header') as HTMLElement; + expect(node).toHaveClass('rc-listy-group-header-fixed'); + expect(node).toHaveClass('my-header'); + expect(node).toHaveStyle({ color: 'rgb(7, 8, 9)' }); + }); + }); + + // ============================ Native mode =========================== + describe('native scroll (virtual=false)', () => { + const renderRaw = (extra?: object) => + render( + {item.id}} + {...extra} + />, + ); + + it('applies root class/style to the scroll container', () => { + const { container } = renderRaw(); + const root = container.querySelector('.rc-listy') as HTMLElement; + expect(root).toHaveClass('my-root'); + expect(root).toHaveStyle({ background: 'rgb(1, 2, 3)' }); + }); + + it('applies item class/style to every item', () => { + const { container } = renderRaw(); + const items = container.querySelectorAll('.rc-listy-item'); + expect(items).toHaveLength(3); + items.forEach((item) => { + expect(item).toHaveClass('my-item'); + expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); + }); + }); + + it('applies groupHeader class/style to headers', () => { + const { container } = renderRaw(); + const headers = container.querySelectorAll('.rc-listy-group-header'); + expect(headers).toHaveLength(2); + headers.forEach((header) => { + expect(header).toHaveClass('my-header'); + expect(header).toHaveStyle({ color: 'rgb(7, 8, 9)' }); + }); + }); + + it('merges item style with the sticky scroll-margin (no overwrite)', () => { + const { container } = renderRaw({ sticky: true }); + const item = container.querySelector('.rc-listy-item') as HTMLElement; + // custom style survives... + expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); + // ...alongside the internal sticky scroll margin. + expect(item.style.scrollMarginTop).toBe( + 'var(--rc-listy-item-scroll-margin-top, 0px)', + ); + }); + }); + + // =========================== Virtual mode =========================== + describe('virtual scroll', () => { + const renderVirtual = () => + render( + {item.id}} + />, + ); + + it('passes root class/style down to the virtual list', () => { + const { container } = renderVirtual(); + const root = container.querySelector( + '[data-testid="mock-virtual-list"]', + ) as HTMLElement; + expect(root).toHaveClass('my-root'); + expect(root).toHaveStyle({ background: 'rgb(1, 2, 3)' }); + }); + + it('applies item class/style to the item wrapper', () => { + const { container } = renderVirtual(); + const items = container.querySelectorAll('.rc-listy-item'); + expect(items).toHaveLength(3); + items.forEach((item) => { + expect(item).toHaveClass('my-item'); + expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); + }); + }); + + it('applies groupHeader class/style to in-flow header rows', () => { + const { container } = renderVirtual(); + const headers = container.querySelectorAll('.rc-listy-group-header'); + expect(headers).toHaveLength(2); + headers.forEach((header) => { + expect(header).toHaveClass('my-header'); + expect(header).toHaveStyle({ color: 'rgb(7, 8, 9)' }); + }); + }); + }); + + // ===================== Virtual sticky clone ========================= + // The pinned clone is a separate node from the in-flow header; the same + // groupHeader class/style must land on it too (parity with native's single + // sticky node). Driven via the extraRender directly to avoid mock timing. + describe('virtual sticky clone', () => { + it('applies groupHeader class/style to the pinned clone', () => { + const portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + const listRef = { + current: { nativeElement: portalContainer }, + } as any; + + let extraRender: any; + function Harness() { + extraRender = useStickyGroupHeader({ + enabled: true, + group: group as any, + groupKeys: ['A'], + groupKeyToItems: new Map([['A', []]]), + prefixCls: 'rc-listy', + listRef, + headerClassName: 'my-header', + headerStyle: { color: 'rgb(7, 8, 9)' }, + }); + return null; + } + render(); + render( + extraRender({ + getSize: () => ({ top: 0, bottom: 24 }), + scrollTop: 0, + virtual: true, + }), + ); + + const clone = portalContainer.querySelector( + '.rc-listy-group-header-fixed', + ) as HTMLElement; + expect(clone).not.toBeNull(); + expect(clone).toHaveClass('my-header'); + expect(clone).toHaveStyle({ color: 'rgb(7, 8, 9)' }); + + portalContainer.remove(); + }); + }); +}); From 0b2df82b849235cc9986aff6435f5f6b37736fb9 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Jun 2026 16:46:12 +0800 Subject: [PATCH 2/6] chore: ignore .claude directory in git --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d048bce..162f4c9 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,6 @@ bun.lockb # dumi .dumi/tmp -.dumi/tmp-production \ No newline at end of file +.dumi/tmp-production + +.claude \ No newline at end of file From 9de88bb9d29108739697049d6b7450cbd909ebc0 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Jun 2026 15:14:19 +0800 Subject: [PATCH 3/6] fix: keep computed sticky header top from being overridden by styles.groupHeader --- src/VirtualList/useStickyGroupHeader.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/VirtualList/useStickyGroupHeader.tsx b/src/VirtualList/useStickyGroupHeader.tsx index da3c596..cd94536 100644 --- a/src/VirtualList/useStickyGroupHeader.tsx +++ b/src/VirtualList/useStickyGroupHeader.tsx @@ -108,7 +108,9 @@ export default function useStickyGroupHeader< groupItems={groupItems} prefixCls={prefixCls} className={headerClassName} - style={{ top, ...headerStyle }} + // `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 }} />
From 531ceb5d0b95d3401f428b136a989022f1a8e7ad Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Jun 2026 15:48:01 +0800 Subject: [PATCH 4/6] docs: add semantic DOM demo Demonstrate the classNames/styles slots (root/item/groupHeader) with a virtual on/off toggle showing the same slots apply in both render modes. --- docs/demos/semantic.md | 8 +++ docs/examples/semantic.tsx | 105 +++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 docs/demos/semantic.md create mode 100644 docs/examples/semantic.tsx diff --git a/docs/demos/semantic.md b/docs/demos/semantic.md new file mode 100644 index 0000000..abf1dfd --- /dev/null +++ b/docs/demos/semantic.md @@ -0,0 +1,8 @@ +--- +title: Semantic DOM +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/semantic.tsx b/docs/examples/semantic.tsx new file mode 100644 index 0000000..50d7e38 --- /dev/null +++ b/docs/examples/semantic.tsx @@ -0,0 +1,105 @@ +import React, { useRef, useState } from 'react'; +import Listy, { + type ListyRef, + type ListyClassNames, + type ListyStyles, +} from '@rc-component/listy'; +import '../../assets/index.less'; + +const GROUPS = [ + { id: 'frontend', label: 'Frontend' }, + { id: 'backend', label: 'Backend' }, + { id: 'infra', label: 'Infrastructure' }, + { id: 'mobile', label: 'Mobile' }, +]; + +interface Task { + id: string; + name: string; + groupId: string; +} + +const items: Task[] = GROUPS.flatMap((group) => + Array.from({ length: 8 }, (_, index) => ({ + id: `${group.id}-${index}`, + name: `${group.label} task #${index + 1}`, + groupId: group.id, + })), +); + +// The `classNames` and `styles` props share the same semantic slots: +// `root`, `item`, and `groupHeader` (including the sticky clone). +const classNames: ListyClassNames = { + root: 'listy-semantic-root', + groupHeader: 'listy-semantic-header', + item: 'listy-semantic-item', +}; + +// Inline `styles` stack on top of the classNames above. +const styles: ListyStyles = { + root: { borderRadius: 10 }, + groupHeader: { letterSpacing: 0.5 }, + item: { transition: 'background 0.15s ease' }, +}; + +const CSS = ` +.listy-semantic-root { + border: 1px solid #e5e7eb; + overflow: hidden; + background: #fff; + font-family: system-ui, sans-serif; +} +.listy-semantic-header { + padding: 10px 16px; + font-weight: 600; + color: #fff; + background: linear-gradient(90deg, #6366f1, #8b5cf6); +} +.listy-semantic-item { + padding: 0 16px; + line-height: 40px; + border-bottom: 1px solid #f3f4f6; +} +.listy-semantic-item:hover { + /* hover state — only a className slot can express this, not inline styles */ + background: #f5f3ff; +} +`; + +export default () => { + const listRef = useRef(null); + const [virtual, setVirtual] = useState(true); + + return ( + <> + +
+ + item.name} + group={{ + key: (item) => item.groupId, + title: (groupKey) => + GROUPS.find((group) => group.id === groupKey)?.label ?? groupKey, + }} + /> +
+ + ); +}; From 7fce56b9b9eb426d83fbd689fcb1c6302a3e3c6d Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Jun 2026 15:52:49 +0800 Subject: [PATCH 5/6] fix: keep item scrollMarginTop from being overridden by styles.item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In sticky grouped mode RawList sets scroll-margin-top on items so scrollTo({ key }) lands them clear of the position:sticky group header (the header height is measured live into the --*-item-scroll-margin-top var). It's internal scrollTo machinery, not a style knob, so it must win over styles.item — otherwise a user-supplied scrollMarginTop breaks the scroll landing and the item ends up hidden behind the header. --- src/RawList/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RawList/index.tsx b/src/RawList/index.tsx index 56932c5..e70b36d 100644 --- a/src/RawList/index.tsx +++ b/src/RawList/index.tsx @@ -60,12 +60,12 @@ function RawList( key={key} className={clsx(`${prefixCls}-item`, classNames?.item)} style={{ + ...styles?.item, ...(sticky && groupKey !== undefined ? { scrollMarginTop: `var(--${prefixCls}-item-scroll-margin-top, 0px)`, } : undefined), - ...styles?.item, }} {...scrollTargetProps} > From b7e67b597dda6838d75d8d3d70e13dc5c5df88d0 Mon Sep 17 00:00:00 2001 From: aojunhao123 <1844749591@qq.com> Date: Tue, 30 Jun 2026 17:09:10 +0800 Subject: [PATCH 6/6] test: guard internal positioning against styles overrides styles.item.scrollMarginTop must not override the sticky scrollTo offset, and styles.groupHeader.top must not override the computed sticky-clone top. The existing slot tests only asserted coexistence, so they stayed green even with the override bug present; these two assert the internal value wins. --- tests/semantic.test.tsx | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx index f54316d..7351302 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -131,6 +131,20 @@ describe('semantic DOM (classNames / styles)', () => { 'var(--rc-listy-item-scroll-margin-top, 0px)', ); }); + + it('does not let styles.item.scrollMarginTop override the internal offset', () => { + const { container } = renderRaw({ + sticky: true, + styles: { item: { color: 'rgb(4, 5, 6)', scrollMarginTop: 999 } }, + }); + const item = container.querySelector('.rc-listy-item') as HTMLElement; + // the internal scrollTo offset must win, not the user's 999px... + expect(item.style.scrollMarginTop).toBe( + 'var(--rc-listy-item-scroll-margin-top, 0px)', + ); + // ...while the user's other item styles still apply. + expect(item).toHaveStyle({ color: 'rgb(4, 5, 6)' }); + }); }); // =========================== Virtual mode =========================== @@ -223,5 +237,50 @@ describe('semantic DOM (classNames / styles)', () => { portalContainer.remove(); }); + + it('does not let headerStyle.top override the computed sticky top', () => { + const portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + const listRef = { + current: { nativeElement: portalContainer }, + } as any; + + let extraRender: any; + function Harness() { + extraRender = useStickyGroupHeader({ + enabled: true, + group: group as any, + groupKeys: ['A', 'B'], + groupKeyToItems: new Map([ + ['A', []], + ['B', []], + ]), + prefixCls: 'rc-listy', + listRef, + headerClassName: 'my-header', + headerStyle: { top: 999 }, + }); + return null; + } + render(); + render( + extraRender({ + // A is the active header; B is approaching, so the computed top is a + // negative push offset: min(0, 100 - 24 - 90) = -14. + getSize: (key: React.Key) => + key === 'B' ? { top: 100, bottom: 124 } : { top: 0, bottom: 24 }, + scrollTop: 90, + virtual: true, + }), + ); + + const clone = portalContainer.querySelector( + '.rc-listy-group-header-fixed', + ) as HTMLElement; + // the computed push offset wins; the user's top:999 must not leak through. + expect(clone.style.top).toBe('-14px'); + + portalContainer.remove(); + }); }); });