add utils to handle list multiselect / expand states

This commit is contained in:
jeffvli
2025-09-25 19:20:12 -07:00
parent 7a2af3d013
commit 3ed6d4b2f7
6 changed files with 638 additions and 124 deletions
@@ -34,6 +34,8 @@ interface ItemCardProps {
data: Album | AlbumArtist | Artist | Playlist | Song;
isRound?: boolean;
onClick?: () => void;
onItemExpand?: () => void;
onItemSelect?: () => void;
type?: 'compact' | 'default' | 'poster';
withControls?: boolean;
}
@@ -42,6 +44,8 @@ export const ItemCard = ({
data,
isRound,
onClick,
onItemExpand,
onItemSelect,
type = 'poster',
withControls,
}: ItemCardProps) => {
@@ -58,6 +62,8 @@ export const ItemCard = ({
imageUrl={imageUrl}
isRound={isRound}
onClick={onClick}
onItemExpand={onItemExpand}
onItemSelect={onItemSelect}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
@@ -71,6 +77,8 @@ export const ItemCard = ({
imageUrl={imageUrl}
isRound={isRound}
onClick={onClick}
onItemExpand={onItemExpand}
onItemSelect={onItemSelect}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
@@ -85,6 +93,8 @@ export const ItemCard = ({
imageUrl={imageUrl}
isRound={isRound}
onClick={onClick}
onItemExpand={onItemExpand}
onItemSelect={onItemSelect}
rows={rows}
setShowControls={setShowControls}
showControls={showControls}
@@ -106,6 +116,8 @@ const CompactItemCard = ({
imageUrl,
isRound,
onClick,
onItemExpand,
onItemSelect,
rows,
setShowControls,
showControls,
@@ -141,6 +153,8 @@ const DefaultItemCard = ({
imageUrl,
isRound,
onClick,
onItemExpand,
onItemSelect,
rows,
setShowControls,
showControls,
@@ -151,6 +165,7 @@ const DefaultItemCard = ({
<div
className={clsx(styles.imageContainer, { [styles.isRound]: isRound })}
onClick={onClick}
onDoubleClick={onItemExpand}
onMouseEnter={() => withControls && setShowControls(true)}
onMouseLeave={() => withControls && setShowControls(false)}
>
@@ -178,6 +193,8 @@ const PosterItemCard = ({
imageUrl,
isRound,
onClick,
onItemExpand,
onItemSelect,
rows,
setShowControls,
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,
};
};
@@ -1,10 +1,16 @@
.item-grid-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.grid-list-container {
width: 100%;
height: 100%;
padding-right: var(--theme-spacing-md);
container-name: grid-list;
container-type: inline-size;
scrollbar-gutter: stable;
}
.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
/>
);
},
);