diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index dce21fcf0a..7de1fce9d6 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -20,7 +20,7 @@ import type {CnMods} from '../utils/cn'; import {TreeSelectItem} from './TreeSelectItem'; import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; import {useTreeSelectSelection, useValue} from './hooks/useTreeSelectSelection'; -import type {RenderControlProps, TreeSelectProps} from './types'; +import type {TreeSelectProps, TreeSelectRenderControlProps} from './types'; import './TreeSelect.scss'; @@ -135,15 +135,19 @@ export const TreeSelect = React.forwardRef(function TreeSelect( }; if (onItemClick) { - return onItemClick(defaultHandleClick, { - id, - isGroup: id in listParsedState.groupsState, - isLastItem: - listParsedState.visibleFlattenIds[ - listParsedState.visibleFlattenIds.length - 1 - ] === id, - disabled: listState.disabledById[id], - }); + return onItemClick( + listParsedState.itemsById[id], + { + id, + isGroup: id in listParsedState.groupsState, + isLastItem: + listParsedState.visibleFlattenIds[ + listParsedState.visibleFlattenIds.length - 1 + ] === id, + disabled: listState.disabledById[id], + }, + defaultHandleClick, + ); } return defaultHandleClick(); @@ -152,6 +156,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( onItemClick, listState, listParsedState.groupsState, + listParsedState.itemsById, listParsedState.visibleFlattenIds, groupsBehavior, multiple, @@ -188,7 +193,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]); - const controlProps: RenderControlProps = { + const controlProps: TreeSelectRenderControlProps = { open, toggleOpen, clearValue: handleClearValue, @@ -263,7 +268,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( id={`list-${treeSelectId}`} {...listParsedState} {...listState} - renderItem={(id, renderContextProps) => { + renderItem={(id, index, renderContextProps) => { const renderState = getItemRenderState({ id, size, @@ -277,12 +282,13 @@ export const TreeSelect = React.forwardRef(function TreeSelect( Boolean(multiple) && !renderState.context.groupState; if (renderItem) { - return renderItem( - renderState.data, - renderState.props, - renderState.context, - renderContextProps, - ); + return renderItem({ + data: renderState.data, + props: renderState.props, + itemState: renderState.context, + index, + renderContext: renderContextProps, + }); } const itemData = listParsedState.itemsById[id]; diff --git a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx index 5cfee9dddd..142ea642ae 100644 --- a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -18,10 +18,13 @@ export interface InfinityScrollExampleProps itemsCount?: number; } -export const InfinityScrollExample = ({itemsCount = 5, ...props}: InfinityScrollExampleProps) => { +export const InfinityScrollExample = ({ + itemsCount = 5, + ...storyProps +}: InfinityScrollExampleProps) => { const [value, setValue] = React.useState([]); const { - data = [], + data: items = [], onFetchMore, canFetchMore, isLoading, @@ -30,14 +33,14 @@ export const InfinityScrollExample = ({itemsCount = 5, ...props}: InfinityScroll return ( - {...props} - items={data} + {...storyProps} + items={items} value={value} - renderItem={(item, state, {isLastItem, groupState}) => { + renderItem={({data, props, itemState: {isLastItem, groupState}}) => { const node = ( {groupState.childrenIds.length} diff --git a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx index c3b80e6f97..6df91efc98 100644 --- a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx +++ b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {ListContainerView, computeItemSize} from '../../../useList'; import {VirtualizedListContainer} from '../../../useList/__stories__/components/VirtualizedListContainer'; -import type {RenderContainerProps} from '../../types'; +import type {TreeSelectRenderContainerProps} from '../../types'; // custom container renderer example export const RenderVirtualizedContainer = ({ @@ -11,7 +11,7 @@ export const RenderVirtualizedContainer = ({ visibleFlattenIds, renderItem, size, -}: RenderContainerProps) => { +}: TreeSelectRenderContainerProps) => { return ( , 'value' | 'onUpdate' | 'items' | 'getItemContent'> {} + extends Omit< + TreeSelectProps, + 'value' | 'onUpdate' | 'items' | 'getItemContent' | 'renderControlContent' + > {} -export const WithDndListExample = (props: WithDndListExampleProps) => { - const [items, setItems] = React.useState(() => - createRandomizedData({num: 10, depth: 0, getData: (title) => title}), - ); +const randomItems: CustomDataType[] = createRandomizedData({ + num: 10, + depth: 0, + getData: (title) => title, +}).map(({data}, idx) => ({someRandomKey: data, id: String(idx)})); + +export const WithDndListExample = (storyProps: WithDndListExampleProps) => { + const [items, setItems] = React.useState(randomItems); const [value, setValue] = React.useState([]); const handleDrugEnd: OnDragEndResponder = ({destination, source}) => { - if (destination?.index && destination?.index !== source.index) { + if (typeof destination?.index === 'number' && destination.index !== source.index) { setItems((items) => reorderArray(items, source.index, destination.index)); } }; @@ -52,10 +61,15 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { return ( { + // you can omit this prop here. If prop `id` passed, TreeSelect would take it by default + getId={({id}) => id} + renderControlContent={({someRandomKey}) => ({ + title: someRandomKey, + })} + onItemClick={(_data, {id, isGroup, disabled}) => { if (!isGroup && !disabled) { setValue([id]); } @@ -70,10 +84,14 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { snapshot: DraggableStateSnapshot, rubric: DraggableRubric, ) => { - return renderItem(visibleFlattenIds[rubric.source.index], { - provided, - active: snapshot.isDragging, - }); + return renderItem( + visibleFlattenIds[rubric.source.index], + rubric.source.index, + { + provided, + active: snapshot.isDragging, + }, + ); }} > {(droppableProvided: DroppableProvided) => ( @@ -82,7 +100,9 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { {...droppableProvided.droppableProps} ref={droppableProvided.innerRef} > - {visibleFlattenIds.map((id) => renderItem(id))} + {visibleFlattenIds.map((id, index) => + renderItem(id, index), + )} {droppableProvided.placeholder} @@ -91,10 +111,10 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { ); }} - renderItem={(item, state, _listContext, renderContextProps) => { + renderItem={({data, props, index, renderContext: renderContextProps}) => { const commonProps = { - ...state, - title: item, + ...props, + title: data.someRandomKey, endSlot: , }; @@ -102,7 +122,7 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { if (renderContextProps) { return ( @@ -110,9 +130,9 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { } return ( {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( { const {items, renderContainer} = React.useMemo(() => { const baseItems = createRandomizedData({num: itemsCount}); - const containerRenderer = (props: RenderContainerProps<{title: string}>) => { + const containerRenderer = (props: TreeSelectRenderContainerProps<{title: string}>) => { if (props.items.length === 0 && baseItems.length > 0) { return ( @@ -47,13 +47,12 @@ export const WithFiltrationAndControlsExample = ({ return ( { + itemState: {groupState}, + }) => { return ( } diff --git a/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx index 8b71822585..edb7920367 100644 --- a/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx @@ -42,14 +42,14 @@ export const WithItemLinksAndActionsExample = (props: WithItemLinksAndActionsExa setOpen((x) => !x); }} expandedById={expandedById} - renderItem={( - item, - { + renderItem={({ + data, + props: { expanded, // don't use build in expand icon ListItemView behavior ...state }, - {groupState}, - ) => { + itemState: {groupState}, + }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-is-valid ({ expandedById, renderItem, className, -}: RenderContainerProps & {className?: string}) => { + idToFlattenIndex, +}: TreeSelectRenderContainerProps & {className?: string}) => { return ( {items.map((itemSchema, index) => ( = ( - item: T, +export type TreeSelectRenderItem = (props: { + data: T; // required item props to render - state: RenderItemState, + props: RenderItemState; // internal list context props - context: RenderItemContext, - renderContextProps?: Object, -) => React.JSX.Element; + itemState: RenderItemContext; + index: number; + renderContext?: P; +}) => React.JSX.Element; -export type RenderContainerProps = ListParsedState & +export type TreeSelectRenderContainerProps = ListParsedState & ListState & { id: string; size: ListItemSize; - renderItem(id: ListItemId, renderContextProps?: Object): React.JSX.Element; + renderItem(id: ListItemId, index: number, renderContextProps?: Object): React.JSX.Element; containerRef: React.RefObject; className?: string; }; +export type TreeSelectRenderContainer = ( + props: TreeSelectRenderContainerProps, +) => React.JSX.Element; + interface TreeSelectBaseProps extends QAProps, Partial> { value?: ListItemId[]; defaultOpen?: boolean; @@ -85,21 +90,21 @@ interface TreeSelectBaseProps extends QAProps, Partial; + renderItem?: TreeSelectRenderItem; onClose?(): void; onUpdate?(value: ListItemId[], selectedItems: T[]): void; onOpenChange?(open: boolean): void; - renderContainer?(props: RenderContainerProps): React.JSX.Element; + renderContainer?: TreeSelectRenderContainer; /** * If you wont to disable default behavior pass `disabled` as a value; */ onItemClick?: | 'disabled' - | ((defaultClickCallback: () => void, content: OverrideItemContext) => void); + | ((data: T, content: OverrideItemContext, defaultClickCallback: () => void) => void); } type TreeSelectKnownProps = TreeSelectBaseProps & { diff --git a/src/components/useList/__stories__/components/InfinityScrollList.tsx b/src/components/useList/__stories__/components/InfinityScrollList.tsx index 75a07b5c26..7246c070ce 100644 --- a/src/components/useList/__stories__/components/InfinityScrollList.tsx +++ b/src/components/useList/__stories__/components/InfinityScrollList.tsx @@ -89,6 +89,7 @@ export const InfinityScrollList = ({size}: InfinityScrollListProps) => { key={index} index={index} expandedById={listState.expandedById} + idToFlattenIndex={list.idToFlattenIndex} > {(id) => { const {data, props, context} = getItemRenderState({ diff --git a/src/components/useList/__stories__/components/ListWithDnd.tsx b/src/components/useList/__stories__/components/ListWithDnd.tsx index c7f41be4fa..a11aa28d01 100644 --- a/src/components/useList/__stories__/components/ListWithDnd.tsx +++ b/src/components/useList/__stories__/components/ListWithDnd.tsx @@ -39,6 +39,7 @@ export const ListWithDnd = ({size, itemsCount}: ListWithDndProps) => { const listState = useListState(); const list = useList({ + getId: ({title}) => title, items: filterState.items, ...listState, }); diff --git a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx index c64c9480e8..4d59c657d5 100644 --- a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -104,6 +104,7 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro key={index} index={index} expandedById={listState.expandedById} + idToFlattenIndex={list.idToFlattenIndex} > {(id) => { const {data, props, context} = getItemRenderState({ diff --git a/src/components/useList/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx index b63ae4f048..66abfe7d03 100644 --- a/src/components/useList/__stories__/components/RecursiveList.tsx +++ b/src/components/useList/__stories__/components/RecursiveList.tsx @@ -79,6 +79,7 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { key={index} index={index} expandedById={listState.expandedById} + idToFlattenIndex={list.idToFlattenIndex} > {(id) => { const {data, props, context} = getItemRenderState({ diff --git a/src/components/useList/__stories__/utils/makeData.ts b/src/components/useList/__stories__/utils/makeData.ts index 0e4a86f7c9..91c614cdd9 100644 --- a/src/components/useList/__stories__/utils/makeData.ts +++ b/src/components/useList/__stories__/utils/makeData.ts @@ -1,6 +1,6 @@ import {faker} from '@faker-js/faker/locale/en'; -import type {ListItemType, ListTreeItemType} from '../../types'; +import type {ListTreeItemType} from '../../types'; const RANDOM_WORDS = Array(50) .fill(null) @@ -18,7 +18,7 @@ export function createRandomizedData({ num: number; depth?: number; getData?: (title: string) => T; -}): ListItemType[] { +}): ListTreeItemType[] { const data = []; for (let i = 0; i < num; i++) { diff --git a/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx index f73ab801ed..5689991c7e 100644 --- a/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx +++ b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx @@ -12,12 +12,13 @@ const b = block('list-recursive-renderer'); export interface ListRecursiveRendererProps extends Partial> { itemSchema: ListItemType; - children(id: ListItemId): React.JSX.Element; + children(id: ListItemId, index: number): React.JSX.Element; index: number; parentId?: string; className?: string; getId?(item: T): ListItemId; style?: React.CSSProperties; + idToFlattenIndex: Record; } // Saves the nested html structure for tree data structure @@ -30,7 +31,7 @@ export function ListItemRecursiveRenderer({ const groupedId = getGroupItemId(index, parentId); const id = getListItemId({item: itemSchema, groupedId, getId: props.getId}); - const node = props.children(id); + const node = props.children(id, props.idToFlattenIndex[id]); if (isTreeItemGuard(itemSchema) && itemSchema.children) { const isExpanded = diff --git a/src/components/useList/hooks/useList.ts b/src/components/useList/hooks/useList.ts index 7e6f184a4f..8e371f7701 100644 --- a/src/components/useList/hooks/useList.ts +++ b/src/components/useList/hooks/useList.ts @@ -23,7 +23,7 @@ export const useList = ({items, expandedById, getId}: UseListProps): UseLi getId, }); - const visibleFlattenIds = useFlattenListItems({ + const {visibleFlattenIds, idToFlattenIndex} = useFlattenListItems({ items, /** * By default controlled from list items declaration state @@ -32,5 +32,5 @@ export const useList = ({items, expandedById, getId}: UseListProps): UseLi getId, }); - return {items, visibleFlattenIds, itemsById, groupsState, itemsState}; + return {items, visibleFlattenIds, idToFlattenIndex, itemsById, groupsState, itemsState}; }; diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index 34615229ea..723fe0a239 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -54,6 +54,11 @@ export interface OverrideItemContext { } export type RenderItemContext = { + /** + * optional, because ids may be skipped in the flatten order list, + * depending on the expanded state + */ + visibleFlattenIndex?: number; itemState: ItemState; /** * Exists if item is group @@ -97,7 +102,12 @@ export type ListState = { activeItemId?: ListItemId; }; -export type ListParsedState = ParsedState & { - items: ListItemType[]; +export type ParsedFlattenState = { visibleFlattenIds: ListItemId[]; + idToFlattenIndex: Record; }; + +export type ListParsedState = ParsedState & + ParsedFlattenState & { + items: ListItemType[]; + }; diff --git a/src/components/useList/utils/flattenItems.test.ts b/src/components/useList/utils/flattenItems.test.ts index c872fa6f12..2fc9b44956 100644 --- a/src/components/useList/utils/flattenItems.test.ts +++ b/src/components/useList/utils/flattenItems.test.ts @@ -31,7 +31,10 @@ const data = [ describe('flattenItems', () => { test('should return expected result', () => { - expect(flattenItems(data)).toEqual(['0', '1', '1-0', '1-1', '1-1-0', '1-2', '2']); + expect(flattenItems(data)).toEqual({ + visibleFlattenIds: ['0', '1', '1-0', '1-1', '1-1-0', '1-2', '2'], + idToFlattenIndex: {0: 0, 1: 1, '1-0': 2, '1-1': 3, '1-1-0': 4, '1-2': 5, 2: 6}, + }); }); test('should return expected result with expanded state', () => { @@ -39,14 +42,17 @@ describe('flattenItems', () => { flattenItems(data, { '1': false, }), - ).toEqual(['0', '1', '2']); + ).toEqual({visibleFlattenIds: ['0', '1', '2'], idToFlattenIndex: {0: 0, 1: 1, 2: 2}}); }); test('should return expected result with expanded state 2', () => { expect( flattenItems(data, { '1-1': false, }), - ).toEqual(['0', '1', '1-0', '1-1', '1-2', '2']); + ).toEqual({ + visibleFlattenIds: ['0', '1', '1-0', '1-1', '1-2', '2'], + idToFlattenIndex: {0: 0, 1: 1, '1-0': 2, '1-1': 3, '1-2': 4, 2: 5}, + }); }); test('should return expected result with expanded state and id getter override', () => { @@ -58,6 +64,13 @@ describe('flattenItems', () => { }, ({title}) => title, ), - ).toEqual(['item-0', 'item-1', 'item-2']); + ).toEqual({ + visibleFlattenIds: ['item-0', 'item-1', 'item-2'], + idToFlattenIndex: { + 'item-0': 0, + 'item-1': 1, + 'item-2': 2, + }, + }); }); }); diff --git a/src/components/useList/utils/flattenItems.ts b/src/components/useList/utils/flattenItems.ts index e170da6b9f..d60f1760b0 100644 --- a/src/components/useList/utils/flattenItems.ts +++ b/src/components/useList/utils/flattenItems.ts @@ -1,4 +1,4 @@ -import type {ListItemId, ListItemType} from '../types'; +import type {ListItemId, ListItemType, ParsedFlattenState} from '../types'; import {getListItemId} from './getListItemId'; import {getGroupItemId} from './groupItemId'; @@ -8,7 +8,7 @@ export function flattenItems( items: ListItemType[], expandedById: Record = {}, getId?: (item: T) => ListItemId, -): ListItemId[] { +): ParsedFlattenState { if (process.env.NODE_ENV !== 'production') { console.time('flattenItems'); } @@ -39,10 +39,22 @@ export function flattenItems( return order; }; - const result = items.reduce((acc, item, index) => getNestedIds(acc, item, index), []); + const visibleFlattenIds = items.reduce( + (acc, item, index) => getNestedIds(acc, item, index), + [], + ); + + const idToFlattenIndex: Record = {}; + + for (const [item, index] of visibleFlattenIds.entries()) { + idToFlattenIndex[index] = item; + } if (process.env.NODE_ENV !== 'production') { console.timeEnd('flattenItems'); } - return result; + return { + visibleFlattenIds, + idToFlattenIndex, + }; } diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index 5bd07bdc4d..7971b91c73 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -31,10 +31,12 @@ export const getItemRenderState = ( selectedById, activeItemId, id, + idToFlattenIndex, }: ItemRendererProps, {defaultExpanded = true}: {defaultExpanded?: boolean} = {}, ) => { const context: RenderItemContext = { + visibleFlattenIndex: idToFlattenIndex[id], itemState: itemsState[id], groupState: groupsState[id], isLastItem: id === visibleFlattenIds[visibleFlattenIds.length - 1],