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..cd94536 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,25 @@ export default function useStickyGroupHeader<
groupKey={currGroupKey}
groupItems={groupItems}
prefixCls={prefixCls}
- style={{ top }}
+ 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 }}
/>
);
},
- [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..7351302
--- /dev/null
+++ b/tests/semantic.test.tsx
@@ -0,0 +1,286 @@
+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)',
+ );
+ });
+
+ 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 ===========================
+ 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();
+ });
+
+ 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();
+ });
+ });
+});