forked from sk1982/actaeon
refactor: use react-virtual
This commit is contained in:
parent
3eb24e5c9b
commit
9a6d5e8f6c
69
package-lock.json
generated
69
package-lock.json
generated
@ -12,6 +12,7 @@
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@next/bundle-analyzer": "^14.1.4",
|
||||
"@nextui-org/react": "^2.2.10",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"clsx": "^2.1.0",
|
||||
"db-migrate": "^0.11.14",
|
||||
@ -32,7 +33,6 @@
|
||||
"react-icons": "^5.0.1",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"sass": "^1.72.0",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
|
||||
@ -3586,6 +3586,31 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.2.0.tgz",
|
||||
"integrity": "sha512-OEdMByf2hEfDa6XDbGlZN8qO6bTjlNKqjM3im9JG+u3mCL8jALy0T/67oDI001raUUPh1Bdmfn4ZvPOV5knpcg==",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.2.0.tgz",
|
||||
"integrity": "sha512-P5XgYoAw/vfW65byBbJQCw+cagdXDT/qH6wmABiLt4v4YBT2q2vqCOhihe+D1Nt325F/S/0Tkv6C5z0Lv+VBQQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
|
||||
@ -5353,7 +5378,8 @@
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/cuint": {
|
||||
"version": "0.2.2",
|
||||
@ -5753,15 +5779,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-prop": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
|
||||
@ -9957,11 +9974,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/react-lifecycles-compat": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||
},
|
||||
"node_modules/react-remove-scroll-bar": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.5.tgz",
|
||||
@ -10041,31 +10053,6 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtualized": {
|
||||
"version": "9.22.5",
|
||||
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.5.tgz",
|
||||
"integrity": "sha512-YqQMRzlVANBv1L/7r63OHa2b0ZsAaDp1UhVNEdUaXI8A5u6hTpA5NYtUueLH2rFuY/27mTGIBl7ZhqFKzw18YQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"clsx": "^1.0.4",
|
||||
"dom-helpers": "^5.1.3",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.7.2",
|
||||
"react-lifecycles-compat": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtualized/node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/read": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
|
||||
|
@ -19,6 +19,7 @@
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@next/bundle-analyzer": "^14.1.4",
|
||||
"@nextui-org/react": "^2.2.10",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"clsx": "^2.1.0",
|
||||
"db-migrate": "^0.11.14",
|
||||
@ -39,7 +40,6 @@
|
||||
"react-icons": "^5.0.1",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-swipeable": "^7.0.1",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"sass": "^1.72.0",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-text-fill-stroke": "^2.0.0-beta.1",
|
||||
|
@ -165,13 +165,13 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
||||
</div>
|
||||
<div className="flex gap-2 w-full px-2 sm:px-1">
|
||||
<SelectModalButton className="flex-grow flex-1" displayMode="grid" modalSize="full" rowSize={230} colSize={500} gap={6} items={userboxItems.namePlate}
|
||||
modalId="nameplate"
|
||||
modalId="nameplate" itemId="id"
|
||||
renderItem={n => renderItem(n, getImageUrl(`chuni/name-plate/${n.imagePath}`), 'w-full sm:text-lg', 'px-2 pb-1')}
|
||||
selectedItem={equipped.namePlate} onSelected={i => equipItem('namePlate', i)}>
|
||||
Change Nameplate
|
||||
</SelectModalButton>
|
||||
<SelectModalButton className="flex-grow flex-1" displayMode="list" modalSize="2xl" rowSize={66} items={userboxItems.trophy}
|
||||
modalId="trophy"
|
||||
modalId="trophy" itemId="id"
|
||||
renderItem={n => <ChuniTrophy rarity={n.rareType} name={n.name} />}
|
||||
selectedItem={equipped.trophy} onSelected={i => equipItem('trophy', i)}>
|
||||
Change Trophy
|
||||
@ -209,7 +209,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 w-full px-2 sm:px-0 sm:flex flex-col gap-1.5 sm:ml-3 flex-grow">
|
||||
{(['avatarHead', 'avatarFace', 'avatarWear', 'avatarSkin', 'avatarItem', 'avatarBack'] as const).map(k => ((k !== 'avatarSkin' || userboxItems.avatarSkin.length > 1) && <SelectModalButton
|
||||
key={k} displayMode="grid" modalSize="3xl" colSize={175} rowSize={205} gap={5} modalId={k}
|
||||
key={k} displayMode="grid" modalSize="3xl" colSize={175} rowSize={205} gap={5} modalId={k} itemId="avatarAccessoryId"
|
||||
className={(k === 'avatarBack' && userboxItems.avatarSkin.length === 1) ? 'w-full col-span-full' : 'w-full'}
|
||||
onSelected={i => equipItem(k, i)} items={userboxItems[k]} selectedItem={equipped[k]}
|
||||
renderItem={i => renderItem(i, getImageUrl(`chuni/avatar/${i.iconPath}`)) }>
|
||||
@ -252,8 +252,8 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
||||
</Checkbox>
|
||||
{ voicePreview }
|
||||
<SelectModalButton selectedItem={equipped.systemVoice} items={userboxItems.systemVoice}
|
||||
displayMode="grid" rowSize={150} colSize={175} gap={6} modalSize="full"
|
||||
modalId="system-voice"
|
||||
displayMode="grid" rowSize={150} colSize={175} gap={6} modalSize="full"
|
||||
modalId="system-voice" itemId="id"
|
||||
footer={<><div className="flex flex-grow gap-2 items-center max-w-full sm:max-w-[min(100%,18rem)]">
|
||||
{ voicePreview }
|
||||
</div>
|
||||
@ -304,7 +304,7 @@ export const ChuniUserbox = ({ profile, userboxItems }: ChuniUserboxProps) => {
|
||||
<div className="px-2 w-full flex justify-center">
|
||||
<SelectModalButton onSelected={i => i && equipItem('mapIcon', i)} selectedItem={equipped.mapIcon}
|
||||
displayMode="grid" modalSize="full" rowSize={210} colSize={175} items={userboxItems.mapIcon} gap={6}
|
||||
className="w-full sm:w-auto mb-4" modalId="map-icon"
|
||||
className="w-full sm:w-auto mb-4" modalId="map-icon" itemId="id"
|
||||
renderItem={i => renderItem(i, getImageUrl(`chuni/map-icon/${i.imagePath}`))}>
|
||||
Change Map Icon
|
||||
</SelectModalButton>
|
||||
|
@ -4,16 +4,17 @@ import { Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, ModalProps }
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, ButtonProps, Input } from '@nextui-org/react';
|
||||
import { SearchIcon } from '@nextui-org/shared-icons';
|
||||
import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
import { CellMeasurerChildProps } from 'react-virtualized/dist/es/CellMeasurer';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useWindowListener } from '@/helpers/use-window-listener';
|
||||
import { useReloaded } from './client-providers';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
type Data<I extends string> = {
|
||||
name?: string | null,
|
||||
} & { [K in I]: any };
|
||||
|
||||
|
||||
export type SelectModalProps<T extends 'grid' | 'list', D extends { name?: string | null }> = {
|
||||
export type SelectModalProps<T extends 'grid' | 'list', I extends string, D extends Data<I>> = {
|
||||
isOpen: boolean,
|
||||
onSelected: (item: D | null | undefined) => void,
|
||||
selectedItem: D | null | undefined,
|
||||
@ -21,30 +22,121 @@ export type SelectModalProps<T extends 'grid' | 'list', D extends { name?: strin
|
||||
displayMode: T,
|
||||
rowSize: number,
|
||||
items: D[],
|
||||
renderItem: (item: D) => (ReactNode | ((props: Pick<CellMeasurerChildProps, 'measure'>) => ReactNode)),
|
||||
renderItem: (item: D) => ReactNode,
|
||||
gap?: number,
|
||||
onSelectionChanged?: (item: D) => void,
|
||||
footer?: ReactNode
|
||||
footer?: ReactNode,
|
||||
itemId: I
|
||||
} & (T extends 'grid' ? {
|
||||
colSize: number
|
||||
} : {
|
||||
colSize?: never
|
||||
});
|
||||
|
||||
const SelectModal = <T extends 'grid' | 'list', D extends { name?: string | null }>({ footer, onSelectionChanged, gap, selectedItem, renderItem, displayMode, items, isOpen, onSelected, modalSize, colSize, rowSize }: SelectModalProps<T, D>) => {
|
||||
const measurementCache = useMemo(() => {
|
||||
return new CellMeasurerCache({
|
||||
defaultHeight: rowSize,
|
||||
fixedWidth: true,
|
||||
minHeight: Math.ceil(rowSize / 3),
|
||||
keyMapper: () => window.innerWidth
|
||||
});
|
||||
}, [rowSize]);
|
||||
|
||||
const listRef = useRef<List | null>(null);
|
||||
const SelectModalList = <I extends string, D extends Data<I>>({ onSelectionChanged, setSelected, gap, rowSize, renderItem, items, selected, itemId }:
|
||||
Pick<SelectModalProps<'list', I, D>, 'itemId' | 'onSelectionChanged' | 'gap' | 'rowSize' | 'renderItem' | 'items'> & { selected?: D | null, setSelected: (d: D) => void }) => {
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastHeight = useRef(rowSize);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => lastHeight.current,
|
||||
overscan: 5,
|
||||
scrollingDelay: 0,
|
||||
measureElement: el => {
|
||||
return lastHeight.current = el.clientHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const buttonStyle = { height: `calc(100% - ${gap ?? 0}px)` };
|
||||
|
||||
return (<div ref={listRef} className="w-full overflow-y-auto overflow-x-hidden">
|
||||
<div className="w-full relative" style={{
|
||||
height: `${virtualizer.getTotalSize()}px`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map(item => {
|
||||
const data = items[item.index];
|
||||
const child = renderItem(data);
|
||||
|
||||
return (<div key={item.key} ref={virtualizer.measureElement} data-index={item.index}
|
||||
className="flex items-center justify-center absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${item.start}px)`
|
||||
}}>
|
||||
<Button
|
||||
style={buttonStyle}
|
||||
className={`w-full h-fit max-h-full px-0 transition ${data[itemId] === selected?.[itemId] ? 'bg-gray-400/75' : 'bg-transparent'}`}
|
||||
variant="flat" onPress={() => { setSelected(data); onSelectionChanged?.(data); }}>
|
||||
{child}
|
||||
</Button>
|
||||
</div>)
|
||||
})}
|
||||
</div>
|
||||
</div>)
|
||||
};
|
||||
|
||||
const SelectModalGrid = <I extends string, D extends Data<I>>({ onSelectionChanged, setSelected, gap, rowSize, renderItem, items, selected, colSize, itemId }:
|
||||
Pick<SelectModalProps<'grid', I, D>, 'itemId' | 'onSelectionChanged' | 'gap' | 'rowSize' | 'renderItem' | 'items' | 'colSize'> & { selected?: D | null, setSelected: (d: D) => void; }) => {
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastHeight = useRef(rowSize);
|
||||
const [width, setWidth] = useState(0)
|
||||
|
||||
const itemsPerRow = Math.max(1, Math.floor(width / colSize));
|
||||
const rowCount = Math.ceil(items.length / itemsPerRow);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => lastHeight.current,
|
||||
overscan: 5,
|
||||
scrollingDelay: 0,
|
||||
measureElement: el => {
|
||||
return lastHeight.current = el.clientHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const buttonStyle = {
|
||||
maxWidth: `${colSize}px`,
|
||||
aspectRatio: `${colSize}/${rowSize}`
|
||||
};
|
||||
const rowStyle = { gap: `${gap ?? 0}px`, paddingBottom: `${gap ?? 0}px` };
|
||||
|
||||
useEffect(() => setWidth(listRef.current?.clientWidth ?? 0), []);
|
||||
useWindowListener('resize', () => setWidth(listRef.current?.clientWidth ?? 0));
|
||||
|
||||
return (<div ref={listRef} className="w-full overflow-y-auto overflow-x-hidden">
|
||||
<div className="w-full relative" style={{
|
||||
height: `${virtualizer.getTotalSize()}px`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map(item => {
|
||||
const row = items.slice(item.index * itemsPerRow, (item.index + 1) * itemsPerRow);
|
||||
const children = row.map((item, i) => (<Button key={i} style={buttonStyle}
|
||||
className={`w-full px-0 h-full ${selected?.[itemId] === item[itemId] ? 'bg-gray-400/75' : 'bg-transparent'}`} onPress={() => {
|
||||
onSelectionChanged?.(item);
|
||||
setSelected(item);
|
||||
}}>
|
||||
{renderItem(item)}
|
||||
</Button>));
|
||||
|
||||
return (<div key={item.key} ref={virtualizer.measureElement} data-index={item.index}
|
||||
className="absolute top-0 left-0 w-full flex items-center justify-center"
|
||||
style={{
|
||||
transform: `translateY(${item.start}px)`,
|
||||
...rowStyle
|
||||
}}>
|
||||
{ children }
|
||||
{item.index === rowCount - 1 ? [...new Array(itemsPerRow - children.length)].map((_, i) =>
|
||||
<div key={i} style={buttonStyle} className="w-full h-full"></div>) : null }
|
||||
</div>)
|
||||
})}
|
||||
</div>
|
||||
</div>)
|
||||
};
|
||||
|
||||
const SelectModal = <T extends 'grid' | 'list', I extends string, D extends Data<I>>({ footer, onSelectionChanged, gap, selectedItem, renderItem, displayMode, items, isOpen, onSelected, modalSize, colSize, rowSize, itemId }: SelectModalProps<T, I, D>) => {
|
||||
const [selected, setSelected] = useState(selectedItem);
|
||||
const [filteredItems, setFilteredItems] = useState(items);
|
||||
const [gridRowCount, setGridRowCount] = useState(0);
|
||||
const outputSelected = useRef<null | undefined | D>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -55,118 +147,21 @@ const SelectModal = <T extends 'grid' | 'list', D extends { name?: string | null
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.recomputeRowHeights();
|
||||
}, [colSize, rowSize]);
|
||||
|
||||
const filter = useDebounceCallback((query: string) => {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
setFilteredItems(items.filter(({ name }) => name?.toLowerCase().includes(lowerQuery)));
|
||||
}, 100);
|
||||
|
||||
const recompute = useDebounceCallback(() => {
|
||||
listRef.current?.recomputeRowHeights();
|
||||
}, 150);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let prevWidth = -1;
|
||||
const cb = () => {
|
||||
if (prevWidth !== window.innerWidth) {
|
||||
prevWidth = window.innerWidth;
|
||||
recompute();
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', cb);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', cb);
|
||||
};
|
||||
}, [isOpen, recompute]);
|
||||
|
||||
const containerStyle = { pointerEvents: 'auto' } as const;
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (displayMode === 'list') {
|
||||
const buttonStyle = { height: `calc(100% - ${gap ?? 0}px)` };
|
||||
if (displayMode === 'list')
|
||||
return <SelectModalList onSelectionChanged={onSelectionChanged} gap={gap} rowSize={rowSize} renderItem={renderItem}
|
||||
items={filteredItems} selected={selected} setSelected={setSelected} itemId={itemId} />
|
||||
|
||||
return (<div style={{ flexBasis: `${filteredItems.length * rowSize}px` }}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (<List containerStyle={containerStyle}
|
||||
deferredMeasurementCache={measurementCache}
|
||||
rowCount={filteredItems.length}
|
||||
height={height}
|
||||
width={width}
|
||||
rowHeight={measurementCache.rowHeight}
|
||||
ref={listRef}
|
||||
rowRenderer={({ key, index, style, parent }) => {
|
||||
const child = renderItem(filteredItems[index]);
|
||||
|
||||
return (<CellMeasurer cache={measurementCache} parent={parent} columnIndex={0} rowIndex={index} key={key}>
|
||||
{({ measure, registerChild }) => {
|
||||
return (<div ref={registerChild as any} style={style} className="flex items-center justify-center">
|
||||
<Button
|
||||
style={buttonStyle}
|
||||
className={`w-full h-fit max-h-full px-0 transition ${filteredItems[index] === selected ? 'bg-gray-400/75' : 'bg-transparent'}`}
|
||||
variant="flat" onPress={() => { setSelected(filteredItems[index]); onSelectionChanged?.(filteredItems[index]) }}>
|
||||
{typeof child === 'function' ? child({ measure }) : child}
|
||||
</Button>
|
||||
</div>)
|
||||
}}
|
||||
</CellMeasurer>);
|
||||
}}
|
||||
/>)}
|
||||
</AutoSizer>
|
||||
</div>);
|
||||
}
|
||||
|
||||
const buttonStyle = { maxWidth: `${colSize}px`,
|
||||
aspectRatio: `${colSize}/${rowSize}`,
|
||||
height: `calc(100% - ${gap ?? 0}px)` };
|
||||
const rowStyle = { gap: `${gap ?? 0}px` };
|
||||
|
||||
return (<AutoSizer disableHeight className="flex flex-1 max-h-full overflow-hidden" style={{ flexBasis: `${gridRowCount * rowSize}px` }}>
|
||||
{(({ width }) => {
|
||||
const itemsPerRow = Math.max(1, Math.floor(width / colSize!));
|
||||
const rowCount = Math.ceil(filteredItems.length / itemsPerRow);
|
||||
setTimeout(() => setGridRowCount(rowCount));
|
||||
|
||||
return (<div style={{ flexBasis: `${rowCount * rowSize}px` }}>
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (<List ref={listRef}
|
||||
containerStyle={containerStyle}
|
||||
deferredMeasurementCache={measurementCache}
|
||||
rowCount={rowCount}
|
||||
height={height}
|
||||
width={width}
|
||||
rowHeight={measurementCache.rowHeight}
|
||||
rowRenderer={({ key, index, style, parent }) => (<CellMeasurer cache={measurementCache} parent={parent} columnIndex={0} rowIndex={index} key={key}>
|
||||
{({ measure, registerChild }) => {
|
||||
const children = filteredItems.slice(index * itemsPerRow, (index + 1) * itemsPerRow)
|
||||
.map((item, i) => {
|
||||
const res = renderItem(item);
|
||||
|
||||
return (<Button key={i} style={buttonStyle} className={`w-full px-0 ${selected === item ? 'bg-gray-400/75' : 'bg-transparent'}`}
|
||||
onPress={() => { setSelected(item); onSelectionChanged?.(item) }}>
|
||||
{ typeof res === 'function' ? res({ measure }) : res }
|
||||
</Button>)
|
||||
});
|
||||
|
||||
return <div style={{...style, ...rowStyle}} className="w-full h-full flex items-center justify-center" ref={registerChild as any}>
|
||||
{children}
|
||||
{ index === rowCount - 1 ? [...new Array(itemsPerRow - children.length)].map((_, i) =>
|
||||
<div key={i} style={buttonStyle} className="w-full"></div>) : null }
|
||||
</div>;
|
||||
}}
|
||||
</CellMeasurer>)} />)}
|
||||
</AutoSizer>
|
||||
</div>);
|
||||
})}
|
||||
</AutoSizer>)
|
||||
}, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gridRowCount, gap, onSelectionChanged]);
|
||||
return <SelectModalGrid onSelectionChanged={onSelectionChanged} gap={gap} rowSize={rowSize} renderItem={renderItem}
|
||||
items={filteredItems} selected={selected} setSelected={setSelected} colSize={colSize!} itemId={itemId} />
|
||||
}, [displayMode, filteredItems, colSize, rowSize, selected, isOpen, gap]);
|
||||
|
||||
return (<Modal classNames={{ wrapper: 'overflow-hidden' }} size={modalSize} onClose={() => {
|
||||
onSelected(outputSelected.current);
|
||||
@ -199,13 +194,12 @@ const SelectModal = <T extends 'grid' | 'list', D extends { name?: string | null
|
||||
</Modal>)
|
||||
};
|
||||
|
||||
export type SelectModalButtonProps<T extends 'grid' | 'list', D extends { name?: string | null }> = Omit<ButtonProps, 'onClick'> &
|
||||
Pick<SelectModalProps<T, D>, 'modalSize' | 'displayMode' | 'colSize' | 'rowSize' | 'items' | 'renderItem' | 'selectedItem' | 'onSelected' | 'gap' | 'onSelectionChanged' | 'footer'> &
|
||||
{ modalId: string };
|
||||
export const SelectModalButton = <T extends 'grid' | 'list', D extends { name?: string | null }>(props: SelectModalButtonProps<T, D>) => {
|
||||
export const SelectModalButton = <T extends 'grid' | 'list', I extends string, D extends Data<I>>(props: Omit<ButtonProps, 'onClick'> &
|
||||
Pick<SelectModalProps<T, I, D>, 'itemId' | 'modalSize' | 'displayMode' | 'colSize' | 'rowSize' | 'items' | 'renderItem' | 'selectedItem' | 'onSelected' | 'gap' | 'onSelectionChanged' | 'footer'> &
|
||||
{ modalId: string; }) => {
|
||||
const router = useRouter();
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const { modalId, footer, onSelectionChanged, gap, onSelected, selectedItem, renderItem, items, colSize, rowSize, displayMode, modalSize } = props;
|
||||
const { modalId, footer, onSelectionChanged, gap, onSelected, selectedItem, renderItem, items, colSize, rowSize, displayMode, modalSize, itemId } = props;
|
||||
const historyPushed = useRef(false);
|
||||
const reloaded = useReloaded();
|
||||
|
||||
@ -223,7 +217,7 @@ export const SelectModalButton = <T extends 'grid' | 'list', D extends { name?:
|
||||
|
||||
return (<>
|
||||
<SelectModal displayMode={displayMode} modalSize={modalSize} isOpen={isOpen} selectedItem={selectedItem} gap={gap} footer={footer}
|
||||
colSize={colSize as any} rowSize={rowSize} items={items} renderItem={renderItem} onSelectionChanged={onSelectionChanged}
|
||||
colSize={colSize as any} rowSize={rowSize} items={items} renderItem={renderItem} onSelectionChanged={onSelectionChanged} itemId={itemId}
|
||||
onSelected={item => {
|
||||
setOpen(false);
|
||||
onSelected(item);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ReactNode, useEffect, useRef } from 'react';
|
||||
import { AutoSizer, List, WindowScroller } from 'react-virtualized';
|
||||
import { ReactNode, useRef } from 'react';
|
||||
import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
|
||||
type WindowScrollerGridProps<D> = {
|
||||
rowSize: number,
|
||||
@ -9,27 +10,38 @@ type WindowScrollerGridProps<D> = {
|
||||
};
|
||||
|
||||
export const WindowScrollerGrid = <D extends any>({ rowSize, colSize, items, children }: WindowScrollerGridProps<D>) => {
|
||||
const listRef = useRef<List | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.recomputeRowHeights(0);
|
||||
}, [rowSize, colSize, items]);
|
||||
const { width = 0 } = useResizeObserver({
|
||||
ref: listRef
|
||||
});
|
||||
|
||||
return (<WindowScroller>{({ height, isScrolling, onChildScroll, scrollTop }) =>
|
||||
(<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const itemsPerRow = Math.max(1, Math.floor(width / colSize));
|
||||
const rowCount = Math.ceil(items.length / itemsPerRow);
|
||||
const itemsPerRow = Math.max(1, Math.floor(width / colSize));
|
||||
const rowCount = Math.ceil(items.length / itemsPerRow);
|
||||
|
||||
return (<List ref={listRef} autoHeight isScrolling={isScrolling} onScroll={onChildScroll} scrollTop={scrollTop}
|
||||
rowCount={rowCount} height={height} rowHeight={rowSize} width={width} rowRenderer={({ index, key, style }) =>
|
||||
(<div key={key} style={{ ...style, height: `${rowSize}px` }} className="max-w-full h-full w-full flex justify-evenly sm:justify-center">
|
||||
{items.slice(index * itemsPerRow, (index + 1) * itemsPerRow).map((item, i) => (<div key={i} style={{ width: `${colSize}px` }} className="h-full max-w-full">
|
||||
{children(item)}
|
||||
</div>))}
|
||||
</div>)
|
||||
} />)
|
||||
}}
|
||||
</AutoSizer>)
|
||||
}</WindowScroller>)
|
||||
const virtualizer = useWindowVirtualizer({
|
||||
count: rowCount,
|
||||
estimateSize: () => rowSize,
|
||||
scrollMargin: listRef.current?.offsetTop ?? 0,
|
||||
overscan: 5,
|
||||
scrollingDelay: 0
|
||||
});
|
||||
|
||||
return (<div ref={listRef} className={width <= 0 ? `invisible` : ''}>
|
||||
{width > 0 && <div className="w-full relative" style={{
|
||||
height: `${virtualizer.getTotalSize()}px`
|
||||
}}>
|
||||
{virtualizer.getVirtualItems().map(item => {
|
||||
const row = items.slice(item.index * itemsPerRow, (item.index + 1) * itemsPerRow);
|
||||
return (<div key={item.key} className="absolute top-0 left-0 max-w-full h-full w-full flex justify-evenly sm:justify-center" style={{
|
||||
height: `${rowSize}px`,
|
||||
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`
|
||||
}}>
|
||||
{row.map((item, i) => (<div key={i} style={{ width: `${colSize}px` }} className="h-full max-w-full">
|
||||
{children(item)}
|
||||
</div>))}
|
||||
</div>);
|
||||
})}
|
||||
</div>}
|
||||
</div>);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user