Skip to content

Commit 3df69f1

Browse files
committed
Support custom tagName for outer element and (optional) children
1 parent 7f07ac3 commit 3df69f1

21 files changed

+1541
-64
lines changed

lib/components/grid/Grid.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,48 @@ describe("Grid", () => {
159159
// TODO
160160
});
161161

162+
test("custom tagName and attributes", () => {
163+
function CustomCellComponent({ style }: CellComponentProps<object>) {
164+
return <span style={style}>Cell</span>;
165+
}
166+
167+
const { container } = render(
168+
<Grid
169+
cellComponent={CustomCellComponent}
170+
cellProps={EMPTY_OBJECT}
171+
columnCount={100}
172+
columnWidth={25}
173+
overscanCount={0}
174+
rowCount={100}
175+
rowHeight={20}
176+
tagName="main"
177+
/>
178+
);
179+
180+
expect(container.firstElementChild?.tagName).toBe("MAIN");
181+
expect(container.querySelectorAll("SPAN")).toHaveLength(8);
182+
});
183+
184+
test("children", () => {
185+
const { container } = render(
186+
<Grid
187+
cellComponent={CellComponent}
188+
cellProps={EMPTY_OBJECT}
189+
columnCount={100}
190+
columnWidth={25}
191+
overscanCount={0}
192+
rowCount={100}
193+
rowHeight={20}
194+
>
195+
<div id="custom">Overlay or tooltip</div>
196+
</Grid>
197+
);
198+
199+
expect(container.querySelector("#custom")).toHaveTextContent(
200+
"Overlay or tooltip"
201+
);
202+
});
203+
162204
describe("imperative API", () => {
163205
test.skip("should return the root element", () => {
164206
// TODO

lib/components/grid/Grid.tsx

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
createElement,
23
memo,
34
useEffect,
45
useImperativeHandle,
@@ -9,13 +10,17 @@ import {
910
import { useIsRtl } from "../../core/useIsRtl";
1011
import { useVirtualizer } from "../../core/useVirtualizer";
1112
import { useMemoizedObject } from "../../hooks/useMemoizedObject";
12-
import type { Align } from "../../types";
13+
import type { Align, TagNames } from "../../types";
1314
import { arePropsEqual } from "../../utils/arePropsEqual";
1415
import type { GridProps } from "./types";
1516

16-
export function Grid<CellProps extends object>({
17+
export function Grid<
18+
CellProps extends object,
19+
TagName extends TagNames = "div"
20+
>({
1721
cellComponent: CellComponentProp,
1822
cellProps: cellPropsUnstable,
23+
children,
1924
className,
2025
columnCount,
2126
columnWidth,
@@ -29,8 +34,9 @@ export function Grid<CellProps extends object>({
2934
rowCount,
3035
rowHeight,
3136
style,
37+
tagName = "div" as TagName,
3238
...rest
33-
}: GridProps<CellProps>) {
39+
}: GridProps<CellProps, TagName>) {
3440
const cellProps = useMemoizedObject(cellPropsUnstable);
3541
const CellComponent = useMemo(
3642
() => memo(CellComponentProp, arePropsEqual),
@@ -247,16 +253,28 @@ export function Grid<CellProps extends object>({
247253
rowStopIndex
248254
]);
249255

250-
return (
256+
const sizingElement = (
251257
<div
252-
aria-colcount={columnCount}
253-
aria-rowcount={rowCount}
254-
role="grid"
255-
{...rest}
256-
className={className}
257-
dir={dir}
258-
ref={setElement}
258+
aria-hidden
259259
style={{
260+
height: getEstimatedHeight(),
261+
width: getEstimatedWidth(),
262+
zIndex: -1
263+
}}
264+
></div>
265+
);
266+
267+
return createElement(
268+
tagName,
269+
{
270+
"aria-colcount": columnCount,
271+
"aria-rowcount": rowCount,
272+
role: "grid",
273+
...rest,
274+
className,
275+
dir,
276+
ref: setElement,
277+
style: {
260278
position: "relative",
261279
width: "100%",
262280
height: "100%",
@@ -265,18 +283,10 @@ export function Grid<CellProps extends object>({
265283
flexGrow: 1,
266284
overflow: "auto",
267285
...style
268-
}}
269-
>
270-
{cells}
271-
272-
<div
273-
aria-hidden
274-
style={{
275-
height: getEstimatedHeight(),
276-
width: getEstimatedWidth(),
277-
zIndex: -1
278-
}}
279-
></div>
280-
</div>
286+
}
287+
},
288+
cells,
289+
children,
290+
sizingElement
281291
);
282292
}

lib/components/grid/types.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,17 @@ import type {
55
ReactNode,
66
Ref
77
} from "react";
8+
import type { TagNames } from "../../types";
89

910
type ForbiddenKeys = "columnIndex" | "rowIndex" | "style";
1011
type ExcludeForbiddenKeys<Type> = {
1112
[Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key];
1213
};
1314

14-
export type GridProps<CellProps extends object> = Omit<
15-
HTMLAttributes<HTMLDivElement>,
16-
"onResize"
17-
> & {
18-
/**
19-
* CSS class name.
20-
*/
21-
className?: string;
22-
15+
export type GridProps<
16+
CellProps extends object,
17+
TagName extends TagNames = "div"
18+
> = Omit<HTMLAttributes<HTMLDivElement>, "onResize"> & {
2319
/**
2420
* React component responsible for rendering a cell.
2521
*
@@ -48,6 +44,17 @@ export type GridProps<CellProps extends object> = Omit<
4844
*/
4945
cellProps: ExcludeForbiddenKeys<CellProps>;
5046

47+
/**
48+
* Additional content to be rendered within the grid (above cells).
49+
* This property can be used to render things like overlays or tooltips.
50+
*/
51+
children?: ReactNode;
52+
53+
/**
54+
* CSS class name.
55+
*/
56+
className?: string;
57+
5158
/**
5259
* Number of columns to be rendered in the grid.
5360
*/
@@ -137,6 +144,14 @@ export type GridProps<CellProps extends object> = Omit<
137144
* The grid of cells will fill the height and width defined by this style.
138145
*/
139146
style?: CSSProperties;
147+
148+
/**
149+
* Can be used to override the root HTML element rendered by the List component.
150+
* The default value is "div", meaning that List renders an HTMLDivElement as its root.
151+
*
152+
* ⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed.
153+
*/
154+
tagName?: TagName;
140155
};
141156

142157
export type CellComponent<CellProps extends object> =

lib/components/list/List.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,44 @@ describe("List", () => {
329329
expect(screen.queryByTestId("foo")).toHaveRole("list");
330330
});
331331

332+
test("custom tagName and attributes", () => {
333+
function CustomRowComponent({ index, style }: RowComponentProps<object>) {
334+
return <li style={style}>Row {index + 1}</li>;
335+
}
336+
337+
const { container } = render(
338+
<List
339+
overscanCount={0}
340+
rowCount={4}
341+
rowComponent={CustomRowComponent}
342+
rowHeight={25}
343+
rowProps={EMPTY_OBJECT}
344+
tagName="ul"
345+
/>
346+
);
347+
348+
expect(container.firstElementChild?.tagName).toBe("UL");
349+
expect(container.querySelectorAll("LI")).toHaveLength(4);
350+
});
351+
352+
test("children", () => {
353+
const { container } = render(
354+
<List
355+
overscanCount={0}
356+
rowCount={100}
357+
rowComponent={RowComponent}
358+
rowHeight={25}
359+
rowProps={EMPTY_OBJECT}
360+
>
361+
<div id="custom">Overlay or tooltip</div>
362+
</List>
363+
);
364+
365+
expect(container.querySelector("#custom")).toHaveTextContent(
366+
"Overlay or tooltip"
367+
);
368+
});
369+
332370
describe("imperative API", () => {
333371
test("should return the root element", () => {
334372
const listRef = createRef<ListImperativeAPI>();

lib/components/list/List.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
createElement,
23
memo,
34
useEffect,
45
useImperativeHandle,
@@ -8,11 +9,15 @@ import {
89
} from "react";
910
import { useVirtualizer } from "../../core/useVirtualizer";
1011
import { useMemoizedObject } from "../../hooks/useMemoizedObject";
11-
import type { Align } from "../../types";
12+
import type { Align, TagNames } from "../../types";
1213
import { arePropsEqual } from "../../utils/arePropsEqual";
1314
import type { ListProps } from "./types";
1415

15-
export function List<RowProps extends object>({
16+
export function List<
17+
RowProps extends object,
18+
TagName extends TagNames = "div"
19+
>({
20+
children,
1621
className,
1722
defaultHeight = 0,
1823
listRef,
@@ -23,9 +28,10 @@ export function List<RowProps extends object>({
2328
rowCount,
2429
rowHeight,
2530
rowProps: rowPropsUnstable,
31+
tagName = "div" as TagName,
2632
style,
2733
...rest
28-
}: ListProps<RowProps>) {
34+
}: ListProps<RowProps, TagName>) {
2935
const rowProps = useMemoizedObject(rowPropsUnstable);
3036
const RowComponent = useMemo(
3137
() => memo(RowComponentProp, arePropsEqual),
@@ -123,30 +129,34 @@ export function List<RowProps extends object>({
123129
return children;
124130
}, [RowComponent, getCellBounds, rowCount, rowProps, startIndex, stopIndex]);
125131

126-
return (
132+
const sizingElement = (
127133
<div
128-
role="list"
129-
{...rest}
130-
className={className}
131-
ref={setElement}
134+
aria-hidden
132135
style={{
136+
height: getEstimatedSize(),
137+
width: "100%",
138+
zIndex: -1
139+
}}
140+
></div>
141+
);
142+
143+
return createElement(
144+
tagName,
145+
{
146+
role: "list",
147+
...rest,
148+
className,
149+
ref: setElement,
150+
style: {
133151
position: "relative",
134152
maxHeight: "100%",
135153
flexGrow: 1,
136154
overflowY: "auto",
137155
...style
138-
}}
139-
>
140-
{rows}
141-
142-
<div
143-
aria-hidden
144-
style={{
145-
height: getEstimatedSize(),
146-
width: "100%",
147-
zIndex: -1
148-
}}
149-
></div>
150-
</div>
156+
}
157+
},
158+
rows,
159+
children,
160+
sizingElement
151161
);
152162
}

lib/components/list/types.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@ import type {
55
ReactNode,
66
Ref
77
} from "react";
8+
import type { TagNames } from "../../types";
89

910
type ForbiddenKeys = "ariaAttributes" | "index" | "style";
1011
type ExcludeForbiddenKeys<Type> = {
1112
[Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key];
1213
};
1314

14-
export type ListProps<RowProps extends object> = Omit<
15-
HTMLAttributes<HTMLDivElement>,
16-
"onResize"
17-
> & {
15+
export type ListProps<
16+
RowProps extends object,
17+
TagName extends TagNames = "div"
18+
> = Omit<HTMLAttributes<HTMLDivElement>, "onResize"> & {
19+
/**
20+
* Additional content to be rendered within the list (above cells).
21+
* This property can be used to render things like overlays or tooltips.
22+
*/
23+
children?: ReactNode;
24+
1825
/**
1926
* CSS class name.
2027
*/
@@ -101,6 +108,14 @@ export type ListProps<RowProps extends object> = Omit<
101108
* The list of rows will fill the height defined by this style.
102109
*/
103110
style?: CSSProperties;
111+
112+
/**
113+
* Can be used to override the root HTML element rendered by the List component.
114+
* The default value is "div", meaning that List renders an HTMLDivElement as its root.
115+
*
116+
* ⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed.
117+
*/
118+
tagName?: TagName;
104119
};
105120

106121
export type RowComponent<RowProps extends object> =

lib/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
import type { JSX } from "react";
12
export type Align = "auto" | "center" | "end" | "smart" | "start";
3+
4+
export type TagNames = keyof JSX.IntrinsicElements;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@types/react-dom": "^19.1.6",
6868
"@vitejs/plugin-react-swc": "^3.10.2",
6969
"clsx": "^2.1.1",
70+
"csstype": "^3.1.3",
7071
"eslint": "^9.30.1",
7172
"eslint-plugin-react-hooks": "^5.2.0",
7273
"eslint-plugin-react-refresh": "^0.4.20",

0 commit comments

Comments
 (0)