From 5e7d8b9af214ecc084375e681e0f8d64dc933c2c Mon Sep 17 00:00:00 2001 From: Isaev Alexandr Date: Mon, 18 Mar 2024 17:20:48 +0300 Subject: [PATCH] feat(TreeList): new useList family component (#1417) Co-authored-by: Alexandr Isaev --- .../Table/__stories__/Table.stories.tsx | 9 +- .../TableColumnSetup/TableColumnSetup.scss | 11 -- .../TableColumnSetup/TableColumnSetup.tsx | 11 +- src/components/TreeList/TreeList.tsx | 127 ++++++++++++ .../TreeList/__stories__/TreeList.stories.tsx | 58 ++++++ .../components/RenderVirtualizedContainer.tsx | 4 +- .../__stories__/stories/DefaultStory.tsx | 25 +++ .../stories/InfinityScrollStory.tsx | 97 ++++++++++ .../__stories__/stories/WithDndListStory.tsx | 165 ++++++++++++++++ .../WithFiltrationAndControlsStory.tsx | 111 +++++++++++ .../WithGroupSelectionAndCustomIconStory.tsx | 105 ++++++++++ .../stories/WithItemLinksAndActionsStory.tsx | 111 +++++++++++ .../TreeListContainer/TreeListContainer.tsx | 4 +- src/components/TreeList/index.ts | 2 + src/components/TreeList/types.ts | 89 +++++++++ src/components/TreeSelect/TreeSelect.tsx | 182 +++++++----------- .../__stories__/TreeSelect.stories.tsx | 3 +- .../components/InfinityScrollExample.tsx | 9 +- .../components/WithDndListExample.tsx | 168 ++++++++-------- .../WithFiltrationAndControlsExample.tsx | 12 +- ...pSelectionControlledStateAndCustomIcon.tsx | 4 +- .../WithItemLinksAndActionsExample.tsx | 10 +- .../hooks/useTreeSelectSelection.ts | 12 +- src/components/TreeSelect/types.ts | 52 ++--- .../__stories__/components/FlattenList.tsx | 5 +- .../components/InfinityScrollList.tsx | 5 +- .../__stories__/components/ListWithDnd.tsx | 6 +- .../components/PopupWithTogglerList.tsx | 4 +- .../__stories__/components/RecursiveList.tsx | 9 +- .../useList/__stories__/useList.mdx | 4 + .../components/ListItemView/ListItemView.scss | 1 + .../components/ListItemView/ListItemView.tsx | 8 +- src/components/useList/hooks/useList.ts | 20 +- src/components/useList/index.ts | 1 - src/components/useList/types.ts | 16 +- src/components/useList/utils.ts | 5 - .../useList/utils/getItemRenderState.tsx | 18 +- .../useList/utils/getListParsedState.ts | 4 +- src/unstable.ts | 4 + 39 files changed, 1181 insertions(+), 310 deletions(-) create mode 100644 src/components/TreeList/TreeList.tsx create mode 100644 src/components/TreeList/__stories__/TreeList.stories.tsx rename src/components/{TreeSelect => TreeList}/__stories__/components/RenderVirtualizedContainer.tsx (86%) create mode 100644 src/components/TreeList/__stories__/stories/DefaultStory.tsx create mode 100644 src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx create mode 100644 src/components/TreeList/__stories__/stories/WithDndListStory.tsx create mode 100644 src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx create mode 100644 src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx create mode 100644 src/components/TreeList/__stories__/stories/WithItemLinksAndActionsStory.tsx rename src/components/{TreeSelect => TreeList}/components/TreeListContainer/TreeListContainer.tsx (87%) create mode 100644 src/components/TreeList/index.ts create mode 100644 src/components/TreeList/types.ts delete mode 100644 src/components/useList/utils.ts diff --git a/src/components/Table/__stories__/Table.stories.tsx b/src/components/Table/__stories__/Table.stories.tsx index eb80ae30ae..c6ec7b22de 100644 --- a/src/components/Table/__stories__/Table.stories.tsx +++ b/src/components/Table/__stories__/Table.stories.tsx @@ -143,7 +143,14 @@ const WithTableActionsTemplate: StoryFn> = (args) => { } const items = ['action 1', 'action 2', 'action 3']; - return ; + + return ( + ({title})} + /> + ); }} /> diff --git a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.scss b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.scss index 11820473db..a9a535280c 100644 --- a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.scss +++ b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.scss @@ -6,15 +6,4 @@ $block: '.#{variables.$ns}table-column-setup'; &__controls { margin: var(--g-spacing-1) var(--g-spacing-1) 0; } - - // to override this https://github.com/gravity-ui/uikit/blob/main/src/components/useList/components/ListItemView/ListItemView.scss#L25 - &__required-item { - background: inherit; - - &:hover { - /* stylelint-disable declaration-no-important */ - background: var(--g-color-base-simple-hover-solid) !important; - /* stylelint-enable declaration-no-important */ - } - } } diff --git a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx index 4ad867e1cd..1af82a7d1d 100644 --- a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx +++ b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx @@ -25,10 +25,13 @@ import i18n from './i18n'; import './TableColumnSetup.scss'; +function identity(value: T): T { + return value; +} + const b = block('table-column-setup'); const tableColumnSetupCn = b(null); const controlsCn = b('controls'); -const requiredDndItemCn = b('required-item'); const reorderArray = (list: T[], startIndex: number, endIndex: number): T[] => { const result = [...list]; @@ -46,8 +49,7 @@ const prepareDndItems = (tableColumnItems: TableColumnSetupItem[]) => { ...tableColumnItem, startSlot: tableColumnItem.isRequired ? : undefined, hasSelectionIcon, - // to overwrite select background effect - https://github.com/gravity-ui/uikit/blob/main/src/components/useList/components/ListItemView/ListItemView.tsx#L125 - className: hasSelectionIcon ? undefined : requiredDndItemCn, + selected: hasSelectionIcon ? tableColumnItem.isSelected : undefined, }; }); }; @@ -152,7 +154,7 @@ const useDndRenderItem = (sortable: boolean | undefined) => { {...provided.draggableProps} {...provided.dragHandleProps} style={style} - active={snapshot.isDragging} + dragging={snapshot.isDragging} /> ); }} @@ -277,6 +279,7 @@ export const TableColumnSetup = (props: TableColumnSetupProps) => { return ( ({ + id, + size = 'm', + items, + className, + expandedById, + disabledById, + activeItemId, + selectedById, + getId, + renderItem: propsRenderItem, + renderContainer = TreeListContainer, + onItemClick, + multiple, + setActiveItemId, + containerRef: propsContainerRef, + mapItemDataToProps, +}: TreeListProps) => { + const uniqId = useUniqId(); + const treeListId = id ?? uniqId; + const containerRefLocal = React.useRef(null); + const containerRef = propsContainerRef ?? containerRefLocal; + + const listParsedState = useList({ + items, + getId, + expandedById, + disabledById, + activeItemId, + selectedById, + }); + + const handleItemClick = React.useCallback( + (listItemId: ListItemId) => { + onItemClick?.({ + id: listItemId, + data: listParsedState.itemsById[listItemId], + disabled: disabledById + ? Boolean(disabledById[listItemId]) + : Boolean(listParsedState.initialState.disabledById[listItemId]), + isLastItem: + listParsedState.visibleFlattenIds[ + listParsedState.visibleFlattenIds.length - 1 + ] === listItemId, + groupState: listParsedState.groupsState[listItemId], + itemState: listParsedState.itemsState[listItemId], + }); + }, + [ + disabledById, + listParsedState.groupsState, + listParsedState.initialState.disabledById, + listParsedState.itemsById, + listParsedState.itemsState, + listParsedState.visibleFlattenIds, + onItemClick, + ], + ); + + useListKeydown({ + containerRef, + onItemClick: handleItemClick, + ...listParsedState, + activeItemId, + disabledById, + setActiveItemId, + }); + + const renderItem: TreeListRenderContainerProps['renderItem'] = ( + itemId, + index, + renderContextProps, + ) => { + const renderState = getItemRenderState({ + id: itemId, + size, + mapItemDataToProps, + onItemClick: handleItemClick, + ...listParsedState, + expandedById, + disabledById, + activeItemId, + selectedById, + }); + + // redefining the view logic for groups and multiple selection of list items + renderState.props.hasSelectionIcon = Boolean(multiple) && !renderState.context.groupState; + + if (propsRenderItem) { + return propsRenderItem({ + data: renderState.data, + props: renderState.props, + itemState: renderState.context, + index, + renderContext: renderContextProps, + }); + } + + return ; + }; + + // not JSX decl here is from weird `react-beautiful-dnd` render bug + return renderContainer({ + id: `list-${treeListId}`, + size, + containerRef, + className: b(null, className), + ...listParsedState, + expandedById, + disabledById, + activeItemId, + selectedById, + renderItem, + }); +}; diff --git a/src/components/TreeList/__stories__/TreeList.stories.tsx b/src/components/TreeList/__stories__/TreeList.stories.tsx new file mode 100644 index 0000000000..99100b4d31 --- /dev/null +++ b/src/components/TreeList/__stories__/TreeList.stories.tsx @@ -0,0 +1,58 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {TreeList} from '../TreeList'; + +import {DefaultStory} from './stories/DefaultStory'; +import {InfinityScrollStory} from './stories/InfinityScrollStory'; +import {WithDndListStory} from './stories/WithDndListStory'; +import {WithFiltrationAndControlsStory} from './stories/WithFiltrationAndControlsStory'; +import {WithGroupSelectionAndCustomIconStory} from './stories/WithGroupSelectionAndCustomIconStory'; +import {WithItemLinksAndActionsStory} from './stories/WithItemLinksAndActionsStory'; + +export default { + title: 'Unstable/TreeList', + component: TreeList, +} as Meta; + +type DefaultStoryObj = StoryObj; + +export const Default: DefaultStoryObj = { + render: DefaultStory, +}; + +type InfinityScrollStoryObj = StoryObj; + +export const InfinityScroll: InfinityScrollStoryObj = { + render: InfinityScrollStory, +}; + +type WithDndListStoryObj = StoryObj; + +export const WithDndList: WithDndListStoryObj = { + parameters: { + // Strict mode ruins sortable list due to this react-beautiful-dnd issue + // https://github.com/atlassian/react-beautiful-dnd/issues/2350 + disableStrictMode: true, + }, + render: WithDndListStory, +}; + +type WithFiltrationAndControlsStoryObj = StoryObj; + +export const WithFiltrationAndControls: WithFiltrationAndControlsStoryObj = { + render: WithFiltrationAndControlsStory, +}; + +type WithGroupSelectionAndCustomIconStoryObj = StoryObj< + typeof WithGroupSelectionAndCustomIconStory +>; + +export const WithGroupSelectionAndCustomIcon: WithGroupSelectionAndCustomIconStoryObj = { + render: WithGroupSelectionAndCustomIconStory, +}; + +type WithItemLinksAndActionsStoryObj = StoryObj; + +export const WithItemLinksAndActions: WithItemLinksAndActionsStoryObj = { + render: WithItemLinksAndActionsStory, +}; diff --git a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx similarity index 86% rename from src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx rename to src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx index 6df91efc98..0ed4d69b01 100644 --- a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx +++ b/src/components/TreeList/__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 {TreeSelectRenderContainerProps} from '../../types'; +import type {TreeListRenderContainerProps} from '../../types'; // custom container renderer example export const RenderVirtualizedContainer = ({ @@ -11,7 +11,7 @@ export const RenderVirtualizedContainer = ({ visibleFlattenIds, renderItem, size, -}: TreeSelectRenderContainerProps) => { +}: TreeListRenderContainerProps) => { return ( (value: T): T { + return value; +} + +export interface DefaultStoryProps + extends Omit, 'items' | 'mapItemDataToProps'> { + itemsCount?: number; +} + +export const DefaultStory = ({itemsCount = 5, ...props}: DefaultStoryProps) => { + const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); + + return ( + + + + ); +}; diff --git a/src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx b/src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx new file mode 100644 index 0000000000..756cd694ac --- /dev/null +++ b/src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx @@ -0,0 +1,97 @@ +import React from 'react'; + +import {Label} from '../../../Label'; +import {Loader} from '../../../Loader'; +import {Flex, spacing} from '../../../layout'; +import {ListItemView, useListState} from '../../../useList'; +import {IntersectionContainer} from '../../../useList/__stories__/components/IntersectionContainer/IntersectionContainer'; +import {useInfinityFetch} from '../../../useList/__stories__/utils/useInfinityFetch'; +import {TreeList} from '../../TreeList'; +import type {TreeListOnItemClick, TreeListProps} from '../../types'; +import {RenderVirtualizedContainer} from '../components/RenderVirtualizedContainer'; + +function identity(value: T): T { + return value; +} + +export interface InfinityScrollStoryProps + extends Omit< + TreeListProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'multiple' | 'size' | 'mapItemDataToProps' + > { + itemsCount?: number; +} + +export const InfinityScrollStory = ({itemsCount = 5, ...storyProps}: InfinityScrollStoryProps) => { + const listState = useListState(); + + const handleItemClick: TreeListOnItemClick<{title: string}> = ({id, groupState, disabled}) => { + if (disabled) return; + + listState.setActiveItemId(id); + + if (groupState) { + listState.setExpanded((prevState) => ({ + ...prevState, + [id]: id in prevState ? !prevState[id] : false, + })); + } else { + listState.setSelected((prevState) => ({ + ...prevState, + [id]: !prevState[id], + })); + } + }; + + const { + data: items = [], + onFetchMore, + canFetchMore, + isLoading, + } = useInfinityFetch<{title: string}>(itemsCount, true); + + return ( + + { + const node = ( + {groupState.childrenIds.length} + ) : undefined + } + /> + ); + + if (isLastItem) { + return ( + + {node} + + ); + } + + return node; + }} + renderContainer={RenderVirtualizedContainer} + /> + {isLoading && ( + + + + )} + + ); +}; diff --git a/src/components/TreeList/__stories__/stories/WithDndListStory.tsx b/src/components/TreeList/__stories__/stories/WithDndListStory.tsx new file mode 100644 index 0000000000..b05f482f3c --- /dev/null +++ b/src/components/TreeList/__stories__/stories/WithDndListStory.tsx @@ -0,0 +1,165 @@ +import React from 'react'; + +import {Grip} from '@gravity-ui/icons'; +import {DragDropContext, Draggable, Droppable} from 'react-beautiful-dnd'; +import type { + DraggableProvided, + DraggableRubric, + DraggableStateSnapshot, + DroppableProvided, + OnDragEndResponder, +} from 'react-beautiful-dnd'; + +import {Icon} from '../../../Icon'; +import {ListContainerView, ListItemView, useListState} from '../../../useList'; +import type {ListItemViewProps} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {reorderArray} from '../../../useList/__stories__/utils/reorderArray'; +import {TreeList} from '../../TreeList'; +import type {TreeListProps, TreeListRenderContainer, TreeListRenderItem} from '../../types'; + +const DraggableListItem = ({ + provided, + ...props +}: {provided?: DraggableProvided} & ListItemViewProps) => { + return ( + + ); +}; + +type CustomDataType = {someRandomKey: string; id: string}; + +const randomItems: CustomDataType[] = createRandomizedData({ + num: 10, + depth: 0, + getData: (title) => title, +}).map(({data}, idx) => ({someRandomKey: data, id: String(idx)})); + +export interface WithDndListStoryProps + extends Omit, 'items' | 'mapItemDataToProps'> {} + +export const WithDndListStory = (storyProps: WithDndListStoryProps) => { + const [items, setItems] = React.useState(randomItems); + const containerRef = React.useRef(null); + const listState = useListState(); + + React.useLayoutEffect(() => { + containerRef?.current?.focus(); + }, []); + + const renderContainer: TreeListRenderContainer = ({ + renderItem, + visibleFlattenIds, + containerRef, + id, + }) => { + const handleDrugEnd: OnDragEndResponder = ({destination, source}) => { + if (typeof destination?.index === 'number' && destination.index !== source.index) { + setItems((currentItems) => + reorderArray(currentItems, source.index, destination.index), + ); + + listState.setActiveItemId(`${destination.index}`); + } + }; + + return ( + + { + return renderItem( + visibleFlattenIds[rubric.source.index], + rubric.source.index, + { + provided, + dragging: snapshot.isDragging, + }, + ); + }} + > + {(droppableProvided: DroppableProvided) => ( + +
+ {visibleFlattenIds.map((listItemId, index) => + renderItem(listItemId, index), + )} + {droppableProvided.placeholder} +
+
+ )} +
+
+ ); + }; + + const renderItem: TreeListRenderItem = ({ + data, + props, + index, + renderContext: renderContextProps, + }) => { + const commonProps = { + ...props, + title: data.someRandomKey, + endSlot: , + }; + + // here passed props from `renderContainer` method. + if (renderContextProps) { + return ( + + ); + } + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( + + )} + + ); + }; + + return ( + ({title: someRandomKey})} + // you can omit this prop here. If prop `id` passed, TreeSelect would take it by default + getId={({id}) => id} + onItemClick={({id, groupState, disabled}) => { + if (!groupState && !disabled) { + listState.setSelected((prevState) => ({ + [id]: !prevState[id], + })); + + listState.setActiveItemId(id); + } + }} + renderContainer={renderContainer} + renderItem={renderItem} + /> + ); +}; diff --git a/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx b/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx new file mode 100644 index 0000000000..534436d04d --- /dev/null +++ b/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import {Button} from '../../../Button'; +import {Text} from '../../../Text'; +import {TextInput} from '../../../controls'; +import {Flex, spacing} from '../../../layout'; +import {useListFilter, useListState} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {TreeList} from '../../TreeList'; +import type {TreeListProps, TreeListRenderContainerProps} from '../../types'; +import {RenderVirtualizedContainer} from '../components/RenderVirtualizedContainer'; + +export interface WithFiltrationAndControlsStoryProps + extends Omit< + TreeListProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' + > { + itemsCount?: number; +} + +export const WithFiltrationAndControlsStory = ({ + itemsCount = 5, + ...treeSelectProps +}: WithFiltrationAndControlsStoryProps) => { + const {items, renderContainer} = React.useMemo(() => { + const baseItems = createRandomizedData({num: itemsCount}); + const containerRenderer = (props: TreeListRenderContainerProps<{title: string}>) => { + if (props.items.length === 0 && baseItems.length > 0) { + return ( + + Nothing found + + ); + } + + return ; + }; + + return {items: baseItems, renderContainer: containerRenderer}; + }, [itemsCount]); + + const listState = useListState(); + + const filterState = useListFilter({items}); + + return ( + + + { + if (disabled) return; + + if (groupState) { + listState.setExpanded((prevState) => ({ + ...prevState, + [id]: id in prevState ? !prevState[id] : false, + })); + } else { + listState.setSelected((prevState) => + treeSelectProps.multiple + ? { + ...prevState, + [id]: !prevState[id], + } + : { + [id]: !prevState[id], + }, + ); + } + + listState.setActiveItemId(id); + }} + mapItemDataToProps={(x) => x} + renderContainer={renderContainer} + items={filterState.items} + /> + + + + + + ); +}; diff --git a/src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx b/src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx new file mode 100644 index 0000000000..5c42291911 --- /dev/null +++ b/src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx @@ -0,0 +1,105 @@ +import React from 'react'; + +import {ChevronDown, ChevronUp, Database, PlugConnection} from '@gravity-ui/icons'; + +import {Button} from '../../../Button'; +import {Icon} from '../../../Icon'; +import {Flex, spacing} from '../../../layout'; +import {ListItemView, useListState} from '../../../useList'; +import type {KnownItemStructure} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {TreeList} from '../../TreeList'; +import type {TreeListOnItemClick, TreeListProps} from '../../types'; + +/** + * Just for example how to work with data + */ +interface CustomDataStructure { + a: string; +} + +export interface WithGroupSelectionAndCustomIconStoryProps + extends Omit< + TreeListProps, + 'value' | 'onUpdate' | 'items' | 'multiple' | 'cantainerRef' | 'size' | 'mapItemDataToProps' + > { + itemsCount?: number; +} + +const mapCustomDataStructureToKnownProps = (props: CustomDataStructure): KnownItemStructure => ({ + title: props.a, +}); + +export const WithGroupSelectionAndCustomIconStory = ({ + itemsCount = 5, + ...props +}: WithGroupSelectionAndCustomIconStoryProps) => { + const items = React.useMemo( + () => createRandomizedData({num: itemsCount, getData: (a) => ({a})}), + [itemsCount], + ); + + const listState = useListState(); + + const handleItemClick: TreeListOnItemClick = ({id, disabled}) => { + if (disabled) return; + + listState.setSelected((prevState) => ({ + [id]: !prevState[id], + })); + + listState.setActiveItemId(id); + }; + + return ( + + { + return ( + + } + endSlot={ + groupState ? ( + + ) : undefined + } + /> + ); + }} + items={items} + /> + + ); +}; diff --git a/src/components/TreeList/__stories__/stories/WithItemLinksAndActionsStory.tsx b/src/components/TreeList/__stories__/stories/WithItemLinksAndActionsStory.tsx new file mode 100644 index 0000000000..f92f316497 --- /dev/null +++ b/src/components/TreeList/__stories__/stories/WithItemLinksAndActionsStory.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import {ChevronDown, ChevronUp, FolderOpen} from '@gravity-ui/icons'; + +import {Button} from '../../../Button'; +import {DropdownMenu} from '../../../DropdownMenu'; +import {Icon} from '../../../Icon'; +import {Flex} from '../../../layout'; +import {ListItemView, useListState} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {TreeList} from '../../TreeList'; +import type {TreeListProps} from '../../types'; + +function identity(value: T): T { + return value; +} + +export interface WithItemLinksAndActionsStoryProps + extends Omit< + TreeListProps<{title: string}>, + 'items' | 'size' | 'multiple' | 'mapItemDataToProps' + > {} + +export const WithItemLinksAndActionsStory = (props: WithItemLinksAndActionsStoryProps) => { + const items = React.useMemo(() => createRandomizedData({num: 10, depth: 1}), []); + + const listState = useListState(); + + return ( + { + if (!groupState && !disabled) { + listState.setSelected((prevState) => ({[id]: !prevState[id]})); + } + }} + renderItem={({ + data, + props: { + expanded, // don't use build in expand icon ListItemView behavior + ...state + }, + itemState: {groupState}, + }) => { + return ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + + { + e.stopPropagation(); + e.preventDefault(); + }} + items={[ + { + action: (e) => { + e.stopPropagation(); + console.log( + `Clicked by action with id: ${state.id}`, + ); + }, + text: 'action 1', + }, + ]} + /> + } + startSlot={ + groupState ? ( + + ) : ( + 0 ? {ml: 1} : undefined} + > + + + ) + } + /> + + ); + }} + /> + ); +}; diff --git a/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx b/src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx similarity index 87% rename from src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx rename to src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx index 9b1f6cf39d..432a522266 100644 --- a/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx +++ b/src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {ListContainerView} from '../../../useList'; import {ListItemRecursiveRenderer} from '../../../useList/components/ListRecursiveRenderer/ListRecursiveRenderer'; -import type {TreeSelectRenderContainerProps} from '../../types'; +import type {TreeListRenderContainerProps} from '../../types'; export const TreeListContainer = ({ items, @@ -12,7 +12,7 @@ export const TreeListContainer = ({ renderItem, className, idToFlattenIndex, -}: TreeSelectRenderContainerProps & {className?: string}) => { +}: TreeListRenderContainerProps & {className?: string}) => { return ( {items.map((itemSchema, index) => ( diff --git a/src/components/TreeList/index.ts b/src/components/TreeList/index.ts new file mode 100644 index 0000000000..87726a1756 --- /dev/null +++ b/src/components/TreeList/index.ts @@ -0,0 +1,2 @@ +export {TreeList} from './TreeList'; +export {type TreeListProps} from './types'; diff --git a/src/components/TreeList/types.ts b/src/components/TreeList/types.ts new file mode 100644 index 0000000000..a6c07989c7 --- /dev/null +++ b/src/components/TreeList/types.ts @@ -0,0 +1,89 @@ +import type React from 'react'; + +import type {QAProps} from '../types'; +import type { + KnownItemStructure, + ListItemId, + ListItemSize, + ListItemType, + ListParsedState, + ListState, + RenderItemContext, + RenderItemProps, +} from '../useList'; + +export type TreeListRenderItem = (props: { + data: T; + // required item props to render + props: RenderItemProps; + // internal list context props + itemState: RenderItemContext; + index: number; + renderContext?: P; +}) => React.JSX.Element; + +interface ItemClickContext { + id: ListItemId; + /** + * Defined only if item is group + */ + groupState?: ListParsedState['groupsState'][number]; + itemState: ListParsedState['itemsState'][number]; + isLastItem: boolean; + disabled: boolean; + data: T; +} + +export type TreeListOnItemClick = (ctx: ItemClickContext & R) => void; + +export type TreeListRenderContainerProps = ListParsedState & + Partial & { + id: string; + size: ListItemSize; + renderItem( + id: ListItemId, + index: number, + /** + * Ability to transfer props from an overridden container render + */ + renderContextProps?: Object, + ): React.JSX.Element; + containerRef?: React.RefObject; + className?: string; + }; + +export type TreeListRenderContainer = ( + props: TreeListRenderContainerProps, +) => React.JSX.Element; + +export type TreeListMapItemDataToProps = (item: T) => KnownItemStructure; + +export interface TreeListProps extends QAProps, Partial { + /** + * Control outside list container dom element. For example for keyboard + */ + containerRef?: React.RefObject; + id?: string | undefined; + className?: string; + items: ListItemType[]; + multiple?: boolean; + size?: ListItemSize; + /** + * Define custom id depended on item data value to use in controlled state component variant + */ + getId?(item: T): ListItemId; + /** + * Override list item content by you custom node. + */ + renderItem?: TreeListRenderItem; + renderContainer?: TreeListRenderContainer; + /** + * If you want to disable default behavior pass `disabled` as a value; + */ + onItemClick?: TreeListOnItemClick; + mapItemDataToProps: TreeListMapItemDataToProps; + /** + * Required for keyboard correct work + */ + setActiveItemId?(listItemId: ListItemId): void; +} diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index 07fb35ebcf..1372871224 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -3,22 +3,16 @@ import React from 'react'; import {useForkRef, useUniqId} from '../../hooks'; import {SelectControl} from '../Select/components'; import {SelectPopup} from '../Select/components/SelectPopup/SelectPopup'; +import {TreeList} from '../TreeList'; +import type {TreeListOnItemClick, TreeListRenderItem} from '../TreeList/types'; import {Flex} from '../layout'; import {useMobile} from '../mobile'; -import { - getItemRenderState, - isKnownStructureGuard, - scrollToListItem, - useList, - useListKeydown, - useListState, -} from '../useList'; +import {scrollToListItem, useList, useListState} from '../useList'; import type {ListItemId} from '../useList'; import {block} from '../utils/cn'; import type {CnMods} from '../utils/cn'; import {TreeSelectItem} from './TreeSelectItem'; -import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; import {useTreeSelectSelection, useValue} from './hooks/useTreeSelectSelection'; import type {TreeSelectProps, TreeSelectRenderControlProps} from './types'; @@ -26,19 +20,24 @@ import './TreeSelect.scss'; const b = block('tree-select'); +const defaultItemRenderer: TreeListRenderItem = (renderState) => { + return ; +}; + export const TreeSelect = React.forwardRef(function TreeSelect( - props: TreeSelectProps, - ref: React.Ref, -) { - const { + { id, + qa, + placement, slotBeforeListBody, slotAfterListBody, - size = 'm', + size, items, defaultOpen, - className, width, + containerRef: propsContainerRef, + className, + containerClassName, popupClassName, open: propsOpen, multiple, @@ -55,19 +54,23 @@ export const TreeSelect = React.forwardRef(function TreeSelect( getId, onOpenChange, renderControl, - renderItem, - renderContainer = TreeListContainer, + renderItem = defaultItemRenderer as TreeListRenderItem, + renderContainer, onItemClick, - placement, - } = props; - + setActiveItemId: propsSetActiveItemId, + mapItemDataToProps, + }: TreeSelectProps, + ref: React.Ref, +) { const mobile = useMobile(); const uniqId = useUniqId(); const treeSelectId = id ?? uniqId; const controlWrapRef = React.useRef(null); const controlRef = React.useRef(null); - const containerRef = React.useRef(null); + const containerRefLocal = React.useRef(null); + const containerRef = propsContainerRef ?? containerRefLocal; + const handleControlRef = useForkRef(ref, controlRef); const {value, setInnerValue, selected} = useValue({ @@ -82,6 +85,8 @@ export const TreeSelect = React.forwardRef(function TreeSelect( selectedById: selected, }); + const setActiveItemId = propsSetActiveItemId ?? listState.setActiveItemId; + const listParsedState = useList({ items, getId, @@ -108,47 +113,39 @@ export const TreeSelect = React.forwardRef(function TreeSelect( onOpenChange, }); - const handleItemClick = React.useCallback( - (id: ListItemId) => { - // onItemClick = disabled - switch off default click behavior - if (onItemClick === 'disabled') return undefined; - + const handleItemClick = React.useCallback>( + ({id: listItemId, data, groupState, isLastItem, itemState}) => { const defaultHandleClick = () => { - if (listState.disabledById[id]) return; + if (listState.disabledById[listItemId]) return; // always activate selected item - listState.setActiveItemId(id); - - const isGroup = id in listParsedState.groupsState; + setActiveItemId(listItemId); - if (isGroup && groupsBehavior === 'expandable') { + if (groupState && groupsBehavior === 'expandable') { listState.setExpanded((state) => ({ ...state, // toggle expanded state by id, by default all groups expanded - [id]: typeof state[id] === 'boolean' ? !state[id] : false, + [listItemId]: + typeof state[listItemId] === 'boolean' ? !state[listItemId] : false, })); } else if (multiple) { - handleMultipleSelection(id); + handleMultipleSelection(listItemId); } else { - handleSingleSelection(id); + handleSingleSelection(listItemId); toggleOpen(false); } }; if (onItemClick) { - 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 onItemClick({ + id: listItemId, + data, + groupState, + itemState, + isLastItem, + disabled: listState.disabledById[listItemId], + defaultClickCallback: defaultHandleClick, + }); } return defaultHandleClick(); @@ -156,9 +153,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( [ onItemClick, listState, - listParsedState.groupsState, - listParsedState.itemsById, - listParsedState.visibleFlattenIds, + setActiveItemId, groupsBehavior, multiple, handleMultipleSelection, @@ -173,9 +168,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( const lastSelectedItemId = value[value.length - 1]; containerRef.current?.focus(); - const firstItemId = listParsedState.visibleFlattenIds[0]; - - listState.setActiveItemId(lastSelectedItemId ?? firstItemId); + setActiveItemId(lastSelectedItemId); if (lastSelectedItemId) { scrollToListItem(lastSelectedItemId, containerRef.current); @@ -185,13 +178,6 @@ export const TreeSelect = React.forwardRef(function TreeSelect( // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - useListKeydown({ - containerRef, - onItemClick: handleItemClick, - ...listParsedState, - ...listState, - }); - const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]); const controlProps: TreeSelectRenderControlProps = { @@ -211,19 +197,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( { - if ('renderControlContent' in props) { - return props.renderControlContent(listParsedState.itemsById[itemId]).title; - } - - const item = listParsedState.itemsById[itemId]; - - if (isKnownStructureGuard(item)) { - return item.title; - } - - return item as string; - }), + value.map((itemId) => mapItemDataToProps(listParsedState.itemsById[itemId]).title), ).join(', ')} view="normal" pin="round-round" @@ -264,51 +238,27 @@ export const TreeSelect = React.forwardRef(function TreeSelect( id={`tree-select-popup-${treeSelectId}`} > {slotBeforeListBody} - {renderContainer({ - size, - containerRef, - id: `list-${treeSelectId}`, - ...listParsedState, - ...listState, - renderItem: (itemId, index, renderContextProps) => { - const renderState = getItemRenderState({ - id: itemId, - size, - onItemClick: handleItemClick, - ...listParsedState, - ...listState, - }); - - // assign components scope logic - renderState.props.hasSelectionIcon = - Boolean(multiple) && !renderState.context.groupState; - - if (renderItem) { - return renderItem({ - data: renderState.data, - props: renderState.props, - itemState: renderState.context, - index, - renderContext: renderContextProps, - }); - } - const itemData = listParsedState.itemsById[itemId]; + + size={size} + className={containerClassName} + qa={qa} + multiple={multiple} + id={`list-${treeSelectId}`} + containerRef={containerRef} + getId={getId} + disabledById={listState.disabledById} + selectedById={listState.selectedById} + expandedById={listState.expandedById} + activeItemId={listState.activeItemId} + setActiveItemId={setActiveItemId} + onItemClick={handleItemClick} + items={items} + renderContainer={renderContainer} + mapItemDataToProps={mapItemDataToProps} + renderItem={renderItem ?? defaultItemRenderer} + /> - return ( - - ); - }, - })} {slotAfterListBody} diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index fd96ddb7fc..b34ed0066c 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -26,7 +26,7 @@ export default { const DefaultTemplate: StoryFn< Omit< TreeSelectProps<{title: string}>, - 'value' | 'onUpdate' | 'items' | 'renderControlContent' + 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' > & { itemsCount?: number; } @@ -37,6 +37,7 @@ const DefaultTemplate: StoryFn< x} items={items} onUpdate={(...args) => console.log('Uncontrolled `TreeSelect onUpdate args: `', ...args) diff --git a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx index 0c92174a28..138583ed83 100644 --- a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {Label} from '../../../Label'; import {Loader} from '../../../Loader'; +import {RenderVirtualizedContainer} from '../../../TreeList/__stories__/components/RenderVirtualizedContainer'; import {Flex, spacing} from '../../../layout'; import {IntersectionContainer} from '../../../useList/__stories__/components/IntersectionContainer/IntersectionContainer'; import {useInfinityFetch} from '../../../useList/__stories__/utils/useInfinityFetch'; @@ -9,11 +10,14 @@ import {TreeSelect} from '../../TreeSelect'; import {TreeSelectItem} from '../../TreeSelectItem'; import type {TreeSelectProps} from '../../types'; -import {RenderVirtualizedContainer} from './RenderVirtualizedContainer'; +function identity(value: T): T { + return value; +} + export interface InfinityScrollExampleProps extends Omit< TreeSelectProps<{title: string}>, - 'value' | 'onUpdate' | 'items' | 'getItemContent' + 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' > { itemsCount?: number; } @@ -34,6 +38,7 @@ export const InfinityScrollExample = ({ {...storyProps} + mapItemDataToProps={identity} items={items} value={value} renderItem={({data, props, itemState: {isLastItem, groupState}}) => { diff --git a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx index c9c6253581..8d5303c5b2 100644 --- a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx @@ -39,7 +39,7 @@ type CustomDataType = {someRandomKey: string; id: string}; export interface WithDndListExampleProps extends Omit< TreeSelectProps, - 'value' | 'onUpdate' | 'items' | 'getItemContent' | 'renderControlContent' + 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' > {} const randomItems: CustomDataType[] = createRandomizedData({ @@ -50,89 +50,96 @@ const randomItems: CustomDataType[] = createRandomizedData({ export const WithDndListExample = (storyProps: WithDndListExampleProps) => { const [items, setItems] = React.useState(randomItems); + const [activeItemId, setActiveItemId] = React.useState(undefined); const [value, setValue] = React.useState([]); - const renderContainer: TreeSelectRenderContainer = React.useCallback( - ({renderItem, visibleFlattenIds, containerRef, id}) => { - const handleDrugEnd: OnDragEndResponder = ({destination, source}) => { - if (typeof destination?.index === 'number' && destination.index !== source.index) { - setItems((currentItems) => - reorderArray(currentItems, source.index, destination.index), - ); - } - }; + const renderContainer: TreeSelectRenderContainer = ({ + renderItem, + visibleFlattenIds, + containerRef, + id, + }) => { + const handleDrugEnd: OnDragEndResponder = ({destination, source}) => { + if (typeof destination?.index === 'number' && destination.index !== source.index) { + setItems((currentItems) => + reorderArray(currentItems, source.index, destination.index), + ); - return ( - - { - return renderItem( - visibleFlattenIds[rubric.source.index], - rubric.source.index, - { - provided, - active: snapshot.isDragging, - }, - ); - }} - > - {(droppableProvided: DroppableProvided) => ( - -
- {visibleFlattenIds.map((listItemId, index) => - renderItem(listItemId, index), - )} - {droppableProvided.placeholder} -
-
- )} -
-
- ); - }, - [setItems], - ); + setActiveItemId(`${destination.index}`); + } + }; + + return ( + + { + return renderItem( + visibleFlattenIds[rubric.source.index], + rubric.source.index, + { + provided, + dragging: snapshot.isDragging, + }, + ); + }} + > + {(droppableProvided: DroppableProvided) => ( + +
+ {visibleFlattenIds.map((listItemId, index) => + renderItem(listItemId, index), + )} + {droppableProvided.placeholder} +
+
+ )} +
+
+ ); + }; - const renderItem: TreeSelectRenderItem = React.useCallback( - ({data, props, index, renderContext: renderContextProps}) => { - const commonProps = { - ...props, - title: data.someRandomKey, - endSlot: , - }; + const renderItem: TreeSelectRenderItem = ({ + data, + props, + index, + renderContext: renderContextProps, + }) => { + const commonProps = { + ...props, + title: data.someRandomKey, + endSlot: , + }; - // here passed props from `renderContainer` method. - if (renderContextProps) { - return ( + // here passed props from `renderContainer` method. + if (renderContextProps) { + return ( + + ); + } + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( - ); - } - return ( - - {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( - - )} - - ); - }, - [], - ); + )} + + ); + }; return ( @@ -140,14 +147,17 @@ export const WithDndListExample = (storyProps: WithDndListExampleProps) => { {...storyProps} value={value} items={items} + activeItemId={activeItemId} + setActiveItemId={setActiveItemId} // you can omit this prop here. If prop `id` passed, TreeSelect would take it by default getId={({id}) => id} - renderControlContent={({someRandomKey}) => ({ + mapItemDataToProps={({someRandomKey}) => ({ title: someRandomKey, })} - onItemClick={(_data, {id, isGroup, disabled}) => { - if (!isGroup && !disabled) { + onItemClick={({id, groupState, disabled}) => { + if (!groupState && !disabled) { setValue([id]); + setActiveItemId(id); } }} renderContainer={renderContainer} diff --git a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx index 77562823d7..d406e64a4d 100644 --- a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx @@ -2,19 +2,22 @@ import React from 'react'; import {Button} from '../../../Button'; import {Text} from '../../../Text'; +import {RenderVirtualizedContainer} from '../../../TreeList/__stories__/components/RenderVirtualizedContainer'; import {TextInput} from '../../../controls'; import {Flex, spacing} from '../../../layout'; import {useListFilter} from '../../../useList'; import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeSelect} from '../../TreeSelect'; -import type {TreeSelectProps, TreeSelectRenderContainerProps} from '../../types'; +import type {TreeSelectProps, TreeSelectRenderContainer} from '../../types'; -import {RenderVirtualizedContainer} from './RenderVirtualizedContainer'; +function identity(value: T): T { + return value; +} export interface WithFiltrationAndControlsExampleProps extends Omit< TreeSelectProps<{title: string}>, - 'value' | 'onUpdate' | 'items' | 'getItemContent' + 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' > { itemsCount?: number; } @@ -25,7 +28,7 @@ export const WithFiltrationAndControlsExample = ({ }: WithFiltrationAndControlsExampleProps) => { const {items, renderContainer} = React.useMemo(() => { const baseItems = createRandomizedData({num: itemsCount}); - const containerRenderer = (props: TreeSelectRenderContainerProps<{title: string}>) => { + const containerRenderer: TreeSelectRenderContainer<{title: string}> = (props) => { if (props.items.length === 0 && baseItems.length > 0) { return ( @@ -48,6 +51,7 @@ export const WithFiltrationAndControlsExample = ({ , - 'value' | 'onUpdate' | 'items' | 'getItemContent' | 'size' + 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' | 'size' > { itemsCount?: number; } @@ -50,7 +50,7 @@ export const WithGroupSelectionControlledStateAndCustomIconExample = ({ (value: T): T { + return value; +} export interface WithItemLinksAndActionsExampleProps extends Omit< TreeSelectProps<{title: string}>, - 'value' | 'onUpdate' | 'items' | 'getItemContent' | 'size' | 'open' | 'onOpenChange' + 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' | 'size' | 'open' | 'onOpenChange' > {} export const WithItemLinksAndActionsExample = (props: WithItemLinksAndActionsExampleProps) => { @@ -28,13 +31,14 @@ export const WithItemLinksAndActionsExample = (props: WithItemLinksAndActionsExa { - if (!isGroup && !disabled) { + onItemClick={({id, groupState, disabled}) => { + if (!groupState && !disabled) { setValue([id]); } diff --git a/src/components/TreeSelect/hooks/useTreeSelectSelection.ts b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts index a6742927f7..23a6eaac2b 100644 --- a/src/components/TreeSelect/hooks/useTreeSelectSelection.ts +++ b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts @@ -4,12 +4,6 @@ import type {UseOpenProps} from '../../../hooks/useSelect/types'; import {useOpenState} from '../../../hooks/useSelect/useOpenState'; import type {ListItemId} from '../../useList/types'; -type UseTreeSelectSelectionProps = { - value: ListItemId[]; - setInnerValue?(ids: ListItemId[]): void; - onUpdate?: (value: ListItemId[]) => void; -} & UseOpenProps; - type UseValueProps = { value?: ListItemId[]; defaultValue?: ListItemId[]; @@ -40,6 +34,12 @@ export const useValue = ({defaultValue, value: valueProps}: UseValueProps) => { }; }; +type UseTreeSelectSelectionProps = { + value: ListItemId[]; + setInnerValue?(ids: ListItemId[]): void; + onUpdate?: (value: ListItemId[]) => void; +} & UseOpenProps; + export const useTreeSelectSelection = ({ value, setInnerValue, diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index 9a31bb5bdc..351b81cc99 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -2,17 +2,20 @@ import type React from 'react'; import type {PopperPlacement} from '../../hooks/private'; import type {SelectPopupProps} from '../Select/components/SelectPopup/types'; +import type { + TreeListMapItemDataToProps, + TreeListOnItemClick, + TreeListRenderContainer, + TreeListRenderContainerProps, +} from '../TreeList/types'; import type {QAProps} from '../types'; import type { - KnownItemStructure, ListItemId, ListItemSize, ListItemType, - ListParsedState, ListState, - OverrideItemContext, RenderItemContext, - RenderItemState, + RenderItemProps, } from '../useList'; export type TreeSelectRenderControlProps = { @@ -29,27 +32,17 @@ export type TreeSelectRenderControlProps = { export type TreeSelectRenderItem = (props: { data: T; // required item props to render - props: RenderItemState; + props: RenderItemProps; // internal list context props itemState: RenderItemContext; index: number; renderContext?: P; }) => React.JSX.Element; -export type TreeSelectRenderContainerProps = ListParsedState & - ListState & { - id: string; - size: ListItemSize; - renderItem(id: ListItemId, index: number, renderContextProps?: Object): React.JSX.Element; - containerRef: React.RefObject; - className?: string; - }; - -export type TreeSelectRenderContainer = ( - props: TreeSelectRenderContainerProps, -) => React.JSX.Element; +export type TreeSelectRenderContainerProps = TreeListRenderContainerProps; +export type TreeSelectRenderContainer = TreeListRenderContainer; -interface TreeSelectBaseProps extends QAProps, Partial> { +export interface TreeSelectProps extends QAProps, Partial> { value?: ListItemId[]; defaultOpen?: boolean; defaultValue?: ListItemId[]; @@ -60,6 +53,8 @@ interface TreeSelectBaseProps extends QAProps, Partial; + containerClassName?: string; popupDisablePortal?: boolean; multiple?: boolean; /** @@ -86,6 +81,7 @@ interface TreeSelectBaseProps extends QAProps, Partial[]; /** * Define custom id depended on item data value to use in controlled state component variant */ @@ -102,22 +98,10 @@ interface TreeSelectBaseProps extends QAProps, Partial; + onItemClick?: TreeListOnItemClick; /** - * If you wont to disable default behavior pass `disabled` as a value; + * Map item data to view props */ - onItemClick?: - | 'disabled' - | ((data: T, content: OverrideItemContext, defaultClickCallback: () => void) => void); + mapItemDataToProps: TreeListMapItemDataToProps; + setActiveItemId?(listItemId?: ListItemId): void; } - -type TreeSelectKnownProps = TreeSelectBaseProps & { - items: ListItemType[]; -}; -type TreeSelectUnknownProps = TreeSelectBaseProps & { - items: ListItemType[]; - renderControlContent(item: T): KnownItemStructure; -}; - -export type TreeSelectProps = T extends KnownItemStructure | string - ? TreeSelectKnownProps - : TreeSelectUnknownProps; diff --git a/src/components/useList/__stories__/components/FlattenList.tsx b/src/components/useList/__stories__/components/FlattenList.tsx index 225b24de25..2e46fb9f45 100644 --- a/src/components/useList/__stories__/components/FlattenList.tsx +++ b/src/components/useList/__stories__/components/FlattenList.tsx @@ -84,14 +84,15 @@ export const FlattenList = ({itemsCount, size}: FlattenListProps) => { } > {(id) => { - const {data, props} = getItemRenderState({ + const {props} = getItemRenderState({ id, size, onItemClick, + mapItemDataToProps: (x) => x, ...list, ...listState, }); - return ; + return ; }}
diff --git a/src/components/useList/__stories__/components/InfinityScrollList.tsx b/src/components/useList/__stories__/components/InfinityScrollList.tsx index 7246c070ce..1a3fd6b3f6 100644 --- a/src/components/useList/__stories__/components/InfinityScrollList.tsx +++ b/src/components/useList/__stories__/components/InfinityScrollList.tsx @@ -92,14 +92,15 @@ export const InfinityScrollList = ({size}: InfinityScrollListProps) => { idToFlattenIndex={list.idToFlattenIndex} > {(id) => { - const {data, props, context} = getItemRenderState({ + const {props, context} = getItemRenderState({ id, size, onItemClick, + mapItemDataToProps: (x) => x, ...list, ...listState, }); - const node = ; + const node = ; if (context.isLastItem) { return ( diff --git a/src/components/useList/__stories__/components/ListWithDnd.tsx b/src/components/useList/__stories__/components/ListWithDnd.tsx index a11aa28d01..49ad89f87c 100644 --- a/src/components/useList/__stories__/components/ListWithDnd.tsx +++ b/src/components/useList/__stories__/components/ListWithDnd.tsx @@ -90,10 +90,11 @@ export const ListWithDnd = ({size, itemsCount}: ListWithDndProps) => {
{list.visibleFlattenIds.map((id, index) => { - const {data, props} = getItemRenderState({ + const {props} = getItemRenderState({ id, size, onItemClick, + mapItemDataToProps: (x) => x, ...list, ...listState, }); @@ -110,10 +111,9 @@ export const ListWithDnd = ({size, itemsCount}: ListWithDndProps) => { ) => ( } /> diff --git a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx index 4d59c657d5..11798b0418 100644 --- a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -107,10 +107,11 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro idToFlattenIndex={list.idToFlattenIndex} > {(id) => { - const {data, props, context} = getItemRenderState({ + const {props, context} = getItemRenderState({ id, size, onItemClick, + mapItemDataToProps: (x) => x, ...list, ...listState, }); @@ -118,7 +119,6 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro return ( ); diff --git a/src/components/useList/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx index 66abfe7d03..e67dd17c04 100644 --- a/src/components/useList/__stories__/components/RecursiveList.tsx +++ b/src/components/useList/__stories__/components/RecursiveList.tsx @@ -82,20 +82,17 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { idToFlattenIndex={list.idToFlattenIndex} > {(id) => { - const {data, props, context} = getItemRenderState({ + const {props, context} = getItemRenderState({ id, size, onItemClick, + mapItemDataToProps: (x) => x, ...list, ...listState, }); return ( - + ); }} diff --git a/src/components/useList/__stories__/useList.mdx b/src/components/useList/__stories__/useList.mdx index 0e7afaf839..3b920e2f5a 100644 --- a/src/components/useList/__stories__/useList.mdx +++ b/src/components/useList/__stories__/useList.mdx @@ -73,6 +73,7 @@ function List() { context: _context, } = getItemRenderState({ id: String(i), + mapItemDataToProps: (title) => ({title}), onItemClick, ...list, ...listState, @@ -121,6 +122,7 @@ function List() { context: _context, } = getItemRenderState({ id: String(i), + mapItemDataToProps: (title) => ({title}), onItemClick, ...list, ...listState, @@ -496,6 +498,7 @@ item = T - `disabled` - is item disabled; - `selected` - is item selected; - `onClick` - on item click handle if exists; + - `mapItemDataToProps` - map item data to view render props with `KnownItemStructure` interface - item list context: - `itemState`: - `parentId?` - id of parant element; @@ -518,6 +521,7 @@ const handleItemClick = () => {}; id, size, // list size onItemClick: handleItemClick, + mapItemDataToProps, ...list, ...listState, }); diff --git a/src/components/useList/components/ListItemView/ListItemView.scss b/src/components/useList/components/ListItemView/ListItemView.scss index f9273cbaae..83113115f0 100644 --- a/src/components/useList/components/ListItemView/ListItemView.scss +++ b/src/components/useList/components/ListItemView/ListItemView.scss @@ -21,6 +21,7 @@ $block: '.#{variables.$ns}list-item-view'; } &_selected, + &_selected:not(#{$block}_dragging)#{$block}_active, // if active and selected selected bgc more priority &_selected:hover#{$block}_activeOnHover { background: var(--g-color-base-selection); } diff --git a/src/components/useList/components/ListItemView/ListItemView.tsx b/src/components/useList/components/ListItemView/ListItemView.tsx index 90be600114..7ebdfcb42a 100644 --- a/src/components/useList/components/ListItemView/ListItemView.tsx +++ b/src/components/useList/components/ListItemView/ListItemView.tsx @@ -52,6 +52,10 @@ export interface ListItemViewProps extends QAProps { className?: string; role?: React.AriaRole; expanded?: boolean; + /** + * Add active styles and change selection behavior during dnd is performing + */ + dragging?: boolean; /** * `[${LIST_ITEM_DATA_ATR}="${id}"]` data attribute to find element. * For example for scroll to @@ -104,6 +108,7 @@ export const ListItemView = React.forwardRef( title, height, expanded, + dragging, style, role = 'option', onClick: _onClick, @@ -121,10 +126,11 @@ export const ListItemView = React.forwardRef( onClick={onClick} className={b( { - active, + active: dragging || active, selected: selected && !hasSelectionIcon, activeOnHover, radius: size, + dragging, clickable: Boolean(onClick), }, spacing({px: 2}, className), diff --git a/src/components/useList/hooks/useList.ts b/src/components/useList/hooks/useList.ts index 8e371f7701..1d18d9736e 100644 --- a/src/components/useList/hooks/useList.ts +++ b/src/components/useList/hooks/useList.ts @@ -1,5 +1,11 @@ /* eslint-disable valid-jsdoc */ -import type {ListItemId, ListItemType, ListParsedState, ListState} from '../types'; +import type { + InitialListParsedState, + ListItemId, + ListItemType, + ListParsedState, + ListState, +} from '../types'; import {useFlattenListItems} from './useFlattenListItems'; import {useListParsedState} from './useListParsedState'; @@ -12,7 +18,7 @@ export interface UseListProps extends Partial { getId?(item: T): ListItemId; } -export type UseListResult = ListParsedState; +export type UseListResult = ListParsedState & {initialState: InitialListParsedState}; /** * Take array of items as a argument and returns parsed representation of this data structure to work with @@ -32,5 +38,13 @@ export const useList = ({items, expandedById, getId}: UseListProps): UseLi getId, }); - return {items, visibleFlattenIds, idToFlattenIndex, itemsById, groupsState, itemsState}; + return { + items, + visibleFlattenIds, + idToFlattenIndex, + itemsById, + groupsState, + itemsState, + initialState, + }; }; diff --git a/src/components/useList/index.ts b/src/components/useList/index.ts index 0321954b30..a2e315811d 100644 --- a/src/components/useList/index.ts +++ b/src/components/useList/index.ts @@ -11,4 +11,3 @@ export * from './utils/getItemRenderState'; export * from './utils/scrollToListItem'; export * from './utils/getListParsedState'; export {modToHeight} from './constants'; -export {isKnownStructureGuard} from './utils'; diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index 723fe0a239..ca9d06c7e9 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -46,13 +46,6 @@ export type KnownItemStructure = { endSlot?: React.ReactNode; }; -export interface OverrideItemContext { - id: ListItemId; - isGroup: boolean; - disabled: boolean; - isLastItem: boolean; -} - export type RenderItemContext = { /** * optional, because ids may be skipped in the flatten order list, @@ -67,7 +60,7 @@ export type RenderItemContext = { isLastItem: boolean; }; -export type RenderItemState = { +export type RenderItemProps = { size: ListItemSize; id: ListItemId; onClick?(): void; @@ -77,7 +70,7 @@ export type RenderItemState = { active: boolean; indentation: number; hasSelectionIcon?: boolean; -}; +} & KnownItemStructure; export type ParsedState = { /** @@ -102,6 +95,11 @@ export type ListState = { activeItemId?: ListItemId; }; +export type InitialListParsedState = Pick< + ListState, + 'disabledById' | 'expandedById' | 'selectedById' +>; + export type ParsedFlattenState = { visibleFlattenIds: ListItemId[]; idToFlattenIndex: Record; diff --git a/src/components/useList/utils.ts b/src/components/useList/utils.ts deleted file mode 100644 index 0bd5e91c28..0000000000 --- a/src/components/useList/utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type {KnownItemStructure} from './types'; - -export const isKnownStructureGuard = (item: unknown): item is KnownItemStructure => { - return item !== null && typeof item === 'object' && 'title' in item; -}; diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index 7971b91c73..eb6ab2244e 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -1,17 +1,19 @@ /* eslint-disable valid-jsdoc */ import type { + KnownItemStructure, ListItemId, ListItemSize, ListParsedState, ListState, RenderItemContext, - RenderItemState, + RenderItemProps, } from '../types'; -type ItemRendererProps = ListState & +type ItemRendererProps = Partial & ListParsedState & { size?: ListItemSize; id: ListItemId; + mapItemDataToProps(data: T): KnownItemStructure; onItemClick?(id: ListItemId): void; }; @@ -25,6 +27,7 @@ export const getItemRenderState = ( expandedById, groupsState, onItemClick, + mapItemDataToProps, visibleFlattenIds, size = 'm', itemsState, @@ -42,22 +45,23 @@ export const getItemRenderState = ( isLastItem: id === visibleFlattenIds[visibleFlattenIds.length - 1], }; - let expanded; + let expanded; // `undefined` value means than tree list will look as nested list without groups // isGroup - if (groupsState[id]) { + if (groupsState[id] && expandedById) { expanded = expandedById[id] ?? defaultExpanded; } - const stateProps: RenderItemState = { + const stateProps: RenderItemProps = { id, size, expanded, active: id === activeItemId, indentation: context.itemState.indentation, - disabled: disabledById[id], - selected: selectedById[id], + disabled: Boolean(disabledById?.[id]), + selected: Boolean(selectedById?.[id]), onClick: onItemClick ? () => onItemClick(id) : undefined, + ...mapItemDataToProps(itemsById[id]), }; return {data: itemsById[id], props: stateProps, context}; diff --git a/src/components/useList/utils/getListParsedState.ts b/src/components/useList/utils/getListParsedState.ts index 86350b3281..9c2cc81b23 100644 --- a/src/components/useList/utils/getListParsedState.ts +++ b/src/components/useList/utils/getListParsedState.ts @@ -1,8 +1,8 @@ import type { + InitialListParsedState, ListFlattenItemType, ListItemId, ListItemType, - ListState, ListTreeItemType, ParsedState, } from '../types'; @@ -28,7 +28,7 @@ interface TraverseTreeItemProps { } type ListParsedStateResult = ParsedState & { - initialState: Pick; + initialState: InitialListParsedState; }; export function getListParsedState( diff --git a/src/unstable.ts b/src/unstable.ts index df8e7d472f..4853fc68f0 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -11,3 +11,7 @@ export { TreeSelectItem as unstable_TreeSelectItem, type TreeSelectItemProps as unstable_TreeSelectItemProps, } from './components/TreeSelect'; +export { + TreeList as unstable_TreeList, + type TreeListProps as unstable_TreeListProps, +} from './components/TreeList';