mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
add utils to handle list multiselect / expand states
This commit is contained in:
@@ -34,6 +34,8 @@ interface ItemCardProps {
|
|||||||
data: Album | AlbumArtist | Artist | Playlist | Song;
|
data: Album | AlbumArtist | Artist | Playlist | Song;
|
||||||
isRound?: boolean;
|
isRound?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onItemExpand?: () => void;
|
||||||
|
onItemSelect?: () => void;
|
||||||
type?: 'compact' | 'default' | 'poster';
|
type?: 'compact' | 'default' | 'poster';
|
||||||
withControls?: boolean;
|
withControls?: boolean;
|
||||||
}
|
}
|
||||||
@@ -42,6 +44,8 @@ export const ItemCard = ({
|
|||||||
data,
|
data,
|
||||||
isRound,
|
isRound,
|
||||||
onClick,
|
onClick,
|
||||||
|
onItemExpand,
|
||||||
|
onItemSelect,
|
||||||
type = 'poster',
|
type = 'poster',
|
||||||
withControls,
|
withControls,
|
||||||
}: ItemCardProps) => {
|
}: ItemCardProps) => {
|
||||||
@@ -58,6 +62,8 @@ export const ItemCard = ({
|
|||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
isRound={isRound}
|
isRound={isRound}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onItemExpand={onItemExpand}
|
||||||
|
onItemSelect={onItemSelect}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
@@ -71,6 +77,8 @@ export const ItemCard = ({
|
|||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
isRound={isRound}
|
isRound={isRound}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onItemExpand={onItemExpand}
|
||||||
|
onItemSelect={onItemSelect}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
@@ -85,6 +93,8 @@ export const ItemCard = ({
|
|||||||
imageUrl={imageUrl}
|
imageUrl={imageUrl}
|
||||||
isRound={isRound}
|
isRound={isRound}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onItemExpand={onItemExpand}
|
||||||
|
onItemSelect={onItemSelect}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
setShowControls={setShowControls}
|
setShowControls={setShowControls}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
@@ -106,6 +116,8 @@ const CompactItemCard = ({
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
isRound,
|
isRound,
|
||||||
onClick,
|
onClick,
|
||||||
|
onItemExpand,
|
||||||
|
onItemSelect,
|
||||||
rows,
|
rows,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
showControls,
|
showControls,
|
||||||
@@ -141,6 +153,8 @@ const DefaultItemCard = ({
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
isRound,
|
isRound,
|
||||||
onClick,
|
onClick,
|
||||||
|
onItemExpand,
|
||||||
|
onItemSelect,
|
||||||
rows,
|
rows,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
showControls,
|
showControls,
|
||||||
@@ -151,6 +165,7 @@ const DefaultItemCard = ({
|
|||||||
<div
|
<div
|
||||||
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
|
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onDoubleClick={onItemExpand}
|
||||||
onMouseEnter={() => withControls && setShowControls(true)}
|
onMouseEnter={() => withControls && setShowControls(true)}
|
||||||
onMouseLeave={() => withControls && setShowControls(false)}
|
onMouseLeave={() => withControls && setShowControls(false)}
|
||||||
>
|
>
|
||||||
@@ -178,6 +193,8 @@ const PosterItemCard = ({
|
|||||||
imageUrl,
|
imageUrl,
|
||||||
isRound,
|
isRound,
|
||||||
onClick,
|
onClick,
|
||||||
|
onItemExpand,
|
||||||
|
onItemSelect,
|
||||||
rows,
|
rows,
|
||||||
setShowControls,
|
setShowControls,
|
||||||
showControls,
|
showControls,
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import clsx from 'clsx';
|
|
||||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
|
||||||
import {
|
|
||||||
CSSProperties,
|
|
||||||
forwardRef,
|
|
||||||
memo,
|
|
||||||
ReactNode,
|
|
||||||
Ref,
|
|
||||||
RefObject,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { GridComponents, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso';
|
|
||||||
|
|
||||||
import styles from './item-grid.module.css';
|
|
||||||
|
|
||||||
import { ItemCard } from '/@/renderer/components/item-card/item-card';
|
|
||||||
|
|
||||||
const gridComponents: GridComponents<any> = {
|
|
||||||
Item: forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{
|
|
||||||
children?: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
'data-index': number;
|
|
||||||
enableExpanded?: boolean;
|
|
||||||
style?: CSSProperties;
|
|
||||||
virtuosoRef?: RefObject<VirtuosoGridHandle>;
|
|
||||||
}
|
|
||||||
>((props, ref) => {
|
|
||||||
const { children, 'data-index': index, enableExpanded, virtuosoRef } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={clsx(styles.gridItemComponent)} ref={ref}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
List: forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{ children?: ReactNode; className?: string; style?: CSSProperties }
|
|
||||||
>((props, ref) => {
|
|
||||||
const { children, className, style, ...rest } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(styles.gridListComponent, className)}
|
|
||||||
ref={ref}
|
|
||||||
style={{ ...style }}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ItemGridProps<TData> {
|
|
||||||
data: TData[];
|
|
||||||
ref: Ref<VirtuosoGridHandle>;
|
|
||||||
totalItemCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ItemGrid = <TData,>({ data, ref, totalItemCount }: ItemGridProps<TData>) => {
|
|
||||||
const rootRef = useRef(null);
|
|
||||||
|
|
||||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const [initialize, osInstance] = useOverlayScrollbars({
|
|
||||||
defer: true,
|
|
||||||
options: {
|
|
||||||
overflow: { x: 'hidden', y: 'scroll' },
|
|
||||||
paddingAbsolute: true,
|
|
||||||
scrollbars: {
|
|
||||||
autoHide: 'leave',
|
|
||||||
autoHideDelay: 500,
|
|
||||||
pointers: ['mouse', 'pen', 'touch'],
|
|
||||||
theme: 'feishin-os-scrollbar',
|
|
||||||
visibility: 'visible',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { current: root } = rootRef;
|
|
||||||
|
|
||||||
if (scroller && root) {
|
|
||||||
initialize({
|
|
||||||
elements: { viewport: scroller },
|
|
||||||
target: root,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => osInstance()?.destroy();
|
|
||||||
}, [scroller, initialize, osInstance]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.itemGridContainer}
|
|
||||||
data-overlayscrollbars-initialize=""
|
|
||||||
ref={rootRef}
|
|
||||||
>
|
|
||||||
<VirtuosoGrid
|
|
||||||
components={gridComponents}
|
|
||||||
increaseViewportBy={200}
|
|
||||||
itemContent={itemContent}
|
|
||||||
ref={ref}
|
|
||||||
scrollerRef={setScroller}
|
|
||||||
totalCount={totalItemCount || data.length}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemContent = (index: number, item: any) => {
|
|
||||||
return <InnerItem index={index} item={item} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const InnerItem = memo(({ index, item }: { index: number; item: any }) => {
|
|
||||||
return <ItemCard data={item} withControls />;
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { ItemListAction, ItemListItem, ItemListState } from './item-list-state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action creators for item grid state management
|
||||||
|
* These can be reused across different components and contexts
|
||||||
|
*/
|
||||||
|
export const itemGridActions = {
|
||||||
|
clearAll: (): ItemListAction => ({
|
||||||
|
type: 'CLEAR_ALL',
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearExpanded: (): ItemListAction => ({
|
||||||
|
type: 'CLEAR_EXPANDED',
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearSelected: (): ItemListAction => ({
|
||||||
|
type: 'CLEAR_SELECTED',
|
||||||
|
}),
|
||||||
|
|
||||||
|
setExpanded: (items: ItemListItem[]): ItemListAction => ({
|
||||||
|
payload: items,
|
||||||
|
type: 'SET_EXPANDED',
|
||||||
|
}),
|
||||||
|
|
||||||
|
setSelected: (items: ItemListItem[]): ItemListAction => ({
|
||||||
|
payload: items,
|
||||||
|
type: 'SET_SELECTED',
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleExpanded: (item: ItemListItem): ItemListAction => ({
|
||||||
|
payload: item,
|
||||||
|
type: 'TOGGLE_EXPANDED',
|
||||||
|
}),
|
||||||
|
|
||||||
|
toggleSelected: (item: ItemListItem): ItemListAction => ({
|
||||||
|
payload: item,
|
||||||
|
type: 'TOGGLE_SELECTED',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selector functions for item grid state
|
||||||
|
* These can be reused to extract specific data from state
|
||||||
|
*/
|
||||||
|
export const itemGridSelectors = {
|
||||||
|
getExpanded: (state: ItemListState): ItemListItem[] => {
|
||||||
|
return Array.from(state.expandedItems.values());
|
||||||
|
},
|
||||||
|
|
||||||
|
getExpandedCount: (state: ItemListState): number => {
|
||||||
|
return state.expanded.size;
|
||||||
|
},
|
||||||
|
|
||||||
|
getExpandedIds: (state: ItemListState): string[] => {
|
||||||
|
return Array.from(state.expanded);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelected: (state: ItemListState): ItemListItem[] => {
|
||||||
|
return Array.from(state.selectedItems.values());
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedCount: (state: ItemListState): number => {
|
||||||
|
return state.selected.size;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedIds: (state: ItemListState): string[] => {
|
||||||
|
return Array.from(state.selected);
|
||||||
|
},
|
||||||
|
|
||||||
|
getVersion: (state: ItemListState): number => {
|
||||||
|
return state.version;
|
||||||
|
},
|
||||||
|
|
||||||
|
hasAnyExpanded: (state: ItemListState): boolean => {
|
||||||
|
return state.expanded.size > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
hasAnySelected: (state: ItemListState): boolean => {
|
||||||
|
return state.selected.size > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
isExpanded: (state: ItemListState, itemId: string): boolean => {
|
||||||
|
return state.expanded.has(itemId);
|
||||||
|
},
|
||||||
|
|
||||||
|
isSelected: (state: ItemListState, itemId: string): boolean => {
|
||||||
|
return state.selected.has(itemId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const itemListUtils = {
|
||||||
|
/**
|
||||||
|
* Check if all items in a list are selected
|
||||||
|
*/
|
||||||
|
areAllSelected: (state: ItemListState, itemIds: string[]): boolean => {
|
||||||
|
return itemIds.every((id) => state.selected.has(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any items in a list are selected
|
||||||
|
*/
|
||||||
|
areAnySelected: (state: ItemListState, itemIds: string[]): boolean => {
|
||||||
|
return itemIds.some((id) => state.selected.has(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if multiple items are expanded
|
||||||
|
*/
|
||||||
|
isMultiExpand: (state: ItemListState): boolean => {
|
||||||
|
return state.expanded.size > 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if multiple items are selected
|
||||||
|
*/
|
||||||
|
isMultiSelect: (state: ItemListState): boolean => {
|
||||||
|
return state.selected.size > 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle expansion of all items in a list
|
||||||
|
*/
|
||||||
|
toggleAllExpanded: (items: ItemListItem[], currentState: ItemListState): ItemListAction => {
|
||||||
|
const allExpanded = items.every((item) => currentState.expanded.has(item.id));
|
||||||
|
return allExpanded ? itemGridActions.clearExpanded() : itemGridActions.setExpanded(items);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle selection of all items in a list
|
||||||
|
*/
|
||||||
|
toggleAllSelected: (items: ItemListItem[], currentState: ItemListState): ItemListAction => {
|
||||||
|
const allSelected = items.every((item) => currentState.selected.has(item.id));
|
||||||
|
return allSelected ? itemGridActions.clearSelected() : itemGridActions.setSelected(items);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useCallback, useReducer } from 'react';
|
||||||
|
|
||||||
|
import { itemGridSelectors } from '/@/renderer/components/item-list/helpers/item-list-reducer-utils';
|
||||||
|
import { LibraryItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
|
export type ItemListAction =
|
||||||
|
| { payload: ItemListItem; type: 'TOGGLE_EXPANDED' }
|
||||||
|
| { payload: ItemListItem; type: 'TOGGLE_SELECTED' }
|
||||||
|
| { payload: ItemListItem[]; type: 'SET_EXPANDED' }
|
||||||
|
| { payload: ItemListItem[]; type: 'SET_SELECTED' }
|
||||||
|
| { type: 'CLEAR_ALL' }
|
||||||
|
| { type: 'CLEAR_EXPANDED' }
|
||||||
|
| { type: 'CLEAR_SELECTED' };
|
||||||
|
|
||||||
|
export interface ItemListItem {
|
||||||
|
id: string;
|
||||||
|
itemType: LibraryItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemListState {
|
||||||
|
expanded: Set<string>;
|
||||||
|
expandedItems: Map<string, ItemListItem>;
|
||||||
|
selected: Set<string>;
|
||||||
|
selectedItems: Map<string, ItemListItem>;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemListStateActions {
|
||||||
|
clearAll: () => void;
|
||||||
|
clearExpanded: () => void;
|
||||||
|
clearSelected: () => void;
|
||||||
|
getExpanded: () => ItemListItem[];
|
||||||
|
getExpandedIds: () => string[];
|
||||||
|
getSelected: () => ItemListItem[];
|
||||||
|
getSelectedIds: () => string[];
|
||||||
|
getVersion: () => number;
|
||||||
|
hasExpanded: () => boolean;
|
||||||
|
hasSelected: () => boolean;
|
||||||
|
isExpanded: (itemId: string) => boolean;
|
||||||
|
isSelected: (itemId: string) => boolean;
|
||||||
|
setExpanded: (items: ItemListItem[]) => void;
|
||||||
|
setSelected: (items: ItemListItem[]) => void;
|
||||||
|
toggleExpanded: (item: ItemListItem) => void;
|
||||||
|
toggleSelected: (item: ItemListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable reducer for item grid state management
|
||||||
|
* Can be used in different components or contexts
|
||||||
|
*/
|
||||||
|
export const itemListReducer = (state: ItemListState, action: ItemListAction): ItemListState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'CLEAR_ALL':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
expanded: new Set(),
|
||||||
|
expandedItems: new Map(),
|
||||||
|
selected: new Set(),
|
||||||
|
selectedItems: new Map(),
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'CLEAR_EXPANDED':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
expanded: new Set(),
|
||||||
|
expandedItems: new Map(),
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'CLEAR_SELECTED':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selected: new Set(),
|
||||||
|
selectedItems: new Map(),
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'SET_EXPANDED': {
|
||||||
|
const newExpanded = new Set<string>();
|
||||||
|
const newExpandedItems = new Map<string, ItemListItem>();
|
||||||
|
|
||||||
|
if (action.payload.length > 0) {
|
||||||
|
const firstItem = action.payload[0];
|
||||||
|
newExpanded.add(firstItem.id);
|
||||||
|
newExpandedItems.set(firstItem.id, firstItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
expanded: newExpanded,
|
||||||
|
expandedItems: newExpandedItems,
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_SELECTED': {
|
||||||
|
const newSelected = new Set<string>();
|
||||||
|
const newSelectedItems = new Map<string, ItemListItem>();
|
||||||
|
|
||||||
|
action.payload.forEach((item) => {
|
||||||
|
newSelected.add(item.id);
|
||||||
|
newSelectedItems.set(item.id, item);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selected: newSelected,
|
||||||
|
selectedItems: newSelectedItems,
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TOGGLE_EXPANDED': {
|
||||||
|
const newExpanded = new Set<string>();
|
||||||
|
const newExpandedItems = new Map<string, ItemListItem>();
|
||||||
|
|
||||||
|
// If the item is already expanded, collapse it
|
||||||
|
if (state.expanded.has(action.payload.id)) {
|
||||||
|
// Item is expanded, so collapse it (leave sets empty)
|
||||||
|
} else {
|
||||||
|
// Item is not expanded, so expand it (clear others first for single expansion)
|
||||||
|
newExpanded.add(action.payload.id);
|
||||||
|
newExpandedItems.set(action.payload.id, action.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
expanded: newExpanded,
|
||||||
|
expandedItems: newExpandedItems,
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TOGGLE_SELECTED': {
|
||||||
|
const newSelected = new Set(state.selected);
|
||||||
|
const newSelectedItems = new Map(state.selectedItems);
|
||||||
|
|
||||||
|
if (newSelected.has(action.payload.id)) {
|
||||||
|
newSelected.delete(action.payload.id);
|
||||||
|
newSelectedItems.delete(action.payload.id);
|
||||||
|
} else {
|
||||||
|
newSelected.add(action.payload.id);
|
||||||
|
newSelectedItems.set(action.payload.id, action.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selected: newSelected,
|
||||||
|
selectedItems: newSelectedItems,
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial state for item grid
|
||||||
|
*/
|
||||||
|
export const initialItemListState: ItemListState = {
|
||||||
|
expanded: new Set(),
|
||||||
|
expandedItems: new Map(),
|
||||||
|
selected: new Set(),
|
||||||
|
selectedItems: new Map(),
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useItemListState = (): ItemListStateActions => {
|
||||||
|
const [state, dispatch] = useReducer(itemListReducer, initialItemListState);
|
||||||
|
|
||||||
|
const setExpanded = useCallback((items: ItemListItem[]) => {
|
||||||
|
dispatch({ payload: items, type: 'SET_EXPANDED' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSelected = useCallback((items: ItemListItem[]) => {
|
||||||
|
dispatch({ payload: items, type: 'SET_SELECTED' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback((item: ItemListItem) => {
|
||||||
|
dispatch({ payload: item, type: 'TOGGLE_EXPANDED' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelected = useCallback((item: ItemListItem) => {
|
||||||
|
dispatch({ payload: item, type: 'TOGGLE_SELECTED' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isExpanded = useCallback(
|
||||||
|
(itemId: string) => {
|
||||||
|
return itemGridSelectors.isExpanded(state, itemId);
|
||||||
|
},
|
||||||
|
[state],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSelected = useCallback(
|
||||||
|
(itemId: string) => {
|
||||||
|
return itemGridSelectors.isSelected(state, itemId);
|
||||||
|
},
|
||||||
|
[state],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getExpanded = useCallback(() => {
|
||||||
|
return itemGridSelectors.getExpanded(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const getSelected = useCallback(() => {
|
||||||
|
return itemGridSelectors.getSelected(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const getExpandedIds = useCallback(() => {
|
||||||
|
return Array.from(state.expanded);
|
||||||
|
}, [state.expanded]);
|
||||||
|
|
||||||
|
const getSelectedIds = useCallback(() => {
|
||||||
|
return Array.from(state.selected);
|
||||||
|
}, [state.selected]);
|
||||||
|
|
||||||
|
const clearExpanded = useCallback(() => {
|
||||||
|
dispatch({ type: 'CLEAR_EXPANDED' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearSelected = useCallback(() => {
|
||||||
|
dispatch({ type: 'CLEAR_SELECTED' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
dispatch({ type: 'CLEAR_ALL' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getVersion = useCallback(() => {
|
||||||
|
return itemGridSelectors.getVersion(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const hasExpanded = useCallback(() => {
|
||||||
|
return itemGridSelectors.hasAnyExpanded(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const hasSelected = useCallback(() => {
|
||||||
|
return itemGridSelectors.hasAnySelected(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
clearAll,
|
||||||
|
clearExpanded,
|
||||||
|
clearSelected,
|
||||||
|
getExpanded,
|
||||||
|
getExpandedIds,
|
||||||
|
getSelected,
|
||||||
|
getSelectedIds,
|
||||||
|
getVersion,
|
||||||
|
hasExpanded,
|
||||||
|
hasSelected,
|
||||||
|
isExpanded,
|
||||||
|
isSelected,
|
||||||
|
setExpanded,
|
||||||
|
setSelected,
|
||||||
|
toggleExpanded,
|
||||||
|
toggleSelected,
|
||||||
|
};
|
||||||
|
};
|
||||||
+7
-1
@@ -1,10 +1,16 @@
|
|||||||
.item-grid-container {
|
.item-grid-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-list-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-right: var(--theme-spacing-md);
|
padding-right: var(--theme-spacing-md);
|
||||||
container-name: grid-list;
|
container-name: grid-list;
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-list-component {
|
.grid-list-component {
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { AnimatePresence, motion, Variants } from 'motion/react';
|
||||||
|
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||||
|
import {
|
||||||
|
CSSProperties,
|
||||||
|
forwardRef,
|
||||||
|
memo,
|
||||||
|
ReactNode,
|
||||||
|
Ref,
|
||||||
|
RefObject,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { GridComponents, VirtuosoGrid, VirtuosoGridHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import { ItemListItem, ItemListStateActions, useItemListState } from '../helpers/item-list-state';
|
||||||
|
import styles from './item-grid.module.css';
|
||||||
|
|
||||||
|
import { ItemCard } from '/@/renderer/components/item-card/item-card';
|
||||||
|
|
||||||
|
const gridComponents: GridComponents<any> = {
|
||||||
|
Item: forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
context?: ItemContext;
|
||||||
|
'data-index': number;
|
||||||
|
enableExpanded?: boolean;
|
||||||
|
style?: CSSProperties;
|
||||||
|
virtuosoRef?: RefObject<VirtuosoGridHandle>;
|
||||||
|
}
|
||||||
|
>((props, ref) => {
|
||||||
|
const { children, context, 'data-index': index } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.gridItemComponent)} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
List: forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{ children?: ReactNode; className?: string; style?: CSSProperties }
|
||||||
|
>((props, ref) => {
|
||||||
|
const { children, className, style, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.gridListComponent, className)}
|
||||||
|
ref={ref}
|
||||||
|
style={{ ...style }}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ItemContext {
|
||||||
|
actions: ItemListStateActions;
|
||||||
|
enableExpansion?: boolean;
|
||||||
|
enableSelection?: boolean;
|
||||||
|
onItemClick?: (item: unknown, index: number) => void;
|
||||||
|
onItemContextMenu?: (item: unknown, index: number) => void;
|
||||||
|
onItemDoubleClick?: (item: unknown, index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemGridProps {
|
||||||
|
data: unknown[];
|
||||||
|
enableExpansion?: boolean;
|
||||||
|
enableSelection?: boolean;
|
||||||
|
onItemClick?: (item: unknown, index: number) => void;
|
||||||
|
onItemContextMenu?: (item: unknown, index: number) => void;
|
||||||
|
onItemDoubleClick?: (item: unknown, index: number) => void;
|
||||||
|
ref: Ref<VirtuosoGridHandle>;
|
||||||
|
totalItemCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedAnimationVariants: Variants = {
|
||||||
|
hidden: {
|
||||||
|
height: 0,
|
||||||
|
maxHeight: 0,
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
height: '40dvh',
|
||||||
|
maxHeight: '500px',
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ItemGrid = ({
|
||||||
|
data,
|
||||||
|
enableExpansion = false,
|
||||||
|
enableSelection = false,
|
||||||
|
onItemClick,
|
||||||
|
onItemContextMenu,
|
||||||
|
onItemDoubleClick,
|
||||||
|
ref,
|
||||||
|
totalItemCount,
|
||||||
|
}: ItemGridProps) => {
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
|
||||||
|
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const actions = useItemListState();
|
||||||
|
|
||||||
|
const [initialize, osInstance] = useOverlayScrollbars({
|
||||||
|
defer: true,
|
||||||
|
options: {
|
||||||
|
overflow: { x: 'hidden', y: 'scroll' },
|
||||||
|
paddingAbsolute: true,
|
||||||
|
scrollbars: {
|
||||||
|
autoHide: 'leave',
|
||||||
|
autoHideDelay: 500,
|
||||||
|
pointers: ['mouse', 'pen', 'touch'],
|
||||||
|
theme: 'feishin-os-scrollbar',
|
||||||
|
visibility: 'visible',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current: root } = rootRef;
|
||||||
|
|
||||||
|
if (scroller && root) {
|
||||||
|
initialize({
|
||||||
|
elements: { viewport: scroller },
|
||||||
|
target: root,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => osInstance()?.destroy();
|
||||||
|
}, [scroller, initialize, osInstance]);
|
||||||
|
|
||||||
|
const itemContext = useMemo(
|
||||||
|
() => ({
|
||||||
|
actions,
|
||||||
|
enableExpansion,
|
||||||
|
enableSelection,
|
||||||
|
onItemClick,
|
||||||
|
onItemContextMenu,
|
||||||
|
onItemDoubleClick,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
actions,
|
||||||
|
enableExpansion,
|
||||||
|
enableSelection,
|
||||||
|
onItemClick,
|
||||||
|
onItemDoubleClick,
|
||||||
|
onItemContextMenu,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasExpanded = actions.hasExpanded();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.itemGridContainer}>
|
||||||
|
<div
|
||||||
|
className={styles.gridListContainer}
|
||||||
|
data-overlayscrollbars-initialize=""
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
<VirtuosoGrid
|
||||||
|
components={gridComponents}
|
||||||
|
context={itemContext}
|
||||||
|
data={data}
|
||||||
|
increaseViewportBy={200}
|
||||||
|
itemContent={itemContent}
|
||||||
|
ref={ref}
|
||||||
|
scrollerRef={setScroller}
|
||||||
|
totalCount={totalItemCount || data.length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{hasExpanded && (
|
||||||
|
<motion.div
|
||||||
|
animate="show"
|
||||||
|
className={styles.gridExpandedContainer}
|
||||||
|
exit="hidden"
|
||||||
|
initial="hidden"
|
||||||
|
variants={expandedAnimationVariants}
|
||||||
|
>
|
||||||
|
Hello World
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemContent = (index: number, item: any, context: ItemContext) => {
|
||||||
|
return <InnerItem context={context} index={index} item={item} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InnerItem = memo(
|
||||||
|
({ context, index, item }: { context: ItemContext; index: number; item: ItemListItem }) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
context.actions.toggleExpanded({ id: item.id, itemType: item.itemType });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ItemCard
|
||||||
|
data={item as any}
|
||||||
|
onClick={handleClick}
|
||||||
|
onItemExpand={() => context.onItemDoubleClick?.(item, index)}
|
||||||
|
withControls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user