From 7f07ac33cbbbbf60593df584c1586f4afc42e058 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 4 Sep 2025 16:50:25 -0400 Subject: [PATCH 1/4] Improve ARIA attributes --- CHANGELOG.md | 19 +++++ lib/components/grid/Grid.test.tsx | 53 ++++++++++++-- lib/components/grid/Grid.tsx | 37 +++++++--- lib/components/grid/types.ts | 4 ++ lib/components/list/List.test.tsx | 68 +++++++++++++++++- lib/components/list/List.tsx | 23 ++++--- lib/components/list/types.ts | 7 +- lib/utils/arePropsEqual.ts | 20 ++++-- .../code-snippets/CellComponentAriaRoles.json | 4 ++ .../code-snippets/GridAriaRoles.json | 3 + .../code-snippets/ListAriaRoles.json | 3 + .../code-snippets/RowComponentAriaRoles.json | 4 ++ .../code-snippets/TableAriaAttributes.json | 3 + .../code-snippets/TableAriaOverrideProps.json | 4 ++ public/generated/js-docs/Grid.json | 2 +- public/generated/js-docs/List.json | 2 +- scripts/code-snippets/run.mjs | 69 ++++++++++++------- scripts/code-snippets/syntax-highlight.mjs | 17 ++++- scripts/code-snippets/ts-to-js.mjs | 2 +- src/components/code/FormattedCode.tsx | 16 +++-- src/nav/Nav.tsx | 7 +- src/routes.ts | 5 ++ src/routes/grid/AriaRolesRoute.tsx | 35 ++++++++++ src/routes/grid/ImperativeApiRoute.tsx | 2 + .../CellComponentAriaRoles.example.tsx | 22 ++++++ .../grid/examples/GridAriaRoles.example.html | 10 +++ src/routes/list/AriaRolesRoute.tsx | 33 +++++++++ src/routes/list/ImperativeApiRoute.tsx | 2 + .../list/examples/ListAriaRoles.example.html | 20 ++++++ .../RowComponentAriaRoles.example.tsx | 20 ++++++ src/routes/tables/AriaRolesRoute.tsx | 31 +++++++++ src/routes/tables/TabularDataRoute.tsx | 2 + .../examples/TableAriaAttributes.example.html | 16 +++++ .../TableAriaOverrideProps.example.tsx | 51 ++++++++++++++ 34 files changed, 550 insertions(+), 66 deletions(-) create mode 100644 public/generated/code-snippets/CellComponentAriaRoles.json create mode 100644 public/generated/code-snippets/GridAriaRoles.json create mode 100644 public/generated/code-snippets/ListAriaRoles.json create mode 100644 public/generated/code-snippets/RowComponentAriaRoles.json create mode 100644 public/generated/code-snippets/TableAriaAttributes.json create mode 100644 public/generated/code-snippets/TableAriaOverrideProps.json create mode 100644 src/routes/grid/AriaRolesRoute.tsx create mode 100644 src/routes/grid/examples/CellComponentAriaRoles.example.tsx create mode 100644 src/routes/grid/examples/GridAriaRoles.example.html create mode 100644 src/routes/list/AriaRolesRoute.tsx create mode 100644 src/routes/list/examples/ListAriaRoles.example.html create mode 100644 src/routes/list/examples/RowComponentAriaRoles.example.tsx create mode 100644 src/routes/tables/AriaRolesRoute.tsx create mode 100644 src/routes/tables/examples/TableAriaAttributes.example.html create mode 100644 src/routes/tables/examples/TableAriaOverrideProps.example.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 26632501..45421182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 2.0.3 + +Additional `ariaAttributes` prop pass to row and cell renderers. Suggested usage is as follows: + +```tsx +function RowComponent({ + ariaAttributes, + index, + style, + ...rest +}: RowComponentProps) { + return ( +
+ ... +
+ ); +} +``` + ## 2.0.2 Fixed edge-case bug with `Grid` imperative API `scrollToCell` method and "smooth" scrolling behavior. diff --git a/lib/components/grid/Grid.test.tsx b/lib/components/grid/Grid.test.tsx index 0cc11134..028a7974 100644 --- a/lib/components/grid/Grid.test.tsx +++ b/lib/components/grid/Grid.test.tsx @@ -10,7 +10,7 @@ describe("Grid", () => { let mountedCells: Map> = new Map(); const CellComponent = vi.fn(function Cell(props: CellComponentProps) { - const { columnIndex, rowIndex, style } = props; + const { ariaAttributes, columnIndex, rowIndex, style } = props; const key = `${rowIndex},${columnIndex}`; @@ -22,7 +22,7 @@ describe("Grid", () => { }); return ( -
+
Cell {key}
); @@ -49,7 +49,7 @@ describe("Grid", () => { /> ); - const items = screen.queryAllByRole("listitem"); + const items = screen.queryAllByRole("gridcell"); expect(items).toHaveLength(0); }); @@ -67,7 +67,7 @@ describe("Grid", () => { ); // 4 columns (+2) by 2 rows (+2) - const items = screen.queryAllByRole("listitem"); + const items = screen.queryAllByRole("gridcell"); expect(items).toHaveLength(24); }); @@ -86,7 +86,7 @@ describe("Grid", () => { ); // 4 columns by 2 rows - expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(8); + expect(container.querySelectorAll('[role="gridcell"]')).toHaveLength(8); }); test("type: function (px)", () => { @@ -106,7 +106,7 @@ describe("Grid", () => { ); // 2 columns by 2 rows - expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(4); + expect(container.querySelectorAll('[role="gridcell"]')).toHaveLength(4); }); test("type: string (%)", () => { @@ -123,7 +123,7 @@ describe("Grid", () => { ); // 4 columns by 4 rows - expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(16); + expect(container.querySelectorAll('[role="gridcell"]')).toHaveLength(16); }); }); @@ -253,4 +253,43 @@ describe("Grid", () => { // TODO }); }); + + describe("aria attributes", () => { + test("should adhere to the best recommended practices", () => { + render( + + ); + + expect(screen.queryAllByRole("grid")).toHaveLength(1); + + const rows = screen.queryAllByRole("row"); + expect(rows).toHaveLength(2); + expect(rows[0].getAttribute("aria-rowindex")).toBe("1"); + expect(rows[1].getAttribute("aria-rowindex")).toBe("2"); + + expect(screen.queryAllByRole("gridcell")).toHaveLength(4); + + { + const cells = rows[0].querySelectorAll('[role="gridcell"]'); + expect(cells).toHaveLength(2); + expect(cells[0].getAttribute("aria-colindex")).toBe("1"); + expect(cells[1].getAttribute("aria-colindex")).toBe("2"); + } + + { + const cells = rows[1].querySelectorAll('[role="gridcell"]'); + expect(cells).toHaveLength(2); + expect(cells[0].getAttribute("aria-colindex")).toBe("1"); + expect(cells[1].getAttribute("aria-colindex")).toBe("2"); + } + }); + }); }); diff --git a/lib/components/grid/Grid.tsx b/lib/components/grid/Grid.tsx index 10758186..dd5c877f 100644 --- a/lib/components/grid/Grid.tsx +++ b/lib/components/grid/Grid.tsx @@ -193,6 +193,9 @@ export function Grid({ if (columnCount > 0 && rowCount > 0) { for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { const rowBounds = getRowBounds(rowIndex); + + const columns: ReactNode[] = []; + for ( let columnIndex = columnStartIndex; columnIndex <= columnStopIndex; @@ -200,11 +203,15 @@ export function Grid({ ) { const columnBounds = getColumnBounds(columnIndex); - children.push( + columns.push( ({ /> ); } + + children.push( +
+ {columns} +
+ ); } } return children; @@ -236,30 +249,34 @@ export function Grid({ return (
+ {cells} +
- {cells} -
+ >
); } diff --git a/lib/components/grid/types.ts b/lib/components/grid/types.ts index 668cc48c..0703304b 100644 --- a/lib/components/grid/types.ts +++ b/lib/components/grid/types.ts @@ -30,6 +30,10 @@ export type GridProps = Omit< */ cellComponent: ( props: { + ariaAttributes: { + "aria-colindex": number; + role: "gridcell"; + }; columnIndex: number; rowIndex: number; style: CSSProperties; diff --git a/lib/components/list/List.test.tsx b/lib/components/list/List.test.tsx index e736a9d5..eae146e3 100644 --- a/lib/components/list/List.test.tsx +++ b/lib/components/list/List.test.tsx @@ -14,7 +14,7 @@ describe("List", () => { let mountedRows: Map> = new Map(); const RowComponent = vi.fn(function Row(props: RowComponentProps) { - const { index, style } = props; + const { ariaAttributes, index, style } = props; useLayoutEffect(() => { mountedRows.set(index, props); @@ -24,7 +24,7 @@ describe("List", () => { }); return ( -
+
Row {index}
); @@ -602,4 +602,68 @@ describe("List", () => { render(); }); }); + + describe("aria attributes", () => { + test("should be set by default", () => { + render( + + ); + + expect(screen.queryAllByRole("list")).toHaveLength(1); + + const rows = screen.queryAllByRole("listitem"); + expect(rows).toHaveLength(3); + expect(rows[0].getAttribute("aria-posinset")).toBe("1"); + expect(rows[0].getAttribute("aria-setsize")).toBe("3"); + expect(rows[1].getAttribute("aria-posinset")).toBe("2"); + expect(rows[1].getAttribute("aria-setsize")).toBe("3"); + expect(rows[2].getAttribute("aria-posinset")).toBe("3"); + expect(rows[2].getAttribute("aria-setsize")).toBe("3"); + }); + + test("should support overrides for use cases like tabular data", () => { + const TableRowComponent = (props: RowComponentProps) => { + const { index, style } = props; + + return ( +
+
+
+
+
+ ); + }; + + render( + + ); + + const tables = screen.queryAllByRole("table"); + expect(tables).toHaveLength(1); + expect(tables[0].getAttribute("aria-colcount")).toBe("3"); + expect(tables[0].getAttribute("aria-rowcount")).toBe("2"); + + const rows = screen.queryAllByRole("row"); + expect(rows).toHaveLength(2); + + const columns = rows[0].querySelectorAll('[role="cell"]'); + expect(columns).toHaveLength(3); + expect(columns[0].getAttribute("aria-colindex")).toBe("1"); + expect(columns[1].getAttribute("aria-colindex")).toBe("2"); + expect(columns[2].getAttribute("aria-colindex")).toBe("3"); + }); + }); }); diff --git a/lib/components/list/List.tsx b/lib/components/list/List.tsx index ba1db3d1..bcbf7a18 100644 --- a/lib/components/list/List.tsx +++ b/lib/components/list/List.tsx @@ -102,6 +102,11 @@ export function List({ children.push( ({ return (
+ {rows} +
- {rows} -
+ >
); } diff --git a/lib/components/list/types.ts b/lib/components/list/types.ts index cdd6cef7..bdd85a94 100644 --- a/lib/components/list/types.ts +++ b/lib/components/list/types.ts @@ -6,7 +6,7 @@ import type { Ref } from "react"; -type ForbiddenKeys = "index" | "style"; +type ForbiddenKeys = "ariaAttributes" | "index" | "style"; type ExcludeForbiddenKeys = { [Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key]; }; @@ -65,6 +65,11 @@ export type ListProps = Omit< */ rowComponent: ( props: { + ariaAttributes: { + "aria-posinset": number; + "aria-setsize": number; + role: "listitem"; + }; index: number; style: CSSProperties; } & RowProps diff --git a/lib/utils/arePropsEqual.ts b/lib/utils/arePropsEqual.ts index cbfcba10..ead35cf6 100644 --- a/lib/utils/arePropsEqual.ts +++ b/lib/utils/arePropsEqual.ts @@ -5,13 +5,23 @@ import { shallowCompare } from "./shallowCompare"; // It knows to compare individual style props and ignore the wrapper object. // See https://react.dev/reference/react/memo#memo export function arePropsEqual( - prevProps: { style: CSSProperties }, - nextProps: { style: CSSProperties } + prevProps: { ariaAttributes: object; style: CSSProperties }, + nextProps: { ariaAttributes: object; style: CSSProperties } ): boolean { - const { style: prevStyle, ...prevRest } = prevProps; - const { style: nextStyle, ...nextRest } = nextProps; + const { + ariaAttributes: prevAriaAttributes, + style: prevStyle, + ...prevRest + } = prevProps; + const { + ariaAttributes: nextAriaAttributes, + style: nextStyle, + ...nextRest + } = nextProps; return ( - shallowCompare(prevStyle, nextStyle) && shallowCompare(prevRest, nextRest) + shallowCompare(prevAriaAttributes, nextAriaAttributes) && + shallowCompare(prevStyle, nextStyle) && + shallowCompare(prevRest, nextRest) ); } diff --git a/public/generated/code-snippets/CellComponentAriaRoles.json b/public/generated/code-snippets/CellComponentAriaRoles.json new file mode 100644 index 00000000..00057bff --- /dev/null +++ b/public/generated/code-snippets/CellComponentAriaRoles.json @@ -0,0 +1,4 @@ +{ + "javaScript": "
import {} from \"react-window\";
\n
\n
function CellComponent({ ariaAttributes, columnIndex, rowIndex, style }) {
\n
return (
\n
<div style={style} {...ariaAttributes}>
\n
{/* Data */}
\n
</div>
\n
);
\n
}
", + "typeScript": "
import { type CellComponentProps } from \"react-window\";
\n
\n
function CellComponent({
\n
ariaAttributes,
\n
columnIndex,
\n
rowIndex,
\n
style
\n
}: CellComponentProps<object>) {
\n
return (
\n
<div style={style} {...ariaAttributes}>
\n
{/* Data */}
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/code-snippets/GridAriaRoles.json b/public/generated/code-snippets/GridAriaRoles.json new file mode 100644 index 00000000..51026f0c --- /dev/null +++ b/public/generated/code-snippets/GridAriaRoles.json @@ -0,0 +1,3 @@ +{ + "html": "
<div role=\"grid\" aria-colcount=\"100\" aria-rowcount=\"1000\">
\n
<div role=\"row\" aria-rowindex=\"0\">
\n
<div role=\"gridcell\" aria-colindex=\"0\" />
\n
<div role=\"gridcell\" aria-colindex=\"1\" />
\n
\n
<!-- More columns ... -->
\n
</div>
\n
\n
<!-- More rows ... -->
\n
</div>
" +} \ No newline at end of file diff --git a/public/generated/code-snippets/ListAriaRoles.json b/public/generated/code-snippets/ListAriaRoles.json new file mode 100644 index 00000000..f08041dd --- /dev/null +++ b/public/generated/code-snippets/ListAriaRoles.json @@ -0,0 +1,3 @@ +{ + "html": "
<div role=\"list\">
\n
<div
\n
role=\"listitem\"
\n
aria-posinset=\"1\"
\n
aria-setsize=\"1000\"
\n
>
\n
Row 1
\n
</div>
\n
\n
<div
\n
role=\"listitem\"
\n
aria-posinset=\"2\"
\n
aria-setsize=\"1000\"
\n
>
\n
Row 2
\n
</div>
\n
\n
<!-- More rows ... -->
\n
</div>
" +} \ No newline at end of file diff --git a/public/generated/code-snippets/RowComponentAriaRoles.json b/public/generated/code-snippets/RowComponentAriaRoles.json new file mode 100644 index 00000000..1c3cd4a0 --- /dev/null +++ b/public/generated/code-snippets/RowComponentAriaRoles.json @@ -0,0 +1,4 @@ +{ + "javaScript": "
import {} from \"react-window\";
\n
\n
function RowComponent({ ariaAttributes, names, index, style }) {
\n
return (
\n
<div style={style} {...ariaAttributes}>
\n
{names[index]}
\n
</div>
\n
);
\n
}
", + "typeScript": "
import { type RowComponentProps } from \"react-window\";
\n
\n
function RowComponent({
\n
ariaAttributes,
\n
names,
\n
index,
\n
style
\n
}: RowComponentProps<{
\n
names: string[];
\n
}>) {
\n
return (
\n
<div style={style} {...ariaAttributes}>
\n
{names[index]}
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/code-snippets/TableAriaAttributes.json b/public/generated/code-snippets/TableAriaAttributes.json new file mode 100644 index 00000000..bafc34fc --- /dev/null +++ b/public/generated/code-snippets/TableAriaAttributes.json @@ -0,0 +1,3 @@ +{ + "html": "
<div role=\"table\" aria-colcount=\"3\" aria-rowcount=\"1000\">
\n
<div role=\"row\" aria-rowindex=\"1\">
\n
<div role=\"columnheader\" aria-colindex=\"1\">City</div>
\n
<div role=\"columnheader\" aria-colindex=\"2\">State</div>
\n
<div role=\"columnheader\" aria-colindex=\"3\">Zip</div>
\n
</div>
\n
\n
<div role=\"row\" aria-rowindex=\"2\">
\n
<div role=\"cell\" aria-colindex=\"1\" />
\n
<div role=\"cell\" aria-colindex=\"2\" />
\n
<div role=\"cell\" aria-colindex=\"3\" />
\n
</div>
\n
\n
<!-- More rows ... -->
\n
</div>
" +} \ No newline at end of file diff --git a/public/generated/code-snippets/TableAriaOverrideProps.json b/public/generated/code-snippets/TableAriaOverrideProps.json new file mode 100644 index 00000000..3ee3cb1c --- /dev/null +++ b/public/generated/code-snippets/TableAriaOverrideProps.json @@ -0,0 +1,4 @@ +{ + "javaScript": "
import { List } from \"react-window\";
\n
\n
function Example() {
\n
return (
\n
<div role=\"table\" aria-colcount={3} aria-rowcount={1000}>
\n
<div role=\"row\" aria-rowindex={1}>
\n
<div role=\"columnheader\" aria-colindex={1}>
\n
City
\n
</div>
\n
<div role=\"columnheader\" aria-colindex={1}>
\n
State
\n
</div>
\n
<div role=\"columnheader\" aria-colindex={1}>
\n
Zip
\n
</div>
\n
</div>
\n
\n
<List role=\"rowgroup\" {...otherListProps} />
\n
</div>
\n
);
\n
}
\n
\n
function RowComponent({ index, style }) {
\n
// Add 1 to the row index to account for the header row
\n
return (
\n
<div aria-rowindex={index + 1} role=\"row\" style={style}>
\n
<div role=\"cell\" aria-colindex={1}>
\n
...
\n
</div>
\n
<div role=\"cell\" aria-colindex={2}>
\n
...
\n
</div>
\n
<div role=\"cell\" aria-colindex={3}>
\n
...
\n
</div>
\n
</div>
\n
);
\n
}
", + "typeScript": "
import { List, type RowComponentProps } from \"react-window\";
\n
\n
function Example() {
\n
return (
\n
<div role=\"table\" aria-colcount={3} aria-rowcount={1000}>
\n
<div role=\"row\" aria-rowindex={1}>
\n
<div role=\"columnheader\" aria-colindex={1}>
\n
City
\n
</div>
\n
<div role=\"columnheader\" aria-colindex={1}>
\n
State
\n
</div>
\n
<div role=\"columnheader\" aria-colindex={1}>
\n
Zip
\n
</div>
\n
</div>
\n
\n
<List role=\"rowgroup\" {...otherListProps} />
\n
</div>
\n
);
\n
}
\n
\n
function RowComponent({ index, style }: RowComponentProps<object>) {
\n
// Add 1 to the row index to account for the header row
\n
return (
\n
<div aria-rowindex={index + 1} role=\"row\" style={style}>
\n
<div role=\"cell\" aria-colindex={1}>
\n
...
\n
</div>
\n
<div role=\"cell\" aria-colindex={2}>
\n
...
\n
</div>
\n
<div role=\"cell\" aria-colindex={3}>
\n
...
\n
</div>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/js-docs/Grid.json b/public/generated/js-docs/Grid.json index ec2e4091..9cac4fc6 100644 --- a/public/generated/js-docs/Grid.json +++ b/public/generated/js-docs/Grid.json @@ -116,7 +116,7 @@ ], "required": true, "type": { - "name": "(props: { columnIndex: number; rowIndex: number; style: CSSProperties; } & CellProps) => ReactNode" + "name": "(props: { ariaAttributes: { \"aria-colindex\": number; role: \"gridcell\"; }; columnIndex: number; rowIndex: number; style: CSSProperties; } & CellProps) => ReactNode" } }, "cellProps": { diff --git a/public/generated/js-docs/List.json b/public/generated/js-docs/List.json index 7b0415ff..f69194c2 100644 --- a/public/generated/js-docs/List.json +++ b/public/generated/js-docs/List.json @@ -229,7 +229,7 @@ ], "required": true, "type": { - "name": "(props: { index: number; style: CSSProperties; } & RowProps) => ReactNode" + "name": "(props: { ariaAttributes: { \"aria-posinset\": number; \"aria-setsize\": number; role: \"listitem\"; }; index: number; style: CSSProperties; } & RowProps) => ReactNode" } }, "rowCount": { diff --git a/scripts/code-snippets/run.mjs b/scripts/code-snippets/run.mjs index 17dcef2a..a118c316 100644 --- a/scripts/code-snippets/run.mjs +++ b/scripts/code-snippets/run.mjs @@ -3,7 +3,7 @@ import { basename, join } from "node:path"; import { cwd } from "node:process"; import { getFilesWithExtensions, rmFilesWithExtensions } from "../utils.mjs"; import { syntaxHighlight } from "./syntax-highlight.mjs"; -import { toToJs } from "./ts-to-js.mjs"; +import { tsToJs } from "./ts-to-js.mjs"; async function run() { const inputDir = join(cwd(), "src", "routes"); @@ -13,8 +13,12 @@ async function run() { await rmFilesWithExtensions(outputDir, [".json"]); - const tsFiles = await getFilesWithExtensions(inputDir, [".ts", ".tsx"]); - const exampleFiles = tsFiles.filter((file) => file.includes("example.ts")); + const tsFiles = await getFilesWithExtensions(inputDir, [ + ".html", + ".ts", + ".tsx" + ]); + const exampleFiles = tsFiles.filter((file) => file.includes(".example.")); for (let file of exampleFiles) { console.debug("Extracting", file); @@ -22,35 +26,54 @@ async function run() { const buffer = await readFile(file); let rawText = buffer.toString(); + let json; { - const pieces = rawText.split("// "); - rawText = pieces[pieces.length - 1].trim(); - } - { - const pieces = rawText.split("// "); - rawText = pieces[0].trim(); + { + const pieces = rawText.split("// "); + rawText = pieces[pieces.length - 1].trim(); + } + { + const pieces = rawText.split("// "); + rawText = pieces[0].trim(); + } + + rawText = rawText + .split("\n") + .filter( + (line) => + !line.includes("prettier-ignore") && + !line.includes("eslint-disable-next-line") && + !line.includes("@ts-expect-error") + ) + .join("\n"); } - const typeScript = rawText; - const javaScript = (await toToJs(typeScript)).trim(); + if (file.endsWith(".html")) { + json = { + html: await syntaxHighlight(rawText, "HTML") + }; + } else { + const typeScript = rawText; + const javaScript = (await tsToJs(typeScript)).trim(); - const fileName = basename(file); + json = { + javaScript: await syntaxHighlight(javaScript, "JSX"), + typeScript: + typeScript !== javaScript + ? await syntaxHighlight( + typeScript, + file.endsWith("tsx") ? "TSX" : "TS" + ) + : undefined + }; + } - const json = { - javaScript: await syntaxHighlight(javaScript, "JSX"), - typeScript: - typeScript !== javaScript - ? await syntaxHighlight( - typeScript, - file.endsWith("tsx") ? "TSX" : "TS" - ) - : undefined - }; + const fileName = basename(file); const outputFile = join( outputDir, - fileName.replace(/\.example\.ts(x*)$/, ".json") + fileName.replace(/\.example\..+$/, ".json") ); console.debug("Writing to", outputFile); diff --git a/scripts/code-snippets/syntax-highlight.mjs b/scripts/code-snippets/syntax-highlight.mjs index b495a771..da2cf4fb 100644 --- a/scripts/code-snippets/syntax-highlight.mjs +++ b/scripts/code-snippets/syntax-highlight.mjs @@ -3,6 +3,7 @@ import { tsxLanguage, typescriptLanguage } from "@codemirror/lang-javascript"; +import { htmlLanguage } from "@codemirror/lang-html"; import { ensureSyntaxTree } from "@codemirror/language"; import { EditorState } from "@codemirror/state"; import { classHighlighter, highlightTree } from "@lezer/highlight"; @@ -13,6 +14,10 @@ export const DEFAULT_MAX_TIME = 5000; export async function syntaxHighlight(code, language) { let extension; switch (language) { + case "HTML": { + extension = htmlLanguage.configure({ dialect: "selfClosing" }); + break; + } case "JSX": { extension = jsxLanguage; break; @@ -185,9 +190,17 @@ function parsedTokensToHtml(tokens) { tokens = tokens.map((token, index) => { const className = token.type ? `tok-${token.type}` : ""; + // Trim leading space and use CSS to indent instead; + // this allows for better line wrapping behavior on narrow screens if (index === 0 && !token.type) { - indent = token.value.length; - token.value = ""; + const index = token.value.search(/[^\s]/); + if (index < 0) { + indent = token.value.length; + token.value = ""; + } else { + indent = index; + token.value = token.value.substring(index); + } } const escapedValue = escapeHtmlEntities(token.value); diff --git a/scripts/code-snippets/ts-to-js.mjs b/scripts/code-snippets/ts-to-js.mjs index 238ae503..644a3a3f 100644 --- a/scripts/code-snippets/ts-to-js.mjs +++ b/scripts/code-snippets/ts-to-js.mjs @@ -1,7 +1,7 @@ import prettier from "prettier"; import tsBlankSpace from "ts-blank-space"; -export async function toToJs(source) { +export async function tsToJs(source) { source = tsBlankSpace(source); source = source.replace(/]+>/g, "]+>/g, "; + } + const code = ( Variable row heights Component props Imperative API + ARIA roles + + + Tabular data + ARIA roles Rendering a grid Component props Imperative API + ARIA roles - Tabular data Right to left content Horizontal lists diff --git a/src/routes.ts b/src/routes.ts index 8f506ead..4b6b3ea2 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -19,7 +19,11 @@ export const routes = { () => import("./routes/list/ImperativeApiRoute") ), "/list/props": lazy(() => import("./routes/list/PropsRoute")), + "/list/aria-roles": lazy(() => import("./routes/list/AriaRolesRoute")), "/list/tabular-data": lazy(() => import("./routes/tables/TabularDataRoute")), + "/list/tabular-data-aria-roles": lazy( + () => import("./routes/tables/AriaRolesRoute") + ), // SimpleGrid "/grid/grid": lazy(() => import("./routes/grid/RenderingGridRoute")), @@ -31,6 +35,7 @@ export const routes = { "/grid/imperative-api": lazy( () => import("./routes/grid/ImperativeApiRoute") ), + "/grid/aria-roles": lazy(() => import("./routes/grid/AriaRolesRoute")), // Other "/platform-requirements": lazy( diff --git a/src/routes/grid/AriaRolesRoute.tsx b/src/routes/grid/AriaRolesRoute.tsx new file mode 100644 index 00000000..8f29d6a4 --- /dev/null +++ b/src/routes/grid/AriaRolesRoute.tsx @@ -0,0 +1,35 @@ +import { Box } from "../../components/Box"; +import GridAriaRolesMarkdown from "../../../public/generated/code-snippets/GridAriaRoles.json"; +import CellComponentAriaRolesMarkdown from "../../../public/generated/code-snippets/CellComponentAriaRoles.json"; +import { FormattedCode } from "../../components/code/FormattedCode"; +import { ExternalLink } from "../../components/ExternalLink"; +import { Header } from "../../components/Header"; + +export default function AriaRolesRoute() { + return ( + +
+
+ The ARIA{" "} + + grid role + {" "} + can be used to identify an element that contains one or more rows of + cells. +
+ +
+ The Grid component automatically adds this role to the root + HTMLDivElement it renders, but because individual cells are rendered by + your code- you must assign ARIA attributes to those elements. +
+
+ To simplify this, the recommended ARIA attributes are passed to the{" "} + cellComponent in the form of the{" "} + ariaAttributes prop. The easiest way to use them is just to + pass them through like so: +
+ + + ); +} diff --git a/src/routes/grid/ImperativeApiRoute.tsx b/src/routes/grid/ImperativeApiRoute.tsx index 54cd56f0..949a999e 100644 --- a/src/routes/grid/ImperativeApiRoute.tsx +++ b/src/routes/grid/ImperativeApiRoute.tsx @@ -17,6 +17,7 @@ import { columnWidth } from "./examples/columnWidth.example"; import type { Contact } from "./examples/Grid.example"; import { COLUMN_KEYS } from "./examples/shared"; import { useContacts } from "./hooks/useContacts"; +import { ContinueLink } from "../../components/ContinueLink"; const EMPTY_OPTION: Option = { label: "", @@ -188,6 +189,7 @@ export default function GridImperativeApiRoute() { ref to another component or hook, use the ref callback function instead. + ); } diff --git a/src/routes/grid/examples/CellComponentAriaRoles.example.tsx b/src/routes/grid/examples/CellComponentAriaRoles.example.tsx new file mode 100644 index 00000000..944598a3 --- /dev/null +++ b/src/routes/grid/examples/CellComponentAriaRoles.example.tsx @@ -0,0 +1,22 @@ +import { type CellComponentProps } from "react-window"; + +function CellComponent({ + ariaAttributes, + // @ts-expect-error Unused variable + // eslint-disable-next-line + columnIndex, + // @ts-expect-error Unused variable + // eslint-disable-next-line + rowIndex, + style +}: CellComponentProps) { + return ( +
+ {/* Data */} +
+ ); +} + +// + +export { CellComponent }; diff --git a/src/routes/grid/examples/GridAriaRoles.example.html b/src/routes/grid/examples/GridAriaRoles.example.html new file mode 100644 index 00000000..69c29b37 --- /dev/null +++ b/src/routes/grid/examples/GridAriaRoles.example.html @@ -0,0 +1,10 @@ +
+
+
+
+ + +
+ + +
diff --git a/src/routes/list/AriaRolesRoute.tsx b/src/routes/list/AriaRolesRoute.tsx new file mode 100644 index 00000000..e914d3a0 --- /dev/null +++ b/src/routes/list/AriaRolesRoute.tsx @@ -0,0 +1,33 @@ +import { Box } from "../../components/Box"; +import ListAriaRolesMarkdown from "../../../public/generated/code-snippets/ListAriaRoles.json"; +import RowComponentAriaRolesMarkdown from "../../../public/generated/code-snippets/RowComponentAriaRoles.json"; +import { FormattedCode } from "../../components/code/FormattedCode"; +import { ExternalLink } from "../../components/ExternalLink"; +import { Header } from "../../components/Header"; + +export default function AriaRolesRoute() { + return ( + +
+
+ The ARIA{" "} + + list role + {" "} + can be used to identify a list of items. +
+ +
+ The List component automatically adds this role to the root + HTMLDivElement it renders, but because individual rows are rendered by + your code- you must assign ARIA attributes to those elements. +
+
+ To simplify this, the recommended ARIA attributes are passed to the{" "} + rowComponent in the form of the ariaAttributes{" "} + prop. The easiest way to use them is just to pass them through like so: +
+ + + ); +} diff --git a/src/routes/list/ImperativeApiRoute.tsx b/src/routes/list/ImperativeApiRoute.tsx index b01121a5..e6d8eb5f 100644 --- a/src/routes/list/ImperativeApiRoute.tsx +++ b/src/routes/list/ImperativeApiRoute.tsx @@ -15,6 +15,7 @@ import { Select, type Option } from "../../components/Select"; import { RowComponent } from "./examples/ListVariableRowHeights.example"; import { rowHeight } from "./examples/rowHeight.example"; import { useCitiesByState } from "./hooks/useCitiesByState"; +import { ContinueLink } from "../../components/ContinueLink"; const EMPTY_OPTION: Option = { label: "", @@ -131,6 +132,7 @@ export default function ListImperativeApiRoute() { ref to another component or hook, use the ref callback function instead. + ); } diff --git a/src/routes/list/examples/ListAriaRoles.example.html b/src/routes/list/examples/ListAriaRoles.example.html new file mode 100644 index 00000000..2f8c7223 --- /dev/null +++ b/src/routes/list/examples/ListAriaRoles.example.html @@ -0,0 +1,20 @@ + +
+
+ Row 1 +
+ +
+ Row 2 +
+ + +
diff --git a/src/routes/list/examples/RowComponentAriaRoles.example.tsx b/src/routes/list/examples/RowComponentAriaRoles.example.tsx new file mode 100644 index 00000000..773d94c1 --- /dev/null +++ b/src/routes/list/examples/RowComponentAriaRoles.example.tsx @@ -0,0 +1,20 @@ +import { type RowComponentProps } from "react-window"; + +function RowComponent({ + ariaAttributes, + names, + index, + style +}: RowComponentProps<{ + names: string[]; +}>) { + return ( +
+ {names[index]} +
+ ); +} + +// + +export { RowComponent }; diff --git a/src/routes/tables/AriaRolesRoute.tsx b/src/routes/tables/AriaRolesRoute.tsx new file mode 100644 index 00000000..99072ac0 --- /dev/null +++ b/src/routes/tables/AriaRolesRoute.tsx @@ -0,0 +1,31 @@ +import TableAriaAttributesMarkdown from "../../../public/generated/code-snippets/TableAriaAttributes.json"; +import TableAriaOverridePropsMarkdown from "../../../public/generated/code-snippets/TableAriaOverrideProps.json"; +import { Box } from "../../components/Box"; +import { FormattedCode } from "../../components/code/FormattedCode"; +import { ExternalLink } from "../../components/ExternalLink"; +import { Header } from "../../components/Header"; + +export default function AriaRolesRoute() { + return ( + +
+
+ The default ARIA role set by the List component is{" "} + + list + {" "} + , but the{" "} + + table + {" "} + role is more appropriate for tabular data. +
+ +
+ The example on the previous page can be modified like so to assign the + correct ARIA attributes: +
+ + + ); +} diff --git a/src/routes/tables/TabularDataRoute.tsx b/src/routes/tables/TabularDataRoute.tsx index edf5f902..03000185 100644 --- a/src/routes/tables/TabularDataRoute.tsx +++ b/src/routes/tables/TabularDataRoute.tsx @@ -3,6 +3,7 @@ import { Block } from "../../components/Block"; import { Box } from "../../components/Box"; import { Callout } from "../../components/Callout"; import { FormattedCode } from "../../components/code/FormattedCode"; +import { ContinueLink } from "../../components/ContinueLink"; import { Header } from "../../components/Header"; import { Link } from "../../components/Link"; import { LoadingSpinner } from "../../components/LoadingSpinner"; @@ -35,6 +36,7 @@ export default function TabularDataRoute() { It may be more efficient to render data with many columns using the{" "} Grid component. + ); } diff --git a/src/routes/tables/examples/TableAriaAttributes.example.html b/src/routes/tables/examples/TableAriaAttributes.example.html new file mode 100644 index 00000000..83643624 --- /dev/null +++ b/src/routes/tables/examples/TableAriaAttributes.example.html @@ -0,0 +1,16 @@ + +
+
+
City
+
State
+
Zip
+
+ +
+
+
+
+
+ + +
diff --git a/src/routes/tables/examples/TableAriaOverrideProps.example.tsx b/src/routes/tables/examples/TableAriaOverrideProps.example.tsx new file mode 100644 index 00000000..b157d1e1 --- /dev/null +++ b/src/routes/tables/examples/TableAriaOverrideProps.example.tsx @@ -0,0 +1,51 @@ +const otherListProps = { + rowComponent: RowComponent, + rowCount: 123, + rowHeight: 25, + rowProps: {} +}; + +// + +import { List, type RowComponentProps } from "react-window"; + +function Example() { + return ( +
+
+
+ City +
+
+ State +
+
+ Zip +
+
+ + +
+ ); +} + +function RowComponent({ index, style }: RowComponentProps) { + // Add 1 to the row index to account for the header row + return ( +
+
+ ... +
+
+ ... +
+
+ ... +
+
+ ); +} + +// + +export { Example }; From 9f1e8f2f0a7d80b3b3ab836f0e830805e185a210 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 6 Sep 2025 10:54:20 -0400 Subject: [PATCH 2/4] Support custom tagName for outer element and (optional) children --- lib/components/grid/Grid.test.tsx | 42 ++ lib/components/grid/Grid.tsx | 58 +- lib/components/grid/types.ts | 33 +- lib/components/list/List.test.tsx | 38 ++ lib/components/list/List.tsx | 52 +- lib/components/list/types.ts | 23 +- lib/types.ts | 3 + package.json | 1 + pnpm-lock.yaml | 3 + .../code-snippets/FixedHeightList.json | 4 +- .../FixedHeightRowComponent.json | 4 +- .../code-snippets/ListWithStickyRows.json | 4 + public/generated/js-docs/Grid.json | 631 ++++++++++++++++++ public/generated/js-docs/List.json | 631 ++++++++++++++++++ src/nav/Nav.tsx | 1 + src/routes.ts | 1 + src/routes/list/StickyRowsRoute.tsx | 38 ++ .../list/examples/FixedHeightList.example.tsx | 1 + .../FixedHeightRowComponent.example.tsx | 4 +- .../examples/ListWithStickyRows.example.tsx | 32 + tsconfig.json | 1 + 21 files changed, 1541 insertions(+), 64 deletions(-) create mode 100644 public/generated/code-snippets/ListWithStickyRows.json create mode 100644 src/routes/list/StickyRowsRoute.tsx create mode 100644 src/routes/list/examples/ListWithStickyRows.example.tsx diff --git a/lib/components/grid/Grid.test.tsx b/lib/components/grid/Grid.test.tsx index 028a7974..ef5cad82 100644 --- a/lib/components/grid/Grid.test.tsx +++ b/lib/components/grid/Grid.test.tsx @@ -159,6 +159,48 @@ describe("Grid", () => { // TODO }); + test("custom tagName and attributes", () => { + function CustomCellComponent({ style }: CellComponentProps) { + return Cell; + } + + const { container } = render( + + ); + + expect(container.firstElementChild?.tagName).toBe("MAIN"); + expect(container.querySelectorAll("SPAN")).toHaveLength(8); + }); + + test("children", () => { + const { container } = render( + +
Overlay or tooltip
+
+ ); + + expect(container.querySelector("#custom")).toHaveTextContent( + "Overlay or tooltip" + ); + }); + describe("imperative API", () => { test.skip("should return the root element", () => { // TODO diff --git a/lib/components/grid/Grid.tsx b/lib/components/grid/Grid.tsx index dd5c877f..2a5983a9 100644 --- a/lib/components/grid/Grid.tsx +++ b/lib/components/grid/Grid.tsx @@ -1,4 +1,5 @@ import { + createElement, memo, useEffect, useImperativeHandle, @@ -9,13 +10,17 @@ import { import { useIsRtl } from "../../core/useIsRtl"; import { useVirtualizer } from "../../core/useVirtualizer"; import { useMemoizedObject } from "../../hooks/useMemoizedObject"; -import type { Align } from "../../types"; +import type { Align, TagNames } from "../../types"; import { arePropsEqual } from "../../utils/arePropsEqual"; import type { GridProps } from "./types"; -export function Grid({ +export function Grid< + CellProps extends object, + TagName extends TagNames = "div" +>({ cellComponent: CellComponentProp, cellProps: cellPropsUnstable, + children, className, columnCount, columnWidth, @@ -29,8 +34,9 @@ export function Grid({ rowCount, rowHeight, style, + tagName = "div" as TagName, ...rest -}: GridProps) { +}: GridProps) { const cellProps = useMemoizedObject(cellPropsUnstable); const CellComponent = useMemo( () => memo(CellComponentProp, arePropsEqual), @@ -247,16 +253,28 @@ export function Grid({ rowStopIndex ]); - return ( + const sizingElement = (
+ ); + + return createElement( + tagName, + { + "aria-colcount": columnCount, + "aria-rowcount": rowCount, + role: "grid", + ...rest, + className, + dir, + ref: setElement, + style: { position: "relative", width: "100%", height: "100%", @@ -265,18 +283,10 @@ export function Grid({ flexGrow: 1, overflow: "auto", ...style - }} - > - {cells} - -
- + } + }, + cells, + children, + sizingElement ); } diff --git a/lib/components/grid/types.ts b/lib/components/grid/types.ts index 0703304b..531e49e3 100644 --- a/lib/components/grid/types.ts +++ b/lib/components/grid/types.ts @@ -5,21 +5,17 @@ import type { ReactNode, Ref } from "react"; +import type { TagNames } from "../../types"; type ForbiddenKeys = "columnIndex" | "rowIndex" | "style"; type ExcludeForbiddenKeys = { [Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key]; }; -export type GridProps = Omit< - HTMLAttributes, - "onResize" -> & { - /** - * CSS class name. - */ - className?: string; - +export type GridProps< + CellProps extends object, + TagName extends TagNames = "div" +> = Omit, "onResize"> & { /** * React component responsible for rendering a cell. * @@ -48,6 +44,17 @@ export type GridProps = Omit< */ cellProps: ExcludeForbiddenKeys; + /** + * Additional content to be rendered within the grid (above cells). + * This property can be used to render things like overlays or tooltips. + */ + children?: ReactNode; + + /** + * CSS class name. + */ + className?: string; + /** * Number of columns to be rendered in the grid. */ @@ -137,6 +144,14 @@ export type GridProps = Omit< * The grid of cells will fill the height and width defined by this style. */ style?: CSSProperties; + + /** + * Can be used to override the root HTML element rendered by the List component. + * The default value is "div", meaning that List renders an HTMLDivElement as its root. + * + * ⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed. + */ + tagName?: TagName; }; export type CellComponent = diff --git a/lib/components/list/List.test.tsx b/lib/components/list/List.test.tsx index eae146e3..6e857664 100644 --- a/lib/components/list/List.test.tsx +++ b/lib/components/list/List.test.tsx @@ -329,6 +329,44 @@ describe("List", () => { expect(screen.queryByTestId("foo")).toHaveRole("list"); }); + test("custom tagName and attributes", () => { + function CustomRowComponent({ index, style }: RowComponentProps) { + return
  • Row {index + 1}
  • ; + } + + const { container } = render( + + ); + + expect(container.firstElementChild?.tagName).toBe("UL"); + expect(container.querySelectorAll("LI")).toHaveLength(4); + }); + + test("children", () => { + const { container } = render( + +
    Overlay or tooltip
    +
    + ); + + expect(container.querySelector("#custom")).toHaveTextContent( + "Overlay or tooltip" + ); + }); + describe("imperative API", () => { test("should return the root element", () => { const listRef = createRef(); diff --git a/lib/components/list/List.tsx b/lib/components/list/List.tsx index bcbf7a18..c8d07d2e 100644 --- a/lib/components/list/List.tsx +++ b/lib/components/list/List.tsx @@ -1,4 +1,5 @@ import { + createElement, memo, useEffect, useImperativeHandle, @@ -8,11 +9,15 @@ import { } from "react"; import { useVirtualizer } from "../../core/useVirtualizer"; import { useMemoizedObject } from "../../hooks/useMemoizedObject"; -import type { Align } from "../../types"; +import type { Align, TagNames } from "../../types"; import { arePropsEqual } from "../../utils/arePropsEqual"; import type { ListProps } from "./types"; -export function List({ +export function List< + RowProps extends object, + TagName extends TagNames = "div" +>({ + children, className, defaultHeight = 0, listRef, @@ -23,9 +28,10 @@ export function List({ rowCount, rowHeight, rowProps: rowPropsUnstable, + tagName = "div" as TagName, style, ...rest -}: ListProps) { +}: ListProps) { const rowProps = useMemoizedObject(rowPropsUnstable); const RowComponent = useMemo( () => memo(RowComponentProp, arePropsEqual), @@ -123,30 +129,34 @@ export function List({ return children; }, [RowComponent, getCellBounds, rowCount, rowProps, startIndex, stopIndex]); - return ( + const sizingElement = (
    + ); + + return createElement( + tagName, + { + role: "list", + ...rest, + className, + ref: setElement, + style: { position: "relative", maxHeight: "100%", flexGrow: 1, overflowY: "auto", ...style - }} - > - {rows} - -
    - + } + }, + rows, + children, + sizingElement ); } diff --git a/lib/components/list/types.ts b/lib/components/list/types.ts index bdd85a94..c210526b 100644 --- a/lib/components/list/types.ts +++ b/lib/components/list/types.ts @@ -5,16 +5,23 @@ import type { ReactNode, Ref } from "react"; +import type { TagNames } from "../../types"; type ForbiddenKeys = "ariaAttributes" | "index" | "style"; type ExcludeForbiddenKeys = { [Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key]; }; -export type ListProps = Omit< - HTMLAttributes, - "onResize" -> & { +export type ListProps< + RowProps extends object, + TagName extends TagNames = "div" +> = Omit, "onResize"> & { + /** + * Additional content to be rendered within the list (above cells). + * This property can be used to render things like overlays or tooltips. + */ + children?: ReactNode; + /** * CSS class name. */ @@ -101,6 +108,14 @@ export type ListProps = Omit< * The list of rows will fill the height defined by this style. */ style?: CSSProperties; + + /** + * Can be used to override the root HTML element rendered by the List component. + * The default value is "div", meaning that List renders an HTMLDivElement as its root. + * + * ⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed. + */ + tagName?: TagName; }; export type RowComponent = diff --git a/lib/types.ts b/lib/types.ts index bc9b48fb..d96e027f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1 +1,4 @@ +import type { JSX } from "react"; export type Align = "auto" | "center" | "end" | "smart" | "start"; + +export type TagNames = keyof JSX.IntrinsicElements; diff --git a/package.json b/package.json index 2bb4264d..25da6fbf 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@types/react-dom": "^19.1.6", "@vitejs/plugin-react-swc": "^3.10.2", "clsx": "^2.1.1", + "csstype": "^3.1.3", "eslint": "^9.30.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c57a798..f434e13a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + csstype: + specifier: ^3.1.3 + version: 3.1.3 eslint: specifier: ^9.30.1 version: 9.30.1(jiti@2.4.2) diff --git a/public/generated/code-snippets/FixedHeightList.json b/public/generated/code-snippets/FixedHeightList.json index 9db447f8..7ea8f2ea 100644 --- a/public/generated/code-snippets/FixedHeightList.json +++ b/public/generated/code-snippets/FixedHeightList.json @@ -1,4 +1,4 @@ { - "javaScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    />
    \n
    );
    \n
    }
    ", - "typeScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }: { names: string[] }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    />
    \n
    );
    \n
    }
    " + "javaScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    tagName=\"ul\"
    \n
    />
    \n
    );
    \n
    }
    ", + "typeScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }: { names: string[] }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    tagName=\"ul\"
    \n
    />
    \n
    );
    \n
    }
    " } \ No newline at end of file diff --git a/public/generated/code-snippets/FixedHeightRowComponent.json b/public/generated/code-snippets/FixedHeightRowComponent.json index efa54a6a..9b8e2df4 100644 --- a/public/generated/code-snippets/FixedHeightRowComponent.json +++ b/public/generated/code-snippets/FixedHeightRowComponent.json @@ -1,4 +1,4 @@ { - "javaScript": "
    import {} from \"react-window\";
    \n
    \n
    function RowComponent({ index, names, style }) {
    \n
    return (
    \n
    <div className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </div>
    \n
    );
    \n
    }
    ", - "typeScript": "
    import { type RowComponentProps } from \"react-window\";
    \n
    \n
    function RowComponent({
    \n
    index,
    \n
    names,
    \n
    style
    \n
    }: RowComponentProps<{
    \n
    names: string[];
    \n
    }>) {
    \n
    return (
    \n
    <div className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </div>
    \n
    );
    \n
    }
    " + "javaScript": "
    import {} from \"react-window\";
    \n
    \n
    function RowComponent({ index, names, style }) {
    \n
    return (
    \n
    <li className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </li>
    \n
    );
    \n
    }
    ", + "typeScript": "
    import { type RowComponentProps } from \"react-window\";
    \n
    \n
    function RowComponent({
    \n
    index,
    \n
    names,
    \n
    style
    \n
    }: RowComponentProps<{
    \n
    names: string[];
    \n
    }>) {
    \n
    return (
    \n
    <li className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </li>
    \n
    );
    \n
    }
    " } \ No newline at end of file diff --git a/public/generated/code-snippets/ListWithStickyRows.json b/public/generated/code-snippets/ListWithStickyRows.json new file mode 100644 index 00000000..550c0117 --- /dev/null +++ b/public/generated/code-snippets/ListWithStickyRows.json @@ -0,0 +1,4 @@ +{ + "javaScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example() {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={101}
    \n
    rowHeight={20}
    \n
    rowProps={EMPTY_OBJECT}
    \n
    >
    \n
    <div className=\"w-full h-0 top-0 sticky\">
    \n
    <div className=\"h-[20px] bg-teal-600 px-2 rounded\">Sticky header</div>
    \n
    </div>
    \n
    </List>
    \n
    );
    \n
    }
    ", + "typeScript": "
    import { List, type RowComponentProps } from \"react-window\";
    \n
    \n
    function Example() {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={101}
    \n
    rowHeight={20}
    \n
    rowProps={EMPTY_OBJECT}
    \n
    >
    \n
    <div className=\"w-full h-0 top-0 sticky\">
    \n
    <div className=\"h-[20px] bg-teal-600 px-2 rounded\">Sticky header</div>
    \n
    </div>
    \n
    </List>
    \n
    );
    \n
    }
    " +} \ No newline at end of file diff --git a/public/generated/js-docs/Grid.json b/public/generated/js-docs/Grid.json index 9cac4fc6..d808337d 100644 --- a/public/generated/js-docs/Grid.json +++ b/public/generated/js-docs/Grid.json @@ -104,6 +104,80 @@ ] } }, + "children": { + "defaultValue": null, + "description": "Additional content to be rendered within the grid (above cells).\nThis property can be used to render things like overlays or tooltips.", + "name": "children", + "parent": { + "fileName": "react-window/node_modules/.pnpm/@types+react@19.1.8/node_modules/@types/react/index.d.ts", + "name": "DOMAttributes" + }, + "declarations": [ + { + "fileName": "react-window/node_modules/.pnpm/@types+react@19.1.8/node_modules/@types/react/index.d.ts", + "name": "DOMAttributes" + }, + { + "fileName": "react-window/lib/components/grid/types.ts", + "name": "TypeLiteral" + } + ], + "required": false, + "type": { + "name": "enum", + "raw": "ReactNode", + "value": [ + { + "value": "undefined" + }, + { + "value": "null" + }, + { + "value": "string" + }, + { + "value": "number" + }, + { + "value": "bigint" + }, + { + "value": "false" + }, + { + "value": "true" + }, + { + "value": "ReactElement>", + "description": "Represents a JSX element.\n\nWhere {@link ReactNode} represents everything that can be rendered, `ReactElement`\nonly represents JSX.", + "fullComment": "Represents a JSX element.\n\nWhere {@link ReactNode} represents everything that can be rendered, `ReactElement`\nonly represents JSX.\n@template P The type of the props object\n@template T The type of the component or tag\n@example ```tsx\nconst element: ReactElement =
    ;\n```", + "tags": { + "template": "P The type of the props object\nT The type of the component or tag", + "example": "```tsx\nconst element: ReactElement =
    ;\n```" + } + }, + { + "value": "Iterable", + "description": "", + "fullComment": "", + "tags": {} + }, + { + "value": "ReactPortal", + "description": "", + "fullComment": "", + "tags": {} + }, + { + "value": "Promise", + "description": "Represents the completion of an asynchronous operation", + "fullComment": "Represents the completion of an asynchronous operation", + "tags": {} + } + ] + } + }, "cellComponent": { "defaultValue": null, "description": "React component responsible for rendering a cell.\n\nThis component will receive an `index` and `style` prop by default.\nAdditionally it will receive prop values passed to `cellProps`.\n\n⚠️ The prop types for this component are exported as `CellComponentProps`", @@ -394,6 +468,563 @@ } ] } + }, + "tagName": { + "defaultValue": { + "value": "\"div\" as TagName" + }, + "description": "Can be used to override the root HTML element rendered by the List component.\nThe default value is \"div\", meaning that List renders an HTMLDivElement as its root.\n\n⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed.", + "name": "tagName", + "declarations": [ + { + "fileName": "react-window/lib/components/grid/types.ts", + "name": "TypeLiteral" + } + ], + "required": false, + "type": { + "name": "enum", + "raw": "keyof IntrinsicElements | undefined", + "value": [ + { + "value": "undefined" + }, + { + "value": "\"symbol\"" + }, + { + "value": "\"object\"" + }, + { + "value": "\"slot\"" + }, + { + "value": "\"style\"" + }, + { + "value": "\"title\"" + }, + { + "value": "\"search\"" + }, + { + "value": "\"article\"" + }, + { + "value": "\"button\"" + }, + { + "value": "\"dialog\"" + }, + { + "value": "\"figure\"" + }, + { + "value": "\"form\"" + }, + { + "value": "\"img\"" + }, + { + "value": "\"link\"" + }, + { + "value": "\"main\"" + }, + { + "value": "\"menu\"" + }, + { + "value": "\"menuitem\"" + }, + { + "value": "\"option\"" + }, + { + "value": "\"switch\"" + }, + { + "value": "\"table\"" + }, + { + "value": "\"text\"" + }, + { + "value": "\"time\"" + }, + { + "value": "\"a\"" + }, + { + "value": "\"abbr\"" + }, + { + "value": "\"address\"" + }, + { + "value": "\"area\"" + }, + { + "value": "\"aside\"" + }, + { + "value": "\"audio\"" + }, + { + "value": "\"b\"" + }, + { + "value": "\"base\"" + }, + { + "value": "\"bdi\"" + }, + { + "value": "\"bdo\"" + }, + { + "value": "\"big\"" + }, + { + "value": "\"blockquote\"" + }, + { + "value": "\"body\"" + }, + { + "value": "\"br\"" + }, + { + "value": "\"canvas\"" + }, + { + "value": "\"caption\"" + }, + { + "value": "\"center\"" + }, + { + "value": "\"cite\"" + }, + { + "value": "\"code\"" + }, + { + "value": "\"col\"" + }, + { + "value": "\"colgroup\"" + }, + { + "value": "\"data\"" + }, + { + "value": "\"datalist\"" + }, + { + "value": "\"dd\"" + }, + { + "value": "\"del\"" + }, + { + "value": "\"details\"" + }, + { + "value": "\"dfn\"" + }, + { + "value": "\"div\"" + }, + { + "value": "\"dl\"" + }, + { + "value": "\"dt\"" + }, + { + "value": "\"em\"" + }, + { + "value": "\"embed\"" + }, + { + "value": "\"fieldset\"" + }, + { + "value": "\"figcaption\"" + }, + { + "value": "\"footer\"" + }, + { + "value": "\"h1\"" + }, + { + "value": "\"h2\"" + }, + { + "value": "\"h3\"" + }, + { + "value": "\"h4\"" + }, + { + "value": "\"h5\"" + }, + { + "value": "\"h6\"" + }, + { + "value": "\"head\"" + }, + { + "value": "\"header\"" + }, + { + "value": "\"hgroup\"" + }, + { + "value": "\"hr\"" + }, + { + "value": "\"html\"" + }, + { + "value": "\"i\"" + }, + { + "value": "\"iframe\"" + }, + { + "value": "\"input\"" + }, + { + "value": "\"ins\"" + }, + { + "value": "\"kbd\"" + }, + { + "value": "\"keygen\"" + }, + { + "value": "\"label\"" + }, + { + "value": "\"legend\"" + }, + { + "value": "\"li\"" + }, + { + "value": "\"map\"" + }, + { + "value": "\"mark\"" + }, + { + "value": "\"meta\"" + }, + { + "value": "\"meter\"" + }, + { + "value": "\"nav\"" + }, + { + "value": "\"noindex\"" + }, + { + "value": "\"noscript\"" + }, + { + "value": "\"ol\"" + }, + { + "value": "\"optgroup\"" + }, + { + "value": "\"output\"" + }, + { + "value": "\"p\"" + }, + { + "value": "\"param\"" + }, + { + "value": "\"picture\"" + }, + { + "value": "\"pre\"" + }, + { + "value": "\"progress\"" + }, + { + "value": "\"q\"" + }, + { + "value": "\"rp\"" + }, + { + "value": "\"rt\"" + }, + { + "value": "\"ruby\"" + }, + { + "value": "\"s\"" + }, + { + "value": "\"samp\"" + }, + { + "value": "\"script\"" + }, + { + "value": "\"section\"" + }, + { + "value": "\"select\"" + }, + { + "value": "\"small\"" + }, + { + "value": "\"source\"" + }, + { + "value": "\"span\"" + }, + { + "value": "\"strong\"" + }, + { + "value": "\"sub\"" + }, + { + "value": "\"summary\"" + }, + { + "value": "\"sup\"" + }, + { + "value": "\"template\"" + }, + { + "value": "\"tbody\"" + }, + { + "value": "\"td\"" + }, + { + "value": "\"textarea\"" + }, + { + "value": "\"tfoot\"" + }, + { + "value": "\"th\"" + }, + { + "value": "\"thead\"" + }, + { + "value": "\"tr\"" + }, + { + "value": "\"track\"" + }, + { + "value": "\"u\"" + }, + { + "value": "\"ul\"" + }, + { + "value": "\"var\"" + }, + { + "value": "\"video\"" + }, + { + "value": "\"wbr\"" + }, + { + "value": "\"webview\"" + }, + { + "value": "\"svg\"" + }, + { + "value": "\"animate\"" + }, + { + "value": "\"animateMotion\"" + }, + { + "value": "\"animateTransform\"" + }, + { + "value": "\"circle\"" + }, + { + "value": "\"clipPath\"" + }, + { + "value": "\"defs\"" + }, + { + "value": "\"desc\"" + }, + { + "value": "\"ellipse\"" + }, + { + "value": "\"feBlend\"" + }, + { + "value": "\"feColorMatrix\"" + }, + { + "value": "\"feComponentTransfer\"" + }, + { + "value": "\"feComposite\"" + }, + { + "value": "\"feConvolveMatrix\"" + }, + { + "value": "\"feDiffuseLighting\"" + }, + { + "value": "\"feDisplacementMap\"" + }, + { + "value": "\"feDistantLight\"" + }, + { + "value": "\"feDropShadow\"" + }, + { + "value": "\"feFlood\"" + }, + { + "value": "\"feFuncA\"" + }, + { + "value": "\"feFuncB\"" + }, + { + "value": "\"feFuncG\"" + }, + { + "value": "\"feFuncR\"" + }, + { + "value": "\"feGaussianBlur\"" + }, + { + "value": "\"feImage\"" + }, + { + "value": "\"feMerge\"" + }, + { + "value": "\"feMergeNode\"" + }, + { + "value": "\"feMorphology\"" + }, + { + "value": "\"feOffset\"" + }, + { + "value": "\"fePointLight\"" + }, + { + "value": "\"feSpecularLighting\"" + }, + { + "value": "\"feSpotLight\"" + }, + { + "value": "\"feTile\"" + }, + { + "value": "\"feTurbulence\"" + }, + { + "value": "\"filter\"" + }, + { + "value": "\"foreignObject\"" + }, + { + "value": "\"g\"" + }, + { + "value": "\"image\"" + }, + { + "value": "\"line\"" + }, + { + "value": "\"linearGradient\"" + }, + { + "value": "\"marker\"" + }, + { + "value": "\"mask\"" + }, + { + "value": "\"metadata\"" + }, + { + "value": "\"mpath\"" + }, + { + "value": "\"path\"" + }, + { + "value": "\"pattern\"" + }, + { + "value": "\"polygon\"" + }, + { + "value": "\"polyline\"" + }, + { + "value": "\"radialGradient\"" + }, + { + "value": "\"rect\"" + }, + { + "value": "\"set\"" + }, + { + "value": "\"stop\"" + }, + { + "value": "\"textPath\"" + }, + { + "value": "\"tspan\"" + }, + { + "value": "\"use\"" + }, + { + "value": "\"view\"" + } + ] + } } } } \ No newline at end of file diff --git a/public/generated/js-docs/List.json b/public/generated/js-docs/List.json index f69194c2..96a1649c 100644 --- a/public/generated/js-docs/List.json +++ b/public/generated/js-docs/List.json @@ -72,6 +72,80 @@ ] } }, + "children": { + "defaultValue": null, + "description": "Additional content to be rendered within the list (above cells).\nThis property can be used to render things like overlays or tooltips.", + "name": "children", + "parent": { + "fileName": "react-window/node_modules/.pnpm/@types+react@19.1.8/node_modules/@types/react/index.d.ts", + "name": "DOMAttributes" + }, + "declarations": [ + { + "fileName": "react-window/node_modules/.pnpm/@types+react@19.1.8/node_modules/@types/react/index.d.ts", + "name": "DOMAttributes" + }, + { + "fileName": "react-window/lib/components/list/types.ts", + "name": "TypeLiteral" + } + ], + "required": false, + "type": { + "name": "enum", + "raw": "ReactNode", + "value": [ + { + "value": "undefined" + }, + { + "value": "null" + }, + { + "value": "string" + }, + { + "value": "number" + }, + { + "value": "bigint" + }, + { + "value": "false" + }, + { + "value": "true" + }, + { + "value": "ReactElement>", + "description": "Represents a JSX element.\n\nWhere {@link ReactNode} represents everything that can be rendered, `ReactElement`\nonly represents JSX.", + "fullComment": "Represents a JSX element.\n\nWhere {@link ReactNode} represents everything that can be rendered, `ReactElement`\nonly represents JSX.\n@template P The type of the props object\n@template T The type of the component or tag\n@example ```tsx\nconst element: ReactElement =
    ;\n```", + "tags": { + "template": "P The type of the props object\nT The type of the component or tag", + "example": "```tsx\nconst element: ReactElement =
    ;\n```" + } + }, + { + "value": "Iterable", + "description": "", + "fullComment": "", + "tags": {} + }, + { + "value": "ReactPortal", + "description": "", + "fullComment": "", + "tags": {} + }, + { + "value": "Promise", + "description": "Represents the completion of an asynchronous operation", + "fullComment": "Represents the completion of an asynchronous operation", + "tags": {} + } + ] + } + }, "defaultHeight": { "defaultValue": { "value": "0" @@ -291,6 +365,563 @@ "type": { "name": "ExcludeForbiddenKeys" } + }, + "tagName": { + "defaultValue": { + "value": "\"div\" as TagName" + }, + "description": "Can be used to override the root HTML element rendered by the List component.\nThe default value is \"div\", meaning that List renders an HTMLDivElement as its root.\n\n⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed.", + "name": "tagName", + "declarations": [ + { + "fileName": "react-window/lib/components/list/types.ts", + "name": "TypeLiteral" + } + ], + "required": false, + "type": { + "name": "enum", + "raw": "keyof IntrinsicElements | undefined", + "value": [ + { + "value": "undefined" + }, + { + "value": "\"symbol\"" + }, + { + "value": "\"object\"" + }, + { + "value": "\"slot\"" + }, + { + "value": "\"style\"" + }, + { + "value": "\"title\"" + }, + { + "value": "\"search\"" + }, + { + "value": "\"article\"" + }, + { + "value": "\"button\"" + }, + { + "value": "\"dialog\"" + }, + { + "value": "\"figure\"" + }, + { + "value": "\"form\"" + }, + { + "value": "\"img\"" + }, + { + "value": "\"link\"" + }, + { + "value": "\"main\"" + }, + { + "value": "\"menu\"" + }, + { + "value": "\"menuitem\"" + }, + { + "value": "\"option\"" + }, + { + "value": "\"switch\"" + }, + { + "value": "\"table\"" + }, + { + "value": "\"text\"" + }, + { + "value": "\"time\"" + }, + { + "value": "\"a\"" + }, + { + "value": "\"abbr\"" + }, + { + "value": "\"address\"" + }, + { + "value": "\"area\"" + }, + { + "value": "\"aside\"" + }, + { + "value": "\"audio\"" + }, + { + "value": "\"b\"" + }, + { + "value": "\"base\"" + }, + { + "value": "\"bdi\"" + }, + { + "value": "\"bdo\"" + }, + { + "value": "\"big\"" + }, + { + "value": "\"blockquote\"" + }, + { + "value": "\"body\"" + }, + { + "value": "\"br\"" + }, + { + "value": "\"canvas\"" + }, + { + "value": "\"caption\"" + }, + { + "value": "\"center\"" + }, + { + "value": "\"cite\"" + }, + { + "value": "\"code\"" + }, + { + "value": "\"col\"" + }, + { + "value": "\"colgroup\"" + }, + { + "value": "\"data\"" + }, + { + "value": "\"datalist\"" + }, + { + "value": "\"dd\"" + }, + { + "value": "\"del\"" + }, + { + "value": "\"details\"" + }, + { + "value": "\"dfn\"" + }, + { + "value": "\"div\"" + }, + { + "value": "\"dl\"" + }, + { + "value": "\"dt\"" + }, + { + "value": "\"em\"" + }, + { + "value": "\"embed\"" + }, + { + "value": "\"fieldset\"" + }, + { + "value": "\"figcaption\"" + }, + { + "value": "\"footer\"" + }, + { + "value": "\"h1\"" + }, + { + "value": "\"h2\"" + }, + { + "value": "\"h3\"" + }, + { + "value": "\"h4\"" + }, + { + "value": "\"h5\"" + }, + { + "value": "\"h6\"" + }, + { + "value": "\"head\"" + }, + { + "value": "\"header\"" + }, + { + "value": "\"hgroup\"" + }, + { + "value": "\"hr\"" + }, + { + "value": "\"html\"" + }, + { + "value": "\"i\"" + }, + { + "value": "\"iframe\"" + }, + { + "value": "\"input\"" + }, + { + "value": "\"ins\"" + }, + { + "value": "\"kbd\"" + }, + { + "value": "\"keygen\"" + }, + { + "value": "\"label\"" + }, + { + "value": "\"legend\"" + }, + { + "value": "\"li\"" + }, + { + "value": "\"map\"" + }, + { + "value": "\"mark\"" + }, + { + "value": "\"meta\"" + }, + { + "value": "\"meter\"" + }, + { + "value": "\"nav\"" + }, + { + "value": "\"noindex\"" + }, + { + "value": "\"noscript\"" + }, + { + "value": "\"ol\"" + }, + { + "value": "\"optgroup\"" + }, + { + "value": "\"output\"" + }, + { + "value": "\"p\"" + }, + { + "value": "\"param\"" + }, + { + "value": "\"picture\"" + }, + { + "value": "\"pre\"" + }, + { + "value": "\"progress\"" + }, + { + "value": "\"q\"" + }, + { + "value": "\"rp\"" + }, + { + "value": "\"rt\"" + }, + { + "value": "\"ruby\"" + }, + { + "value": "\"s\"" + }, + { + "value": "\"samp\"" + }, + { + "value": "\"script\"" + }, + { + "value": "\"section\"" + }, + { + "value": "\"select\"" + }, + { + "value": "\"small\"" + }, + { + "value": "\"source\"" + }, + { + "value": "\"span\"" + }, + { + "value": "\"strong\"" + }, + { + "value": "\"sub\"" + }, + { + "value": "\"summary\"" + }, + { + "value": "\"sup\"" + }, + { + "value": "\"template\"" + }, + { + "value": "\"tbody\"" + }, + { + "value": "\"td\"" + }, + { + "value": "\"textarea\"" + }, + { + "value": "\"tfoot\"" + }, + { + "value": "\"th\"" + }, + { + "value": "\"thead\"" + }, + { + "value": "\"tr\"" + }, + { + "value": "\"track\"" + }, + { + "value": "\"u\"" + }, + { + "value": "\"ul\"" + }, + { + "value": "\"var\"" + }, + { + "value": "\"video\"" + }, + { + "value": "\"wbr\"" + }, + { + "value": "\"webview\"" + }, + { + "value": "\"svg\"" + }, + { + "value": "\"animate\"" + }, + { + "value": "\"animateMotion\"" + }, + { + "value": "\"animateTransform\"" + }, + { + "value": "\"circle\"" + }, + { + "value": "\"clipPath\"" + }, + { + "value": "\"defs\"" + }, + { + "value": "\"desc\"" + }, + { + "value": "\"ellipse\"" + }, + { + "value": "\"feBlend\"" + }, + { + "value": "\"feColorMatrix\"" + }, + { + "value": "\"feComponentTransfer\"" + }, + { + "value": "\"feComposite\"" + }, + { + "value": "\"feConvolveMatrix\"" + }, + { + "value": "\"feDiffuseLighting\"" + }, + { + "value": "\"feDisplacementMap\"" + }, + { + "value": "\"feDistantLight\"" + }, + { + "value": "\"feDropShadow\"" + }, + { + "value": "\"feFlood\"" + }, + { + "value": "\"feFuncA\"" + }, + { + "value": "\"feFuncB\"" + }, + { + "value": "\"feFuncG\"" + }, + { + "value": "\"feFuncR\"" + }, + { + "value": "\"feGaussianBlur\"" + }, + { + "value": "\"feImage\"" + }, + { + "value": "\"feMerge\"" + }, + { + "value": "\"feMergeNode\"" + }, + { + "value": "\"feMorphology\"" + }, + { + "value": "\"feOffset\"" + }, + { + "value": "\"fePointLight\"" + }, + { + "value": "\"feSpecularLighting\"" + }, + { + "value": "\"feSpotLight\"" + }, + { + "value": "\"feTile\"" + }, + { + "value": "\"feTurbulence\"" + }, + { + "value": "\"filter\"" + }, + { + "value": "\"foreignObject\"" + }, + { + "value": "\"g\"" + }, + { + "value": "\"image\"" + }, + { + "value": "\"line\"" + }, + { + "value": "\"linearGradient\"" + }, + { + "value": "\"marker\"" + }, + { + "value": "\"mask\"" + }, + { + "value": "\"metadata\"" + }, + { + "value": "\"mpath\"" + }, + { + "value": "\"path\"" + }, + { + "value": "\"pattern\"" + }, + { + "value": "\"polygon\"" + }, + { + "value": "\"polyline\"" + }, + { + "value": "\"radialGradient\"" + }, + { + "value": "\"rect\"" + }, + { + "value": "\"set\"" + }, + { + "value": "\"stop\"" + }, + { + "value": "\"textPath\"" + }, + { + "value": "\"tspan\"" + }, + { + "value": "\"use\"" + }, + { + "value": "\"view\"" + } + ] + } } } } \ No newline at end of file diff --git a/src/nav/Nav.tsx b/src/nav/Nav.tsx index 02e30c83..38b45002 100644 --- a/src/nav/Nav.tsx +++ b/src/nav/Nav.tsx @@ -25,6 +25,7 @@ export function Nav() { Right to left content Horizontal lists + Sticky rows
    Requirements diff --git a/src/routes.ts b/src/routes.ts index 4b6b3ea2..04019983 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -24,6 +24,7 @@ export const routes = { "/list/tabular-data-aria-roles": lazy( () => import("./routes/tables/AriaRolesRoute") ), + "/list/sticky-rows": lazy(() => import("./routes/list/StickyRowsRoute")), // SimpleGrid "/grid/grid": lazy(() => import("./routes/grid/RenderingGridRoute")), diff --git a/src/routes/list/StickyRowsRoute.tsx b/src/routes/list/StickyRowsRoute.tsx new file mode 100644 index 00000000..d9615771 --- /dev/null +++ b/src/routes/list/StickyRowsRoute.tsx @@ -0,0 +1,38 @@ +import ListWithStickyRowsMarkdown from "../../../public/generated/code-snippets/ListWithStickyRows.json"; +import { Block } from "../../components/Block"; +import { Box } from "../../components/Box"; +import { Callout } from "../../components/Callout"; +import { FormattedCode } from "../../components/code/FormattedCode"; +import { ExternalLink } from "../../components/ExternalLink"; +import { Header } from "../../components/Header"; +import { Example } from "./examples/ListWithStickyRows.example"; + +export default function StickyRowsRoute() { + return ( + +
    +
    + If you want to render content on top of your list or grid, the safest + method is to use a{" "} + + portal + {" "} + and render them directly into the parent document. This avoids potential + clipping issues or z-index conflicts. +
    +
    + For the specific case of "sticky" rows, you can render within the parent + list or grid using the children prop: +
    + + + +
    The example above was created using code like this:
    + + + Note the height of 0 in the example above prevents the + sticky row from affecting the height of the parent list. + + + ); +} diff --git a/src/routes/list/examples/FixedHeightList.example.tsx b/src/routes/list/examples/FixedHeightList.example.tsx index 99ff3007..67c5f191 100644 --- a/src/routes/list/examples/FixedHeightList.example.tsx +++ b/src/routes/list/examples/FixedHeightList.example.tsx @@ -11,6 +11,7 @@ function Example({ names }: { names: string[] }) { rowCount={names.length} rowHeight={25} rowProps={{ names }} + tagName="ul" /> ); } diff --git a/src/routes/list/examples/FixedHeightRowComponent.example.tsx b/src/routes/list/examples/FixedHeightRowComponent.example.tsx index da9a68a3..ce390129 100644 --- a/src/routes/list/examples/FixedHeightRowComponent.example.tsx +++ b/src/routes/list/examples/FixedHeightRowComponent.example.tsx @@ -8,10 +8,10 @@ function RowComponent({ names: string[]; }>) { return ( -
    +
  • {names[index]}
    {`${index + 1} of ${names.length}`}
    -
  • + ); } diff --git a/src/routes/list/examples/ListWithStickyRows.example.tsx b/src/routes/list/examples/ListWithStickyRows.example.tsx new file mode 100644 index 00000000..2e350685 --- /dev/null +++ b/src/routes/list/examples/ListWithStickyRows.example.tsx @@ -0,0 +1,32 @@ +import { EMPTY_OBJECT } from "../../../constants"; + +function RowComponent({ index, style }: RowComponentProps) { + if (index === 0) { + return
    ; + } + + return
    Row {index}
    ; +} + +// + +import { List, type RowComponentProps } from "react-window"; + +function Example() { + return ( + +
    +
    Sticky header
    +
    +
    + ); +} + +// + +export { Example }; diff --git a/tsconfig.json b/tsconfig.json index 0c1341c6..3a9a17fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,7 @@ "react-window": ["./lib"] }, "types": [ + "csstype", "vitest/globals", "@testing-library/jest-dom", "@testing-library/jest-dom/vitest" From 8bce7f555b1ff09879787cd86a7b948925815974 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 6 Sep 2025 13:32:48 -0400 Subject: [PATCH 3/4] onRowsRendered/onCellsRendered separate visible and overscan items --- CHANGELOG.md | 48 +++- lib/components/grid/Grid.test.tsx | 329 +++++++++++++++++++++++++-- lib/components/grid/Grid.tsx | 70 ++++-- lib/components/grid/types.ts | 20 +- lib/components/list/List.test.tsx | 86 +++++-- lib/components/list/List.tsx | 45 +++- lib/components/list/types.ts | 5 +- lib/core/getStartStopIndices.test.ts | 77 ++++++- lib/core/getStartStopIndices.ts | 35 ++- lib/core/useVirtualizer.test.ts | 25 +- lib/core/useVirtualizer.ts | 38 +++- lib/utils/areArraysEqual.ts | 13 ++ package.json | 2 +- public/generated/js-docs/Grid.json | 4 +- public/generated/js-docs/List.json | 4 +- 15 files changed, 674 insertions(+), 127 deletions(-) create mode 100644 lib/utils/areArraysEqual.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45421182..239ddacf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,18 @@ # Changelog -## 2.0.3 +## 2.1.0 -Additional `ariaAttributes` prop pass to row and cell renderers. Suggested usage is as follows: +Improved ARIA support: + +- Add better default ARIA attributes for outer `HTMLDivElement` +- Add optional `ariaAttributes` prop to row and cell renderers to simplify better ARIA attributes for user-rendered cells +- Remove intermediate `HTMLDivElement` from `List` and `Grid` + - This may enable more/better custom CSS styling + - This may also enable adding an optional `children` prop to `List` and `Grid` for e.g. overlays/tooltips +- Add optional `tagName` prop; defaults to `"div"` but can be changed to e.g. `"ul"` ```tsx +// Example of how to use new `ariaAttributes` prop function RowComponent({ ariaAttributes, index, @@ -19,6 +27,42 @@ function RowComponent({ } ``` +Added optional `children` prop to better support edge cases like sticky rows. + +Minor changes to `onRowsRendered` and `onCellsRendered` callbacks to make it easier to differentiate between _visible_ items and items rendered due to overscan settings. These methods will now receive two params– the first for _visible_ rows and the second for _all_ rows (including overscan), e.g.: + +```ts +function onRowsRendered( + visibleRows: { + startIndex: number; + stopIndex: number; + }, + allRows: { + startIndex: number; + stopIndex: number; + } +): void { + // ... +} + +function onCellsRendered( + visibleCells: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + }, + allCells: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + } +): void { + // ... +} +``` + ## 2.0.2 Fixed edge-case bug with `Grid` imperative API `scrollToCell` method and "smooth" scrolling behavior. diff --git a/lib/components/grid/Grid.test.tsx b/lib/components/grid/Grid.test.tsx index ef5cad82..fbc75d02 100644 --- a/lib/components/grid/Grid.test.tsx +++ b/lib/components/grid/Grid.test.tsx @@ -2,9 +2,13 @@ import { render, screen } from "@testing-library/react"; import { createRef, useLayoutEffect } from "react"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { EMPTY_OBJECT } from "../../../src/constants"; -import { updateMockResizeObserver } from "../../utils/test/mockResizeObserver"; +import { + disableForCurrentTest, + updateMockResizeObserver +} from "../../utils/test/mockResizeObserver"; import { Grid } from "./Grid"; import type { CellComponentProps, GridImperativeAPI } from "./types"; +import { useGridCallbackRef } from "./useGridCallbackRef"; describe("Grid", () => { let mountedCells: Map> = new Map(); @@ -127,36 +131,220 @@ describe("Grid", () => { }); }); - test.skip("should pass cellProps to the cellComponent", () => { - // TODO + test("should pass cellProps to the cellComponent", () => { + render( + + ); + + expect(mountedCells.size).toEqual(8); + expect(mountedCells.get("0,0")).toMatchObject({ + foo: "abc", + bar: 123 + }); }); - test.skip("should re-render items if cellComponent changes", () => { - // TODO + test("should re-render items if cellComponent changes", () => { + const { rerender } = render( + + ); + + const NewCellComponent = vi.fn(() => null); + + rerender( + + ); + + expect(NewCellComponent).toHaveBeenCalled(); }); - test.skip("should re-render items if cell size changes", () => { - // TODO + test("should re-render items if cell size changes", () => { + const { rerender } = render( + + ); + expect(mountedCells).toHaveLength(8); + + rerender( + + ); + expect(mountedCells).toHaveLength(4); }); - test.skip("should re-render items if cellProps change", () => { - // TODO + test("should re-render items if cellProps change", () => { + const { rerender } = render( + + ); + expect(mountedCells).toHaveLength(4); + expect(mountedCells.get("0,0")).toMatchObject({ + foo: "abc" + }); + + rerender( + + ); + expect(mountedCells).toHaveLength(4); + expect(mountedCells.get("0,0")).toMatchObject({ + bar: 123 + }); }); - test.skip("should use default sizes for initial mount", () => { + test("should use default sizes for initial mount", () => { + // Mimic server rendering + disableForCurrentTest(); + + render( + + ); + + const items = screen.queryAllByRole("gridcell"); + expect(items).toHaveLength(8); // TODO }); - test.skip("should call onCellsRendered", () => { - // TODO + test("should call onCellsRendered", () => { + const onCellsRendered = vi.fn(); + + render( + + ); + + expect(onCellsRendered).toHaveBeenCalled(); + expect(onCellsRendered).toHaveBeenLastCalledWith( + { + columnStartIndex: 0, + columnStopIndex: 3, + rowStartIndex: 0, + rowStopIndex: 1 + }, + { + columnStartIndex: 0, + columnStopIndex: 5, + rowStartIndex: 0, + rowStopIndex: 3 + } + ); }); - test.skip("should support custom className and style props", () => { - // TODO + test("should support custom className and style props", () => { + render( + + ); + + const grid = screen.queryByRole("grid"); + expect(grid).toHaveClass("foo"); + expect(grid?.style.backgroundColor).toBe("red"); }); - test.skip("should spread HTML rest attributes", () => { - // TODO + test("should spread HTML rest attributes", () => { + render( + + ); + + expect(screen.queryByTestId("foo")).toHaveRole("grid"); }); test("custom tagName and attributes", () => { @@ -202,8 +390,23 @@ describe("Grid", () => { }); describe("imperative API", () => { - test.skip("should return the root element", () => { - // TODO + test("should return the root element", () => { + const gridRef = createRef(); + + render( + + ); + + expect(gridRef.current?.element).toEqual(screen.queryByRole("grid")); }); test("should scroll to cell", () => { @@ -286,13 +489,95 @@ describe("Grid", () => { }); }); - test.skip("should auto-memoize cellProps object using shallow equality", () => { - // TODO + test("should auto-memoize cellProps object using shallow equality", () => { + const { rerender } = render( + + ); + + expect(mountedCells).toHaveLength(8); + expect(mountedCells.get("0,0")).toMatchObject({ + foo: "abc", + abc: 123 + }); + + expect(CellComponent).toHaveBeenCalledTimes(8); + + rerender( + + ); + expect(CellComponent).toHaveBeenCalledTimes(8); + + rerender( + + ); + expect(CellComponent).toHaveBeenCalledTimes(16); }); describe("edge cases", () => { - test.skip("should not cause a cycle of Grid callback ref is passed in cellProps", () => { - // TODO + test("should not cause a cycle of Grid callback ref is passed in cellProps", () => { + function CellComponentWithCellProps({ + columnIndex, + rowIndex, + style + }: CellComponentProps<{ gridRef: GridImperativeAPI | null }>) { + return ( +
    + {rowIndex},{columnIndex} +
    + ); + } + + function Test() { + const [gridRef, setGridRef] = useGridCallbackRef(null); + + return ( + + ); + } + + render(); }); }); diff --git a/lib/components/grid/Grid.tsx b/lib/components/grid/Grid.tsx index 2a5983a9..8fbe3dcd 100644 --- a/lib/components/grid/Grid.tsx +++ b/lib/components/grid/Grid.tsx @@ -50,9 +50,11 @@ export function Grid< const { getCellBounds: getColumnBounds, getEstimatedSize: getEstimatedWidth, - startIndex: columnStartIndex, + startIndexOverscan: columnStartIndexOverscan, + startIndexVisible: columnStartIndexVisible, scrollToIndex: scrollToColumnIndex, - stopIndex: columnStopIndex + stopIndexOverscan: columnStopIndexOverscan, + stopIndexVisible: columnStopIndexVisible } = useVirtualizer({ containerElement: element, defaultContainerSize: defaultWidth, @@ -68,9 +70,11 @@ export function Grid< const { getCellBounds: getRowBounds, getEstimatedSize: getEstimatedHeight, - startIndex: rowStartIndex, + startIndexOverscan: rowStartIndexOverscan, + startIndexVisible: rowStartIndexVisible, scrollToIndex: scrollToRowIndex, - stopIndex: rowStopIndex + stopIndexOverscan: rowStopIndexOverscan, + stopIndexVisible: rowStopIndexVisible } = useVirtualizer({ containerElement: element, defaultContainerSize: defaultHeight, @@ -173,38 +177,54 @@ export function Grid< useEffect(() => { if ( - columnStartIndex >= 0 && - columnStopIndex >= 0 && - rowStartIndex >= 0 && - rowStopIndex >= 0 && + columnStartIndexOverscan >= 0 && + columnStopIndexOverscan >= 0 && + rowStartIndexOverscan >= 0 && + rowStopIndexOverscan >= 0 && onCellsRendered ) { - onCellsRendered({ - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex - }); + onCellsRendered( + { + columnStartIndex: columnStartIndexVisible, + columnStopIndex: columnStopIndexVisible, + rowStartIndex: rowStartIndexVisible, + rowStopIndex: rowStopIndexVisible + }, + { + columnStartIndex: columnStartIndexOverscan, + columnStopIndex: columnStopIndexOverscan, + rowStartIndex: rowStartIndexOverscan, + rowStopIndex: rowStopIndexOverscan + } + ); } }, [ onCellsRendered, - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex + columnStartIndexOverscan, + columnStartIndexVisible, + columnStopIndexOverscan, + columnStopIndexVisible, + rowStartIndexOverscan, + rowStartIndexVisible, + rowStopIndexOverscan, + rowStopIndexVisible ]); const cells = useMemo(() => { const children: ReactNode[] = []; if (columnCount > 0 && rowCount > 0) { - for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { + for ( + let rowIndex = rowStartIndexOverscan; + rowIndex <= rowStopIndexOverscan; + rowIndex++ + ) { const rowBounds = getRowBounds(rowIndex); const columns: ReactNode[] = []; for ( - let columnIndex = columnStartIndex; - columnIndex <= columnStopIndex; + let columnIndex = columnStartIndexOverscan; + columnIndex <= columnStopIndexOverscan; columnIndex++ ) { const columnBounds = getColumnBounds(columnIndex); @@ -243,14 +263,14 @@ export function Grid< CellComponent, cellProps, columnCount, - columnStartIndex, - columnStopIndex, + columnStartIndexOverscan, + columnStopIndexOverscan, getColumnBounds, getRowBounds, isRtl, rowCount, - rowStartIndex, - rowStopIndex + rowStartIndexOverscan, + rowStopIndexOverscan ]); const sizingElement = ( diff --git a/lib/components/grid/types.ts b/lib/components/grid/types.ts index 531e49e3..b4252449 100644 --- a/lib/components/grid/types.ts +++ b/lib/components/grid/types.ts @@ -101,12 +101,20 @@ export type GridProps< /** * Callback notified when the range of rendered cells changes. */ - onCellsRendered?: (args: { - columnStartIndex: number; - columnStopIndex: number; - rowStartIndex: number; - rowStopIndex: number; - }) => void; + onCellsRendered?: ( + visibleCells: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + }, + allCells: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + } + ) => void; /** * Callback notified when the Grid's outermost HTMLElement resizes. diff --git a/lib/components/list/List.test.tsx b/lib/components/list/List.test.tsx index 6e857664..9317ba89 100644 --- a/lib/components/list/List.test.tsx +++ b/lib/components/list/List.test.tsx @@ -162,18 +162,18 @@ describe("List", () => { /> ); - const NewRow = vi.fn(() => null); + const NewRowComponent = vi.fn(() => null); rerender( ); - expect(NewRow).toHaveBeenCalled(); + expect(NewRowComponent).toHaveBeenCalled(); }); test("should re-render items if rowHeight changes", () => { @@ -270,14 +270,20 @@ describe("List", () => { /> ); expect(onRowsRendered).toHaveBeenCalledTimes(1); - expect(onRowsRendered).toHaveBeenLastCalledWith({ - startIndex: 0, - stopIndex: 1 - }); + expect(onRowsRendered).toHaveBeenLastCalledWith( + { + startIndex: 0, + stopIndex: 1 + }, + { + startIndex: 0, + stopIndex: 1 + } + ); rerender( { /> ); expect(onRowsRendered).toHaveBeenCalledTimes(2); - expect(onRowsRendered).toHaveBeenLastCalledWith({ - startIndex: 0, - stopIndex: 3 - }); + expect(onRowsRendered).toHaveBeenLastCalledWith( + { + startIndex: 0, + stopIndex: 3 + }, + { + startIndex: 0, + stopIndex: 3 + } + ); + + rerender( + + ); + expect(onRowsRendered).toHaveBeenCalledTimes(3); + expect(onRowsRendered).toHaveBeenLastCalledWith( + { + startIndex: 0, + stopIndex: 3 + }, + { + startIndex: 0, + stopIndex: 5 + } + ); }); test("should support custom className and style props", () => { @@ -526,10 +560,16 @@ describe("List", () => { ); expect(onRowsRendered).toHaveBeenCalled(); - expect(onRowsRendered).toHaveBeenLastCalledWith({ - startIndex: 0, - stopIndex: 3 - }); + expect(onRowsRendered).toHaveBeenLastCalledWith( + { + startIndex: 0, + stopIndex: 3 + }, + { + startIndex: 0, + stopIndex: 3 + } + ); onRowsRendered.mockReset(); @@ -537,10 +577,16 @@ describe("List", () => { listRef.current?.scrollToRow({ index: 10 }); }); expect(onRowsRendered).toHaveBeenCalledTimes(1); - expect(onRowsRendered).toHaveBeenLastCalledWith({ - startIndex: 7, - stopIndex: 10 - }); + expect(onRowsRendered).toHaveBeenLastCalledWith( + { + startIndex: 7, + stopIndex: 10 + }, + { + startIndex: 7, + stopIndex: 10 + } + ); expect(RowComponent).toHaveBeenLastCalledWith( expect.objectContaining({ diff --git a/lib/components/list/List.tsx b/lib/components/list/List.tsx index c8d07d2e..11f01bce 100644 --- a/lib/components/list/List.tsx +++ b/lib/components/list/List.tsx @@ -44,8 +44,10 @@ export function List< getCellBounds, getEstimatedSize, scrollToIndex, - startIndex, - stopIndex + startIndexOverscan, + startIndexVisible, + stopIndexOverscan, + stopIndexVisible } = useVirtualizer({ containerElement: element, defaultContainerSize: defaultHeight, @@ -91,18 +93,34 @@ export function List< ); useEffect(() => { - if (startIndex >= 0 && stopIndex >= 0 && onRowsRendered) { - onRowsRendered({ - startIndex, - stopIndex - }); + if (startIndexOverscan >= 0 && stopIndexOverscan >= 0 && onRowsRendered) { + onRowsRendered( + { + startIndex: startIndexVisible, + stopIndex: stopIndexVisible + }, + { + startIndex: startIndexOverscan, + stopIndex: stopIndexOverscan + } + ); } - }, [onRowsRendered, startIndex, stopIndex]); + }, [ + onRowsRendered, + startIndexOverscan, + startIndexVisible, + stopIndexOverscan, + stopIndexVisible + ]); const rows = useMemo(() => { const children: ReactNode[] = []; if (rowCount > 0) { - for (let index = startIndex; index <= stopIndex; index++) { + for ( + let index = startIndexOverscan; + index <= stopIndexOverscan; + index++ + ) { const bounds = getCellBounds(index); children.push( @@ -127,7 +145,14 @@ export function List< } } return children; - }, [RowComponent, getCellBounds, rowCount, rowProps, startIndex, stopIndex]); + }, [ + RowComponent, + getCellBounds, + rowCount, + rowProps, + startIndexOverscan, + stopIndexOverscan + ]); const sizingElement = (
    void; + onRowsRendered?: ( + visibleRows: { startIndex: number; stopIndex: number }, + allRows: { startIndex: number; stopIndex: number } + ) => void; /** * How many additional rows to render outside of the visible area. diff --git a/lib/core/getStartStopIndices.test.ts b/lib/core/getStartStopIndices.test.ts index 77ce20df..17a60e8f 100644 --- a/lib/core/getStartStopIndices.test.ts +++ b/lib/core/getStartStopIndices.test.ts @@ -39,7 +39,12 @@ describe("getStartStopIndices", () => { itemCount: 0, itemSize: 25 }) - ).toEqual([0, -1]); + ).toEqual({ + startIndexVisible: 0, + startIndexOverscan: 0, + stopIndexVisible: -1, + stopIndexOverscan: -1 + }); }); test("edge case: not enough rows to fill available height", () => { @@ -50,7 +55,12 @@ describe("getStartStopIndices", () => { itemCount: 2, itemSize: 25 }) - ).toEqual([0, 1]); + ).toEqual({ + startIndexVisible: 0, + startIndexOverscan: 0, + stopIndexVisible: 1, + stopIndexOverscan: 1 + }); }); test("initial set of rows", () => { @@ -61,7 +71,12 @@ describe("getStartStopIndices", () => { itemCount: 10, itemSize: 25 }) - ).toEqual([0, 3]); + ).toEqual({ + startIndexVisible: 0, + startIndexOverscan: 0, + stopIndexVisible: 3, + stopIndexOverscan: 3 + }); }); test("middle set of list", () => { @@ -72,7 +87,12 @@ describe("getStartStopIndices", () => { itemCount: 10, itemSize: 25 }) - ).toEqual([4, 7]); + ).toEqual({ + startIndexVisible: 4, + startIndexOverscan: 4, + stopIndexVisible: 7, + stopIndexOverscan: 7 + }); }); test("final set of rows", () => { @@ -83,7 +103,12 @@ describe("getStartStopIndices", () => { itemCount: 10, itemSize: 25 }) - ).toEqual([6, 9]); + ).toEqual({ + startIndexVisible: 6, + startIndexOverscan: 6, + stopIndexVisible: 9, + stopIndexOverscan: 9 + }); }); test("should not under-scroll", () => { @@ -94,7 +119,12 @@ describe("getStartStopIndices", () => { itemCount: 10, itemSize: 25 }) - ).toEqual([0, 1]); + ).toEqual({ + startIndexVisible: 0, + startIndexOverscan: 0, + stopIndexVisible: 1, + stopIndexOverscan: 1 + }); }); test("should not over-scroll", () => { @@ -105,7 +135,12 @@ describe("getStartStopIndices", () => { itemCount: 10, itemSize: 25 }) - ).toEqual([8, 9]); + ).toEqual({ + startIndexVisible: 8, + startIndexOverscan: 8, + stopIndexVisible: 9, + stopIndexOverscan: 9 + }); }); describe("with overscan", () => { @@ -118,7 +153,12 @@ describe("getStartStopIndices", () => { itemSize: 25, overscanCount: 2 }) - ).toEqual([0, 1]); + ).toEqual({ + startIndexVisible: 0, + startIndexOverscan: 0, + stopIndexVisible: 1, + stopIndexOverscan: 1 + }); }); test("edge case: no rows before", () => { @@ -130,7 +170,12 @@ describe("getStartStopIndices", () => { itemSize: 25, overscanCount: 2 }) - ).toEqual([0, 5]); + ).toEqual({ + startIndexVisible: 0, + startIndexOverscan: 0, + stopIndexVisible: 3, + stopIndexOverscan: 5 + }); }); test("edge case: no rows after", () => { @@ -142,7 +187,12 @@ describe("getStartStopIndices", () => { itemSize: 25, overscanCount: 2 }) - ).toEqual([94, 99]); + ).toEqual({ + startIndexVisible: 96, + startIndexOverscan: 94, + stopIndexVisible: 99, + stopIndexOverscan: 99 + }); }); test("rows before and after", () => { @@ -154,7 +204,12 @@ describe("getStartStopIndices", () => { itemSize: 25, overscanCount: 2 }) - ).toEqual([2, 9]); + ).toEqual({ + startIndexVisible: 4, + startIndexOverscan: 2, + stopIndexVisible: 7, + stopIndexOverscan: 9 + }); }); }); }); diff --git a/lib/core/getStartStopIndices.ts b/lib/core/getStartStopIndices.ts index 60abee45..c38d84a3 100644 --- a/lib/core/getStartStopIndices.ts +++ b/lib/core/getStartStopIndices.ts @@ -12,11 +12,18 @@ export function getStartStopIndices({ containerSize: number; itemCount: number; overscanCount: number; -}): [number, number] { +}): { + startIndexVisible: number; + stopIndexVisible: number; + startIndexOverscan: number; + stopIndexOverscan: number; +} { const maxIndex = itemCount - 1; - let startIndex = 0; - let stopIndex = -1; + let startIndexVisible = 0; + let stopIndexVisible = -1; + let startIndexOverscan = 0; + let stopIndexOverscan = -1; let currentIndex = 0; while (currentIndex < maxIndex) { @@ -29,8 +36,8 @@ export function getStartStopIndices({ currentIndex++; } - startIndex = currentIndex; - startIndex = Math.max(0, startIndex - overscanCount); + startIndexVisible = currentIndex; + startIndexOverscan = Math.max(0, startIndexVisible - overscanCount); while (currentIndex < maxIndex) { const bounds = cachedBounds.get(currentIndex); @@ -45,8 +52,20 @@ export function getStartStopIndices({ currentIndex++; } - stopIndex = Math.min(maxIndex, currentIndex); - stopIndex = Math.min(itemCount - 1, stopIndex + overscanCount); + stopIndexVisible = Math.min(maxIndex, currentIndex); + stopIndexOverscan = Math.min(itemCount - 1, stopIndexVisible + overscanCount); - return startIndex < 0 ? [0, -1] : [startIndex, stopIndex]; + if (startIndexVisible < 0) { + startIndexVisible = 0; + stopIndexVisible = -1; + startIndexOverscan = 0; + stopIndexOverscan = -1; + } + + return { + startIndexVisible, + stopIndexVisible, + startIndexOverscan, + stopIndexOverscan + }; } diff --git a/lib/core/useVirtualizer.test.ts b/lib/core/useVirtualizer.test.ts index 419ef655..42a16c12 100644 --- a/lib/core/useVirtualizer.test.ts +++ b/lib/core/useVirtualizer.test.ts @@ -132,11 +132,14 @@ describe("useVirtualizer", () => { useVirtualizer({ ...DEFAULT_ARGS, defaultContainerSize: 100, - itemSize: 25 + itemSize: 25, + overscanCount: 2 }) ); - expect(result.current.startIndex).toBe(0); - expect(result.current.stopIndex).toBe(3); + expect(result.current.startIndexOverscan).toBe(0); + expect(result.current.startIndexVisible).toBe(0); + expect(result.current.stopIndexOverscan).toBe(5); + expect(result.current.stopIndexVisible).toBe(3); }); test("itemSize type: string", () => { @@ -144,11 +147,14 @@ describe("useVirtualizer", () => { useVirtualizer({ ...DEFAULT_ARGS, defaultContainerSize: 100, + overscanCount: 2, itemSize: "50%" }) ); - expect(result.current.startIndex).toBe(0); - expect(result.current.stopIndex).toBe(1); + expect(result.current.startIndexOverscan).toBe(0); + expect(result.current.startIndexVisible).toBe(0); + expect(result.current.stopIndexOverscan).toBe(3); + expect(result.current.stopIndexVisible).toBe(1); }); test("itemSize type: function", () => { @@ -158,11 +164,14 @@ describe("useVirtualizer", () => { useVirtualizer({ ...DEFAULT_ARGS, defaultContainerSize: 100, - itemSize + itemSize, + overscanCount: 2 }) ); - expect(result.current.startIndex).toBe(0); - expect(result.current.stopIndex).toBe(2); + expect(result.current.startIndexOverscan).toBe(0); + expect(result.current.startIndexVisible).toBe(0); + expect(result.current.stopIndexOverscan).toBe(4); + expect(result.current.stopIndexVisible).toBe(2); }); }); diff --git a/lib/core/useVirtualizer.ts b/lib/core/useVirtualizer.ts index ea6dbde3..db427159 100644 --- a/lib/core/useVirtualizer.ts +++ b/lib/core/useVirtualizer.ts @@ -10,6 +10,7 @@ import { useResizeObserver } from "../hooks/useResizeObserver"; import { useStableCallback } from "../hooks/useStableCallback"; import type { Align } from "../types"; import { adjustScrollOffsetForRtl } from "../utils/adjustScrollOffsetForRtl"; +import { shallowCompare } from "../utils/shallowCompare"; import { getEstimatedSize as getEstimatedSizeUtil } from "./getEstimatedSize"; import { getOffsetForIndex } from "./getOffsetForIndex"; import { getStartStopIndices as getStartStopIndicesUtil } from "./getStartStopIndices"; @@ -45,14 +46,31 @@ export function useVirtualizer({ | undefined; overscanCount: number; }) { - const [indices, setIndices] = useState([0, -1]); + const [indices, setIndices] = useState<{ + startIndexVisible: number; + stopIndexVisible: number; + startIndexOverscan: number; + stopIndexOverscan: number; + }>({ + startIndexVisible: 0, + startIndexOverscan: 0, + stopIndexVisible: -1, + stopIndexOverscan: -1 + }); // Guard against temporarily invalid indices that may occur when item count decreases // Cached bounds object will be re-created and a second render will restore things - const [startIndex, stopIndex] = [ - Math.min(itemCount - 1, indices[0]), - Math.min(itemCount - 1, indices[1]) - ]; + const { + startIndexVisible, + startIndexOverscan, + stopIndexVisible, + stopIndexOverscan + } = { + startIndexVisible: Math.min(itemCount - 1, indices.startIndexVisible), + startIndexOverscan: Math.min(itemCount - 1, indices.startIndexOverscan), + stopIndexVisible: Math.min(itemCount - 1, indices.stopIndexVisible), + stopIndexOverscan: Math.min(itemCount - 1, indices.stopIndexOverscan) + }; const { height = defaultContainerSize, width = defaultContainerSize } = useResizeObserver({ @@ -169,7 +187,7 @@ export function useVirtualizer({ overscanCount }); - if (next[0] === prev[0] && next[1] === prev[1]) { + if (shallowCompare(next, prev)) { return prev; } @@ -222,7 +240,7 @@ export function useVirtualizer({ if (typeof containerElement.scrollTo !== "function") { // Special case for environments like jsdom that don't implement scrollTo const next = getStartStopIndices(scrollOffset); - if (next[0] !== startIndex || next[1] !== stopIndex) { + if (!shallowCompare(indices, next)) { setIndices(next); } } @@ -236,7 +254,9 @@ export function useVirtualizer({ getCellBounds, getEstimatedSize, scrollToIndex, - startIndex, - stopIndex + startIndexOverscan, + startIndexVisible, + stopIndexOverscan, + stopIndexVisible }; } diff --git a/lib/utils/areArraysEqual.ts b/lib/utils/areArraysEqual.ts new file mode 100644 index 00000000..8f2fddaf --- /dev/null +++ b/lib/utils/areArraysEqual.ts @@ -0,0 +1,13 @@ +export function areArraysEqual(a: unknown[], b: unknown[]) { + if (a.length !== b.length) { + return false; + } + + for (let index = 0; index < a.length; index++) { + if (!Object.is(a[index], b[index])) { + return false; + } + } + + return true; +} diff --git a/package.json b/package.json index 25da6fbf..11b2b836 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-window", - "version": "2.0.2", + "version": "2.1.0", "type": "module", "author": "Brian Vaughn (https://github.com/bvaughn/)", "contributors": [ diff --git a/public/generated/js-docs/Grid.json b/public/generated/js-docs/Grid.json index d808337d..2c8e09b3 100644 --- a/public/generated/js-docs/Grid.json +++ b/public/generated/js-docs/Grid.json @@ -357,13 +357,13 @@ "required": false, "type": { "name": "enum", - "raw": "((args: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number; }) => void) | undefined", + "raw": "((visibleCells: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number; }, allCells: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number; }) => void) | undefined", "value": [ { "value": "undefined" }, { - "value": "(args: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number; }) => void", + "value": "(visibleCells: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number; }, allCells: { columnStartIndex: number; columnStopIndex: number; rowStartIndex: number; rowStopIndex: number; }) => void", "description": "", "fullComment": "", "tags": {} diff --git a/public/generated/js-docs/List.json b/public/generated/js-docs/List.json index 96a1649c..bd2cda92 100644 --- a/public/generated/js-docs/List.json +++ b/public/generated/js-docs/List.json @@ -251,13 +251,13 @@ "required": false, "type": { "name": "enum", - "raw": "((args: { startIndex: number; stopIndex: number; }) => void) | undefined", + "raw": "((visibleRows: { startIndex: number; stopIndex: number; }, allRows: { startIndex: number; stopIndex: number; }) => void) | undefined", "value": [ { "value": "undefined" }, { - "value": "(args: { startIndex: number; stopIndex: number; }) => void", + "value": "(visibleRows: { startIndex: number; stopIndex: number; }, allRows: { startIndex: number; stopIndex: number; }) => void", "description": "", "fullComment": "", "tags": {} From 35f651b615b41ccc764e698babe6e1a7c8e3f4bf Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 6 Sep 2025 14:21:31 -0400 Subject: [PATCH 4/4] Revert accidental change to docs example --- public/generated/code-snippets/FixedHeightList.json | 4 ++-- public/generated/code-snippets/FixedHeightRowComponent.json | 4 ++-- src/routes/list/examples/FixedHeightList.example.tsx | 1 - src/routes/list/examples/FixedHeightRowComponent.example.tsx | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/public/generated/code-snippets/FixedHeightList.json b/public/generated/code-snippets/FixedHeightList.json index 7ea8f2ea..9db447f8 100644 --- a/public/generated/code-snippets/FixedHeightList.json +++ b/public/generated/code-snippets/FixedHeightList.json @@ -1,4 +1,4 @@ { - "javaScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    tagName=\"ul\"
    \n
    />
    \n
    );
    \n
    }
    ", - "typeScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }: { names: string[] }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    tagName=\"ul\"
    \n
    />
    \n
    );
    \n
    }
    " + "javaScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    />
    \n
    );
    \n
    }
    ", + "typeScript": "
    import { List } from \"react-window\";
    \n
    \n
    function Example({ names }: { names: string[] }) {
    \n
    return (
    \n
    <List
    \n
    rowComponent={RowComponent}
    \n
    rowCount={names.length}
    \n
    rowHeight={25}
    \n
    rowProps={{ names }}
    \n
    />
    \n
    );
    \n
    }
    " } \ No newline at end of file diff --git a/public/generated/code-snippets/FixedHeightRowComponent.json b/public/generated/code-snippets/FixedHeightRowComponent.json index 9b8e2df4..efa54a6a 100644 --- a/public/generated/code-snippets/FixedHeightRowComponent.json +++ b/public/generated/code-snippets/FixedHeightRowComponent.json @@ -1,4 +1,4 @@ { - "javaScript": "
    import {} from \"react-window\";
    \n
    \n
    function RowComponent({ index, names, style }) {
    \n
    return (
    \n
    <li className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </li>
    \n
    );
    \n
    }
    ", - "typeScript": "
    import { type RowComponentProps } from \"react-window\";
    \n
    \n
    function RowComponent({
    \n
    index,
    \n
    names,
    \n
    style
    \n
    }: RowComponentProps<{
    \n
    names: string[];
    \n
    }>) {
    \n
    return (
    \n
    <li className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </li>
    \n
    );
    \n
    }
    " + "javaScript": "
    import {} from \"react-window\";
    \n
    \n
    function RowComponent({ index, names, style }) {
    \n
    return (
    \n
    <div className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </div>
    \n
    );
    \n
    }
    ", + "typeScript": "
    import { type RowComponentProps } from \"react-window\";
    \n
    \n
    function RowComponent({
    \n
    index,
    \n
    names,
    \n
    style
    \n
    }: RowComponentProps<{
    \n
    names: string[];
    \n
    }>) {
    \n
    return (
    \n
    <div className=\"flex items-center justify-between\" style={style}>
    \n
    {names[index]}
    \n
    <div className=\"text-slate-500 text-xs\">{`${index + 1} of ${names.length}`}</div>
    \n
    </div>
    \n
    );
    \n
    }
    " } \ No newline at end of file diff --git a/src/routes/list/examples/FixedHeightList.example.tsx b/src/routes/list/examples/FixedHeightList.example.tsx index 67c5f191..99ff3007 100644 --- a/src/routes/list/examples/FixedHeightList.example.tsx +++ b/src/routes/list/examples/FixedHeightList.example.tsx @@ -11,7 +11,6 @@ function Example({ names }: { names: string[] }) { rowCount={names.length} rowHeight={25} rowProps={{ names }} - tagName="ul" /> ); } diff --git a/src/routes/list/examples/FixedHeightRowComponent.example.tsx b/src/routes/list/examples/FixedHeightRowComponent.example.tsx index ce390129..da9a68a3 100644 --- a/src/routes/list/examples/FixedHeightRowComponent.example.tsx +++ b/src/routes/list/examples/FixedHeightRowComponent.example.tsx @@ -8,10 +8,10 @@ function RowComponent({ names: string[]; }>) { return ( -
  • +
    {names[index]}
    {`${index + 1} of ${names.length}`}
    -
  • +
    ); }