diff --git a/CHANGELOG.md b/CHANGELOG.md index 26632501..239ddacf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # Changelog +## 2.1.0 + +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, + style, + ...rest +}: RowComponentProps) { + return ( +
+ ... +
+ ); +} +``` + +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 0cc11134..fbc75d02 100644 --- a/lib/components/grid/Grid.test.tsx +++ b/lib/components/grid/Grid.test.tsx @@ -2,15 +2,19 @@ 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(); 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 +26,7 @@ describe("Grid", () => { }); return ( -
+
Cell {key}
); @@ -49,7 +53,7 @@ describe("Grid", () => { /> ); - const items = screen.queryAllByRole("listitem"); + const items = screen.queryAllByRole("gridcell"); expect(items).toHaveLength(0); }); @@ -67,7 +71,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 +90,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 +110,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,45 +127,286 @@ describe("Grid", () => { ); // 4 columns by 4 rows - expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(16); + expect(container.querySelectorAll('[role="gridcell"]')).toHaveLength(16); }); }); - 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", () => { + 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 + test("should return the root element", () => { + const gridRef = createRef(); + + render( + + ); + + expect(gridRef.current?.element).toEqual(screen.queryByRole("grid")); }); test("should scroll to cell", () => { @@ -244,13 +489,134 @@ 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(); + }); + }); + + 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..8fbe3dcd 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), @@ -44,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, @@ -62,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, @@ -167,44 +177,67 @@ 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); - children.push( + columns.push( ({ /> ); } + + children.push( +
+ {columns} +
+ ); } } return children; @@ -224,42 +263,50 @@ export function Grid({ CellComponent, cellProps, columnCount, - columnStartIndex, - columnStopIndex, + columnStartIndexOverscan, + columnStopIndexOverscan, getColumnBounds, getRowBounds, isRtl, rowCount, - rowStartIndex, - rowStopIndex + rowStartIndexOverscan, + rowStopIndexOverscan ]); - 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%", - ...style, maxHeight: "100%", maxWidth: "100%", flexGrow: 1, - overflow: "auto" - }} - > -
- {cells} -
- + overflow: "auto", + ...style + } + }, + cells, + children, + sizingElement ); } diff --git a/lib/components/grid/types.ts b/lib/components/grid/types.ts index 668cc48c..b4252449 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. * @@ -30,6 +26,10 @@ export type GridProps = Omit< */ cellComponent: ( props: { + ariaAttributes: { + "aria-colindex": number; + role: "gridcell"; + }; columnIndex: number; rowIndex: number; style: CSSProperties; @@ -44,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. */ @@ -90,12 +101,20 @@ export type GridProps = Omit< /** * 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. @@ -133,6 +152,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 e736a9d5..9317ba89 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}
); @@ -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", () => { @@ -329,6 +363,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(); @@ -488,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(); @@ -499,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({ @@ -602,4 +686,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..11f01bce 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), @@ -38,8 +44,10 @@ export function List({ getCellBounds, getEstimatedSize, scrollToIndex, - startIndex, - stopIndex + startIndexOverscan, + startIndexVisible, + stopIndexOverscan, + stopIndexVisible } = useVirtualizer({ containerElement: element, defaultContainerSize: defaultHeight, @@ -85,23 +93,44 @@ 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( ({ } } return children; - }, [RowComponent, getCellBounds, rowCount, rowProps, startIndex, stopIndex]); + }, [ + RowComponent, + getCellBounds, + rowCount, + rowProps, + startIndexOverscan, + stopIndexOverscan + ]); - return ( + const sizingElement = (
    + ); + + return createElement( + tagName, + { + role: "list", + ...rest, + className, + ref: setElement, + style: { + position: "relative", maxHeight: "100%", flexGrow: 1, - overflowY: "auto" - }} - > -
    - {rows} -
    -
    + overflowY: "auto", + ...style + } + }, + rows, + children, + sizingElement ); } diff --git a/lib/components/list/types.ts b/lib/components/list/types.ts index cdd6cef7..517c79cb 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 = "index" | "style"; +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. */ @@ -47,7 +54,10 @@ export type ListProps = Omit< /** * Callback notified when the range of visible rows changes. */ - onRowsRendered?: (args: { startIndex: number; stopIndex: number }) => void; + onRowsRendered?: ( + visibleRows: { startIndex: number; stopIndex: number }, + allRows: { startIndex: number; stopIndex: number } + ) => void; /** * How many additional rows to render outside of the visible area. @@ -65,6 +75,11 @@ export type ListProps = Omit< */ rowComponent: ( props: { + ariaAttributes: { + "aria-posinset": number; + "aria-setsize": number; + role: "listitem"; + }; index: number; style: CSSProperties; } & RowProps @@ -96,6 +111,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/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/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/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/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/package.json b/package.json index 2bb4264d..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": [ @@ -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/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/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/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..2c8e09b3 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`", @@ -116,7 +190,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": { @@ -283,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": {} @@ -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 7b0415ff..bd2cda92 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" @@ -177,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": {} @@ -229,7 +303,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": { @@ -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/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 + Sticky rows
    Requirements diff --git a/src/routes.ts b/src/routes.ts index 8f506ead..04019983 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -19,7 +19,12 @@ 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") + ), + "/list/sticky-rows": lazy(() => import("./routes/list/StickyRowsRoute")), // SimpleGrid "/grid/grid": lazy(() => import("./routes/grid/RenderingGridRoute")), @@ -31,6 +36,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/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/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/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/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 }; 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"