diff --git a/web/package-lock.json b/web/package-lock.json
index d8b09ce91..09a270737 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -93,7 +93,8 @@
"split-pane-react": "^0.1.3",
"tailwind-merge": "^2.6.0",
"utility-types": "^3.11.0",
- "uuid": "^11.0.5"
+ "uuid": "^11.0.5",
+ "virtua": "^0.39.3"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
@@ -584,7 +585,6 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
- "extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -15886,6 +15886,36 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/virtua": {
+ "version": "0.39.3",
+ "resolved": "https://registry.npmjs.org/virtua/-/virtua-0.39.3.tgz",
+ "integrity": "sha512-Ep3aiJXSGPm1UUniThr5mGDfG0upAleP7pqQs5mvvCgM1wPhII1ZKa7eNCWAJRLkC+InpXKokKozyaaj/aMYOQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.14.0",
+ "react-dom": ">=16.14.0",
+ "solid-js": ">=1.0",
+ "svelte": ">=5.0",
+ "vue": ">=3.2"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "solid-js": {
+ "optional": true
+ },
+ "svelte": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
diff --git a/web/package.json b/web/package.json
index a0b746d5d..ecabd5b56 100644
--- a/web/package.json
+++ b/web/package.json
@@ -98,7 +98,8 @@
"split-pane-react": "^0.1.3",
"tailwind-merge": "^2.6.0",
"utility-types": "^3.11.0",
- "uuid": "^11.0.5"
+ "uuid": "^11.0.5",
+ "virtua": "^0.39.3"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
diff --git a/web/src/app/test/list/page.tsx b/web/src/app/test/list/page.tsx
new file mode 100644
index 000000000..7fb6b8bb2
--- /dev/null
+++ b/web/src/app/test/list/page.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { BusterListColumn, BusterListRow } from '@/components/list';
+import { BusterListVirtua } from '@/components/list/BusterList/BusterListVirtua';
+import { faker } from '@faker-js/faker';
+import { useEffect, useState } from 'react';
+
+const generateRows = (numberToGenerate: number): BusterListRow[] => {
+ return Array.from({ length: numberToGenerate }, (_, index) => ({
+ id: faker.string.uuid(),
+ data: {
+ name: faker.person.fullName() + ` ${index}`,
+ email: faker.internet.email(),
+ phone: faker.phone.number()
+ }
+ }));
+};
+const rows: BusterListRow[] = Array.from({ length: 10 }, (_, index) => {
+ const rows = generateRows(faker.number.int({ min: 10, max: 10 }));
+ return [
+ {
+ id: `section-${index + 1}`,
+ rowSection: {
+ title: `Section ${index + 1}`,
+ secondaryTitle: rows.length.toString()
+ },
+ data: {}
+ },
+ ...rows
+ ];
+}).flat();
+
+const columns: BusterListColumn[] = [
+ { title: 'Name', dataIndex: 'name' },
+ { title: 'Email', dataIndex: 'email' },
+ { title: 'Phone', dataIndex: 'phone' }
+];
+
+export default function ListTest() {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setTimeout(() => {
+ setMounted(true);
+ }, 100);
+ }, []);
+
+ if (!mounted) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/components/list/BusterList/BusterListReactWindow.tsx b/web/src/components/list/BusterList/BusterListReactWindow.tsx
index ad8c05d02..6c849ebf1 100644
--- a/web/src/components/list/BusterList/BusterListReactWindow.tsx
+++ b/web/src/components/list/BusterList/BusterListReactWindow.tsx
@@ -9,7 +9,7 @@ import { getAllIdsInSection } from './helpers';
import { BusterListHeader } from './BusterListHeader';
import { BusterListRowComponentSelector } from './BusterListRowComponentSelector';
-export const BusterList: React.FC = ({
+export const BusterListReactWindow: React.FC = ({
columns,
rows,
selectedRowKeys,
@@ -194,5 +194,5 @@ export const BusterList: React.FC = ({
);
};
-BusterList.displayName = 'BusterList';
+BusterListReactWindow.displayName = 'BusterList';
// Add a memoized checkbox component
diff --git a/web/src/components/list/BusterList/BusterListVirtua.tsx b/web/src/components/list/BusterList/BusterListVirtua.tsx
new file mode 100644
index 000000000..09e008b31
--- /dev/null
+++ b/web/src/components/list/BusterList/BusterListVirtua.tsx
@@ -0,0 +1,134 @@
+import { VList } from 'virtua';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { BusterListProps } from './interfaces';
+import { useMemoizedFn } from 'ahooks';
+import { getAllIdsInSection } from './helpers';
+import { HEIGHT_OF_ROW, HEIGHT_OF_SECTION_ROW } from './config';
+import { useListContextMenu } from './useListContextMenu';
+import { BusterListHeader } from './BusterListHeader';
+import { BusterListContentMenu } from './BusterListContentMenu';
+import { BusterListRowComponentSelector } from './BusterListRowComponentSelector';
+
+export const BusterListVirtua = React.memo(
+ ({
+ columns,
+ rows,
+ selectedRowKeys,
+ onSelectChange,
+ emptyState,
+ showHeader = true,
+ contextMenu,
+ showSelectAll = true
+ }: BusterListProps) => {
+ const contextMenuRef = useRef(null);
+ const showEmptyState = (!rows || rows.length === 0) && !!emptyState;
+
+ const globalCheckStatus = useMemo(() => {
+ if (!selectedRowKeys) return 'unchecked';
+ if (selectedRowKeys.length === 0) return 'unchecked';
+ if (selectedRowKeys.length === rows.length) return 'checked';
+ return 'indeterminate';
+ }, [selectedRowKeys?.length, rows.length]);
+
+ const { contextMenuPosition, setContextMenuPosition, onContextMenuClick } = useListContextMenu({
+ contextMenu
+ });
+
+ const onGlobalSelectChange = useMemoizedFn((v: boolean) => {
+ onSelectChange?.(v ? rows.map((row) => row.id) : []);
+ });
+
+ const onSelectSectionChange = useMemoizedFn((v: boolean, id: string) => {
+ if (!onSelectChange) return;
+ const idsInSection = getAllIdsInSection(rows, id);
+
+ if (v === false) {
+ onSelectChange(selectedRowKeys?.filter((d) => !idsInSection.includes(d)) || []);
+ } else {
+ onSelectChange(selectedRowKeys?.concat(idsInSection) || []);
+ }
+ });
+
+ const onSelectChangePreflight = useMemoizedFn((v: boolean, id: string) => {
+ if (!onSelectChange || !selectedRowKeys) return;
+ if (v === false) {
+ onSelectChange(selectedRowKeys?.filter((d) => d !== id));
+ } else {
+ onSelectChange(selectedRowKeys?.concat(id) || []);
+ }
+ });
+
+ const itemSize = useMemoizedFn((index: number) => {
+ const row = rows[index];
+ return row.rowSection ? HEIGHT_OF_SECTION_ROW : HEIGHT_OF_ROW;
+ });
+
+ const itemData = useMemo(() => {
+ return {
+ columns,
+ rows,
+ selectedRowKeys,
+ onSelectChange: onSelectChange ? onSelectChangePreflight : undefined,
+ onSelectSectionChange: onSelectChange ? onSelectSectionChange : undefined,
+ onContextMenuClick
+ };
+ }, [columns, rows, selectedRowKeys, onSelectChange, onSelectSectionChange, onContextMenuClick]);
+
+ useEffect(() => {
+ if (contextMenu && contextMenuPosition?.show) {
+ const listenForClickAwayFromContextMenu = (e: MouseEvent) => {
+ if (!contextMenuRef.current?.contains(e.target as Node)) {
+ setContextMenuPosition((v) => ({
+ ...v!,
+ show: false
+ }));
+ }
+ };
+ document.addEventListener('click', listenForClickAwayFromContextMenu);
+ return () => {
+ document.removeEventListener('click', listenForClickAwayFromContextMenu);
+ };
+ }
+ }, [contextMenuRef, contextMenuPosition?.show, contextMenu]);
+
+ return (
+
+ {showHeader && !showEmptyState && (
+
+ )}
+
+ {!showEmptyState && (
+
+ {rows.map((row, index) => (
+
+
+
+ ))}
+
+ )}
+
+ {showEmptyState && (
+
{emptyState}
+ )}
+
+ {contextMenu && contextMenuPosition?.id && (
+
+ )}
+
+ );
+ }
+);
+
+BusterListVirtua.displayName = 'BusterListVirtua';
diff --git a/web/src/components/list/BusterList/index.ts b/web/src/components/list/BusterList/index.ts
index 84d85002e..336c3d6b9 100644
--- a/web/src/components/list/BusterList/index.ts
+++ b/web/src/components/list/BusterList/index.ts
@@ -1,3 +1,6 @@
-export * from './BusterListReactWindow';
export * from './interfaces';
export * from './BusterListSelectedOptionPopup';
+
+import { BusterListVirtua } from './BusterListVirtua';
+
+export { BusterListVirtua as BusterList };
diff --git a/web/src/components/list/BusterList/useListContextMenu.tsx b/web/src/components/list/BusterList/useListContextMenu.tsx
new file mode 100644
index 000000000..d1502ad23
--- /dev/null
+++ b/web/src/components/list/BusterList/useListContextMenu.tsx
@@ -0,0 +1,45 @@
+import { useMemoizedFn } from 'ahooks';
+import { useState } from 'react';
+import { BusterListContextMenu } from './interfaces';
+import React from 'react';
+
+export const useListContextMenu = ({ contextMenu }: { contextMenu?: BusterListContextMenu }) => {
+ const [contextMenuPosition, setContextMenuPosition] = useState<{
+ x: number;
+ y: number;
+ scrollYPosition: number;
+ show: boolean;
+ id: string;
+ } | null>(null);
+
+ const onContextMenuClick = useMemoizedFn((e: React.MouseEvent, id: string) => {
+ if (!contextMenu) return;
+ e.stopPropagation();
+ e.preventDefault();
+ const x = e.clientX - 5;
+ const y = e.clientY - 5; // offset the top by 30px
+ const menuWidth = 250; // width of the menu
+ const menuHeight = 200; // height of the menu
+ const pageWidth = window.innerWidth;
+ const pageHeight = window.innerHeight;
+
+ // Ensure the menu does not render offscreen horizontally
+ const adjustedX = Math.min(Math.max(0, x), pageWidth - menuWidth);
+ // Ensure the menu does not render offscreen vertically, considering the offset
+ const adjustedY = Math.min(Math.max(0, y), pageHeight - menuHeight);
+
+ setContextMenuPosition({
+ x: adjustedX,
+ y: adjustedY,
+ show: true,
+ id: id,
+ scrollYPosition: window.scrollY
+ });
+ });
+
+ return {
+ contextMenuPosition,
+ onContextMenuClick,
+ setContextMenuPosition
+ };
+};