{"version":3,"file":"VirtualTable.cjs","names":[],"sources":["../../../src/components/VirtualTable/VirtualTable.tsx"],"sourcesContent":["/**\n * @tempest-limits file-lines, props-count — windowing needs its own measurements\n * (rowHeight, height, overscan, scrollToIndex) on top of everything a table already\n * takes (data, columns, rowKey, initialSort, onRowClick, emptyMessage, caption). The\n * caption is not optional chrome — a virtualised grid without one is unreadable to a\n * screen reader.\n */\nimport {\n    type HTMLAttributes,\n    type ReactNode,\n    memo,\n    useCallback,\n    useEffect,\n    useMemo,\n    useRef,\n    useState,\n} from \"react\";\n\nimport { cn } from \"@/utils/cn\";\nimport { compareValues } from \"@/utils/compare-values\";\n\nimport type { TableAlign } from \"../Table\";\nimport styles from \"./VirtualTable.module.css\";\n\nexport type VirtualTableSortDirection = \"asc\" | \"desc\";\n\nexport interface VirtualTableSort<T> {\n    key: keyof T;\n    direction: VirtualTableSortDirection;\n}\n\nexport interface VirtualTableColumn<T> {\n    /** Property of the row this column reads from. Doubles as the cell key. */\n    key: keyof T;\n    /** Column heading. */\n    header: ReactNode;\n    /** Custom cell renderer. Defaults to `row[key]`. */\n    render?: (row: T, index: number) => ReactNode;\n    /** Enable click-to-sort on this column's header. */\n    sortable?: boolean;\n    /** Text alignment. */\n    align?: TableAlign;\n    /**\n     * Column width. Recommended for every column: virtualized rows enter and\n     * leave the DOM as you scroll, so letting the browser auto-size columns from\n     * whatever is currently rendered makes them jump mid-scroll.\n     */\n    width?: string | number;\n}\n\nexport interface VirtualTableProps<T> extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n    /** Full dataset. Every row is accounted for; only the visible ones render. */\n    data: T[];\n    /** Column definitions. */\n    columns: VirtualTableColumn<T>[];\n    /**\n     * Row height in px. Must be uniform and must match what the CSS produces,\n     * because it is what maps scroll offset to row index.\n     */\n    rowHeight: number;\n    /** Height of the scroll viewport — a number of px or any CSS length. */\n    height: number | string;\n    /** Rows rendered above and below the viewport. Default `4`. */\n    overscan?: number;\n    /** Stable key extractor. Defaults to the row's index in `data`. */\n    rowKey?: (row: T, index: number) => string | number;\n    /** Sort applied before any header interaction. */\n    initialSort?: VirtualTableSort<T>;\n    /** Called when a row is activated by click, `Enter` or `Space`. */\n    onRowClick?: (row: T, index: number) => void;\n    /** Content shown when `data` is empty. */\n    emptyMessage?: ReactNode;\n    /** Accessible name for the table. Rendered as a visually hidden `<caption>`. */\n    caption?: ReactNode;\n    /** Scroll this row into view whenever it changes. */\n    scrollToIndex?: number;\n}\n\n/** Resolve a column's alignment to its CSS module class. */\nfunction alignClass(align: TableAlign | undefined): string | undefined {\n    if (align === \"right\") return styles.alignRight;\n    if (align === \"center\") return styles.alignCenter;\n    return undefined;\n}\n\ninterface VirtualTableRowProps<T> {\n    row: T;\n    index: number;\n    rowHeight: number;\n    columns: VirtualTableColumn<T>[];\n    onRowClick: ((row: T, index: number) => void) | undefined;\n}\n\n/**\n * One windowed table row, rendered on its own so React can skip it.\n *\n * Scrolling changes state that lives inside `VirtualTable`, so without this\n * boundary every scroll event re-rendered every visible row and every cell in\n * them — including whatever `column.render` builds — rather than the one row\n * that entered the window.\n *\n * `columns` and `onRowClick` take part in the comparison because a cell renderer\n * closes over the app's state: leaving them out would freeze a row showing a\n * stale cell. An app that keeps its `columns` array stable (a module constant, or\n * `useMemo`) therefore gets the skip, and one that rebuilds it every render gets\n * exactly today's behaviour.\n *\n * @param props - The row, its position and the column contract.\n * @returns The table row element.\n */\nfunction VirtualTableRowImpl<T>({\n    row,\n    index,\n    rowHeight,\n    columns,\n    onRowClick,\n}: VirtualTableRowProps<T>) {\n    return (\n        <tr\n            aria-rowindex={index + 1}\n            className={cn(styles.tr, onRowClick && styles.clickable)}\n            style={{ height: rowHeight }}\n            tabIndex={onRowClick ? 0 : undefined}\n            onClick={onRowClick ? () => onRowClick(row, index) : undefined}\n            onKeyDown={\n                onRowClick\n                    ? (event) => {\n                          if (event.key === \"Enter\" || event.key === \" \") {\n                              event.preventDefault();\n                              onRowClick(row, index);\n                          }\n                      }\n                    : undefined\n            }\n        >\n            {columns.map((column) => (\n                <td\n                    key={String(column.key)}\n                    className={cn(styles.td, alignClass(column.align))}\n                    style={{ width: column.width }}\n                >\n                    {column.render\n                        ? column.render(row, index)\n                        : ((row[column.key] as ReactNode) ?? null)}\n                </td>\n            ))}\n        </tr>\n    );\n}\n\n/**\n * `memo` erases the generic, so the memoised component is re-typed as the\n * function it wraps. The runtime value is unchanged; only the signature is\n * restored.\n */\nconst VirtualTableRow = memo(VirtualTableRowImpl) as typeof VirtualTableRowImpl;\n\n/**\n * A table that stays responsive at 10k+ rows by rendering only the visible window.\n *\n * `Table` renders every row it is given and `DataTable` paginates to keep that\n * count small; neither has an answer for \"show me all 40 000 rows in one\n * scrollable grid\". This does, at the cost of one constraint: **`rowHeight` must\n * be uniform**, since mapping a scroll offset to a row index is what makes the\n * window computable without measuring anything.\n *\n * It stays a real `<table>`. The window is produced by two spacer rows — one\n * above the visible slice, one below — instead of absolutely positioning rows.\n * That is deliberate: `position: absolute` on `<tr>` collapses table layout, so\n * every column width would have to be computed by hand, and the element would\n * stop being a table for assistive technology. With spacer rows the browser keeps\n * doing column layout and screen readers keep announcing a grid.\n *\n * Because only a slice is in the DOM, `aria-rowcount` on the table and\n * `aria-rowindex` on each row carry the real numbers — without them a screen\n * reader announces \"row 3 of 20\" while the user is on row 5003 of 40 000.\n *\n * @example\n * <VirtualTable\n *     data={rows}\n *     columns={[\n *         { key: \"id\", header: \"#\", width: 80, sortable: true },\n *         { key: \"name\", header: \"Nome\", width: 240, sortable: true },\n *         { key: \"total\", header: \"Total\", align: \"right\", width: 120, sortable: true },\n *     ]}\n *     rowHeight={40}\n *     height={480}\n *     rowKey={(row) => row.id}\n * />\n */\nexport function VirtualTable<T>({\n    data,\n    columns,\n    rowHeight,\n    height,\n    overscan = 4,\n    rowKey,\n    initialSort,\n    onRowClick,\n    emptyMessage = \"Nenhum registro encontrado.\",\n    caption,\n    scrollToIndex,\n    className,\n    ...rest\n}: VirtualTableProps<T>) {\n    const scrollRef = useRef<HTMLDivElement>(null);\n    const [scrollTop, setScrollTop] = useState<number>(0);\n    const [viewport, setViewport] = useState<number>(0);\n    const [sort, setSort] = useState<VirtualTableSort<T> | null>(initialSort ?? null);\n\n    useEffect(() => {\n        const element = scrollRef.current;\n        if (!element) return;\n        setViewport(element.clientHeight);\n        if (typeof ResizeObserver === \"undefined\") return;\n        const observer = new ResizeObserver(() => setViewport(element.clientHeight));\n        observer.observe(element);\n        return () => observer.disconnect();\n    }, []);\n\n    useEffect(() => {\n        const element = scrollRef.current;\n        if (!element || scrollToIndex == null) return;\n        element.scrollTop = Math.max(0, Math.min(scrollToIndex, data.length - 1)) * rowHeight;\n    }, [scrollToIndex, rowHeight, data.length]);\n\n    const sorted = useMemo<T[]>(() => {\n        if (!sort) return data;\n        const factor = sort.direction === \"asc\" ? 1 : -1;\n        return [...data].sort((a, b) => compareValues(a[sort.key], b[sort.key]) * factor);\n    }, [data, sort]);\n\n    const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);\n    const windowSize = Math.ceil(viewport / rowHeight) + overscan * 2;\n    const end = Math.min(sorted.length, start + windowSize);\n    const slice = sorted.slice(start, end);\n\n    /**\n     * Advance a column through asc → desc → unsorted, and jump back to the top.\n     *\n     * The scroll reset happens here rather than in an effect keyed on the sort\n     * state for two reasons: this is the actual event that invalidates the offset\n     * (keeping it would leave the user at the same pixel of a different dataset,\n     * which reads as the table having jumped somewhere random), and an effect would\n     * also fire on mount — clobbering an initial `scrollToIndex`.\n     */\n    const toggleSort = useCallback((key: keyof T): void => {\n        setSort((current) => {\n            if (!current || current.key !== key) return { key, direction: \"asc\" };\n            if (current.direction === \"asc\") return { key, direction: \"desc\" };\n            return null;\n        });\n        const element = scrollRef.current;\n        if (element) element.scrollTop = 0;\n        setScrollTop(0);\n    }, []);\n\n    return (\n        <div\n            ref={scrollRef}\n            className={cn(styles.scroll, className)}\n            style={{ height }}\n            onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}\n            {...rest}\n        >\n            <table className={styles.table} aria-rowcount={sorted.length}>\n                {caption ? <caption className={styles.caption}>{caption}</caption> : null}\n                <thead className={styles.head}>\n                    <tr>\n                        {columns.map((column) => {\n                            const isSorted = sort?.key === column.key;\n                            return (\n                                <th\n                                    key={String(column.key)}\n                                    scope=\"col\"\n                                    className={cn(styles.th, alignClass(column.align))}\n                                    style={{ width: column.width }}\n                                    aria-sort={\n                                        isSorted\n                                            ? sort?.direction === \"asc\"\n                                                ? \"ascending\"\n                                                : \"descending\"\n                                            : column.sortable\n                                              ? \"none\"\n                                              : undefined\n                                    }\n                                >\n                                    {column.sortable ? (\n                                        <button\n                                            type=\"button\"\n                                            className={styles.sortButton}\n                                            onClick={() => toggleSort(column.key)}\n                                        >\n                                            {column.header}\n                                            <span className={styles.sortIndicator} aria-hidden>\n                                                {isSorted\n                                                    ? sort?.direction === \"asc\"\n                                                        ? \"▲\"\n                                                        : \"▼\"\n                                                    : \"↕\"}\n                                            </span>\n                                        </button>\n                                    ) : (\n                                        column.header\n                                    )}\n                                </th>\n                            );\n                        })}\n                    </tr>\n                </thead>\n                <tbody>\n                    {sorted.length === 0 ? (\n                        <tr>\n                            <td className={styles.emptyRow} colSpan={columns.length}>\n                                {emptyMessage}\n                            </td>\n                        </tr>\n                    ) : (\n                        <>\n                            {start > 0 && <tr aria-hidden style={{ height: start * rowHeight }} />}\n                            {slice.map((row, offset) => {\n                                const index = start + offset;\n                                return (\n                                    <VirtualTableRow\n                                        key={rowKey ? rowKey(row, index) : index}\n                                        row={row}\n                                        index={index}\n                                        rowHeight={rowHeight}\n                                        columns={columns}\n                                        onRowClick={onRowClick}\n                                    />\n                                );\n                            })}\n                            {end < sorted.length && (\n                                <tr\n                                    aria-hidden\n                                    style={{ height: (sorted.length - end) * rowHeight }}\n                                />\n                            )}\n                        </>\n                    )}\n                </tbody>\n            </table>\n        </div>\n    );\n}\n"],"mappings":"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"}