mirror of https://github.com/buster-so/buster.git
create virtua list component
This commit is contained in:
parent
1bdf41a063
commit
ad142630d3
|
@ -93,7 +93,8 @@
|
||||||
"split-pane-react": "^0.1.3",
|
"split-pane-react": "^0.1.3",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"utility-types": "^3.11.0",
|
"utility-types": "^3.11.0",
|
||||||
"uuid": "^11.0.5"
|
"uuid": "^11.0.5",
|
||||||
|
"virtua": "^0.39.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
@ -584,7 +585,6 @@
|
||||||
},
|
},
|
||||||
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"extraneous": true,
|
|
||||||
"inBundle": true,
|
"inBundle": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -15886,6 +15886,36 @@
|
||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/vscode-jsonrpc": {
|
||||||
"version": "8.2.0",
|
"version": "8.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||||
|
|
|
@ -98,7 +98,8 @@
|
||||||
"split-pane-react": "^0.1.3",
|
"split-pane-react": "^0.1.3",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"utility-types": "^3.11.0",
|
"utility-types": "^3.11.0",
|
||||||
"uuid": "^11.0.5"
|
"uuid": "^11.0.5",
|
||||||
|
"virtua": "^0.39.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div className="h-[77vh] w-[66vw] border border-red-500 bg-white">
|
||||||
|
<BusterListVirtua columns={columns} rows={rows} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -9,7 +9,7 @@ import { getAllIdsInSection } from './helpers';
|
||||||
import { BusterListHeader } from './BusterListHeader';
|
import { BusterListHeader } from './BusterListHeader';
|
||||||
import { BusterListRowComponentSelector } from './BusterListRowComponentSelector';
|
import { BusterListRowComponentSelector } from './BusterListRowComponentSelector';
|
||||||
|
|
||||||
export const BusterList: React.FC<BusterListProps> = ({
|
export const BusterListReactWindow: React.FC<BusterListProps> = ({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows,
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
|
@ -194,5 +194,5 @@ export const BusterList: React.FC<BusterListProps> = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
BusterList.displayName = 'BusterList';
|
BusterListReactWindow.displayName = 'BusterList';
|
||||||
// Add a memoized checkbox component
|
// Add a memoized checkbox component
|
||||||
|
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="list-container relative flex h-full w-full flex-col overflow-hidden">
|
||||||
|
{showHeader && !showEmptyState && (
|
||||||
|
<BusterListHeader
|
||||||
|
columns={columns}
|
||||||
|
onGlobalSelectChange={onGlobalSelectChange}
|
||||||
|
globalCheckStatus={globalCheckStatus}
|
||||||
|
rowsLength={rows.length}
|
||||||
|
showSelectAll={showSelectAll}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showEmptyState && (
|
||||||
|
<VList>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div key={index} style={{ height: itemSize(index) }}>
|
||||||
|
<BusterListRowComponentSelector row={row} id={row.id} {...itemData} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</VList>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmptyState && (
|
||||||
|
<div className="flex h-full items-center justify-center">{emptyState}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{contextMenu && contextMenuPosition?.id && (
|
||||||
|
<BusterListContentMenu
|
||||||
|
ref={contextMenuRef}
|
||||||
|
open={!!contextMenuPosition?.show}
|
||||||
|
menu={contextMenu}
|
||||||
|
id={contextMenuPosition?.id || ''}
|
||||||
|
placement={{ x: contextMenuPosition?.x || 0, y: contextMenuPosition?.y || 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
BusterListVirtua.displayName = 'BusterListVirtua';
|
|
@ -1,3 +1,6 @@
|
||||||
export * from './BusterListReactWindow';
|
|
||||||
export * from './interfaces';
|
export * from './interfaces';
|
||||||
export * from './BusterListSelectedOptionPopup';
|
export * from './BusterListSelectedOptionPopup';
|
||||||
|
|
||||||
|
import { BusterListVirtua } from './BusterListVirtua';
|
||||||
|
|
||||||
|
export { BusterListVirtua as BusterList };
|
||||||
|
|
|
@ -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<HTMLDivElement>, 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
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue