From 2224de0c65701e3b8258342612b60b6ce4e69e10 Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Mon, 27 Nov 2023 15:30:19 +0300 Subject: [PATCH 01/14] feat(TreeSelect): added TreeSelect unstable component and new list hooks --- package.json | 8 + src/components/ListNext/ListRadiuses.scss | 19 ++ .../__stories__/FlattenRenderer.stories.tsx | 27 ++ .../ListInfinityScroll.stories.tsx | 18 ++ .../__stories__/PopupWithToggler.stories.tsx | 25 ++ .../__stories__/RecursiveRenderer.stories.tsx | 27 ++ .../__stories__/components/FlattenList.tsx | 103 +++++++ .../components/InfinityScrollList.tsx | 145 +++++++++ .../components/PopupWithTogglerList.tsx | 123 ++++++++ .../__stories__/components/RecursiveList.tsx | 98 ++++++ .../ListNext/__stories__/utils/makeData.ts | 48 +++ .../__stories__/utils/useInfinityFetch.ts | 52 ++++ .../IntersectionContainer.tsx | 21 ++ .../components/ItemRenderer/ItemRenderer.tsx | 55 ++++ .../defaultItemRendererBuilder.tsx | 51 ++++ .../ListBodyRenderer/ListBodyRenderer.tsx | 46 +++ .../ListContainerView/ListContainerView.scss | 17 ++ .../ListContainerView/ListContainerView.tsx | 35 +++ .../ListGroupItemView/ListGroupItemView.tsx | 61 ++++ .../components/ListItemView/ListItemView.scss | 59 ++++ .../components/ListItemView/ListItemView.tsx | 168 ++++++++++ .../__stories__/ListItemView.stories.tsx | 79 +++++ .../ListRecursiveRenderer.scss | 8 + .../ListRecursiveRenderer.tsx | 57 ++++ .../VirtualizedListContainer.async.tsx | 26 ++ .../VirtualizedListContainer.tsx | 42 +++ .../VirtualizedListContainer/types.ts | 10 + src/components/ListNext/constants.ts | 20 ++ .../ListNext/hooks/useFlattenListItems.ts | 24 ++ src/components/ListNext/hooks/useList.ts | 31 ++ .../ListNext/hooks/useListFilter.ts | 97 ++++++ .../ListNext/hooks/useListKeydown.tsx | 95 ++++++ .../ListNext/hooks/useListParsedState.ts | 25 ++ src/components/ListNext/hooks/useListState.ts | 47 +++ src/components/ListNext/index.ts | 17 ++ src/components/ListNext/types.ts | 97 ++++++ .../ListNext/utils/computeItemSize.ts | 6 + .../ListNext/utils/createListItemId.ts | 2 + .../ListNext/utils/defaultFilterItems.ts | 33 ++ .../ListNext/utils/findNextIndex.ts | 20 ++ src/components/ListNext/utils/flattenItems.ts | 46 +++ .../ListNext/utils/getListItemId.ts | 7 + .../ListNext/utils/getListParsedState.ts | 85 ++++++ .../ListNext/utils/scrollToListItem.ts | 21 ++ src/components/Select/types.ts | 2 +- src/components/TreeSelect/TreeSelect.scss | 10 + src/components/TreeSelect/TreeSelect.tsx | 279 +++++++++++++++++ .../__stories__/TreeSelect.stories.tsx | 286 ++++++++++++++++++ .../hooks/useTreeSelectSelection.ts | 79 +++++ src/components/TreeSelect/index.ts | 2 + src/components/TreeSelect/types.ts | 83 +++++ src/hooks/useSelect/useOpenState.ts | 8 +- src/hooks/useSelect/useSelect.ts | 19 +- src/unstable.ts | 2 + 54 files changed, 2865 insertions(+), 6 deletions(-) create mode 100644 src/components/ListNext/ListRadiuses.scss create mode 100644 src/components/ListNext/__stories__/FlattenRenderer.stories.tsx create mode 100644 src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx create mode 100644 src/components/ListNext/__stories__/PopupWithToggler.stories.tsx create mode 100644 src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx create mode 100644 src/components/ListNext/__stories__/components/FlattenList.tsx create mode 100644 src/components/ListNext/__stories__/components/InfinityScrollList.tsx create mode 100644 src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx create mode 100644 src/components/ListNext/__stories__/components/RecursiveList.tsx create mode 100644 src/components/ListNext/__stories__/utils/makeData.ts create mode 100644 src/components/ListNext/__stories__/utils/useInfinityFetch.ts create mode 100644 src/components/ListNext/components/IntersectionContainer/IntersectionContainer.tsx create mode 100644 src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx create mode 100644 src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx create mode 100644 src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx create mode 100644 src/components/ListNext/components/ListContainerView/ListContainerView.scss create mode 100644 src/components/ListNext/components/ListContainerView/ListContainerView.tsx create mode 100644 src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx create mode 100644 src/components/ListNext/components/ListItemView/ListItemView.scss create mode 100644 src/components/ListNext/components/ListItemView/ListItemView.tsx create mode 100644 src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx create mode 100644 src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.scss create mode 100644 src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx create mode 100644 src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx create mode 100644 src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx create mode 100644 src/components/ListNext/components/VirtualizedListContainer/types.ts create mode 100644 src/components/ListNext/constants.ts create mode 100644 src/components/ListNext/hooks/useFlattenListItems.ts create mode 100644 src/components/ListNext/hooks/useList.ts create mode 100644 src/components/ListNext/hooks/useListFilter.ts create mode 100644 src/components/ListNext/hooks/useListKeydown.tsx create mode 100644 src/components/ListNext/hooks/useListParsedState.ts create mode 100644 src/components/ListNext/hooks/useListState.ts create mode 100644 src/components/ListNext/index.ts create mode 100644 src/components/ListNext/types.ts create mode 100644 src/components/ListNext/utils/computeItemSize.ts create mode 100644 src/components/ListNext/utils/createListItemId.ts create mode 100644 src/components/ListNext/utils/defaultFilterItems.ts create mode 100644 src/components/ListNext/utils/findNextIndex.ts create mode 100644 src/components/ListNext/utils/flattenItems.ts create mode 100644 src/components/ListNext/utils/getListItemId.ts create mode 100644 src/components/ListNext/utils/getListParsedState.ts create mode 100644 src/components/ListNext/utils/scrollToListItem.ts create mode 100644 src/components/TreeSelect/TreeSelect.scss create mode 100644 src/components/TreeSelect/TreeSelect.tsx create mode 100644 src/components/TreeSelect/__stories__/TreeSelect.stories.tsx create mode 100644 src/components/TreeSelect/hooks/useTreeSelectSelection.ts create mode 100644 src/components/TreeSelect/index.ts create mode 100644 src/components/TreeSelect/types.ts create mode 100644 src/unstable.ts diff --git a/package.json b/package.json index a35fc6bad2..b774007bec 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,11 @@ "require": "./build/cjs/components/utils/addComponentKeysets.js", "import": "./build/esm/components/utils/addComponentKeysets.js" }, + "./unstable": { + "types": "./build/esm/unstable.d.ts", + "require": "./build/cjs/unstable.js", + "import": "./build/esm/unstable.js" + }, "./styles/*": "./styles/*" }, "main": "./build/cjs/index.js", @@ -46,6 +51,9 @@ ], "i18n": [ "./build/esm/components/utils/addComponentKeysets.d.ts" + ], + "unstable": [ + "./build/esm/unstable.d.ts" ] } }, diff --git a/src/components/ListNext/ListRadiuses.scss b/src/components/ListNext/ListRadiuses.scss new file mode 100644 index 0000000000..742058a92d --- /dev/null +++ b/src/components/ListNext/ListRadiuses.scss @@ -0,0 +1,19 @@ +/* stylelint-disable declaration-no-important */ +@use '../variables'; + +$block: '.#{variables.$ns}list-radiuses'; + +#{$block} { + &_s { + border-radius: 5px !important; + } + &_m { + border-radius: 6px !important; + } + &_l { + border-radius: 8px !important; + } + &_xl { + border-radius: 10px !important; + } +} diff --git a/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx b/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx new file mode 100644 index 0000000000..351b0d7b27 --- /dev/null +++ b/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {Flex} from '../../layout'; + +import {FlattenList, FlattenListProps} from './components/FlattenList'; + +export default { + title: 'Unstable/useList/FlattenRenderer(Virtualized)', + component: FlattenList, +} as Meta; + +const DefaultTemplate: StoryFn = (props) => { + return ( + + + + ); +}; + +export const Examples = DefaultTemplate.bind({}); + +Examples.args = { + size: 's', + itemsCount: 1000, +}; diff --git a/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx b/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx new file mode 100644 index 0000000000..9785f85fd0 --- /dev/null +++ b/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {InfinityScrollList, InfinityScrollListProps} from './components/InfinityScrollList'; + +export default { + title: 'Unstable/useList/InfinityScrollList', + component: InfinityScrollList, +} as Meta; + +const ListInfinityScroll: StoryFn = (props) => { + return ; +}; +export const Examples = ListInfinityScroll.bind({}); +Examples.args = { + size: 'm', +}; diff --git a/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx b/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx new file mode 100644 index 0000000000..46acf307da --- /dev/null +++ b/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {Flex} from '../../layout'; + +import {PopupWithTogglerList, PopupWithTogglerListProps} from './components/PopupWithTogglerList'; + +export default { + title: 'Unstable/useList/PopupWithToggler', + component: PopupWithTogglerList, +} as Meta; + +const PopupWithTogglerScroll: StoryFn = (props) => { + return ( + + + + ); +}; +export const Examples = PopupWithTogglerScroll.bind({}); +Examples.args = { + itemsCount: 10, + size: 'm', +}; diff --git a/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx b/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx new file mode 100644 index 0000000000..3ef13b962c --- /dev/null +++ b/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {Flex} from '../../layout'; + +import {RecursiveList, RecursiveListProps} from './components/RecursiveList'; + +export default { + title: 'Unstable/useList/RecursiveRenderer', + component: RecursiveList, +} as Meta; + +const DefaultTemplate: StoryFn = (props) => { + return ( + + + + ); +}; + +export const Examples = DefaultTemplate.bind({}); + +Examples.args = { + size: 's', + itemsCount: 10, +}; diff --git a/src/components/ListNext/__stories__/components/FlattenList.tsx b/src/components/ListNext/__stories__/components/FlattenList.tsx new file mode 100644 index 0000000000..ed0480b913 --- /dev/null +++ b/src/components/ListNext/__stories__/components/FlattenList.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import get from 'lodash/get'; +import identity from 'lodash/identity'; + +import {TextInput} from '../../../controls'; +import {Flex} from '../../../layout'; +import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; +import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; +import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {VirtualizedListContainer} from '../../components/VirtualizedListContainer/VirtualizedListContainer'; +import {useList} from '../../hooks/useList'; +import {useListFilter} from '../../hooks/useListFilter'; +import {useListKeydown} from '../../hooks/useListKeydown'; +import type {ListItemId, ListSizeTypes} from '../../types'; +import {computeItemSize} from '../../utils/computeItemSize'; +import {createRandomizedData} from '../utils/makeData'; + +export interface FlattenListProps { + itemsCount: number; + size: ListSizeTypes; +} + +export const FlattenList = ({itemsCount, size}: FlattenListProps) => { + const containerRef = React.useRef(null); + const items = React.useMemo( + () => createRandomizedData<{title: string}>(itemsCount), + [itemsCount], + ); + + const filterState = useListFilter({items}); + + const [listParsedState, listState] = useList({ + items, + }); + + const onItemClick = React.useCallback( + (id: ListItemId) => { + if (id in listParsedState.groupsState) { + listState.setExpanded((state) => ({ + ...state, + [id]: id in state ? !state[id] : false, + })); + } else { + listState.setSelected((state) => ({ + // can select only one item + [id]: !state[id], + })); + } + + listState.setActiveItemId(id); + }, + [listParsedState, listState], + ); + + useListKeydown({ + containerRef, + onItemClick, + ...listParsedState, + ...listState, + }); + + return ( + + + + + + computeItemSize( + size, + Boolean( + get( + listParsedState.byId[listParsedState.flattenIdsOrder[index]], + 'subtitle', + ), + ), + ) + } + > + {(id) => ( + + )} + + + + ); +}; diff --git a/src/components/ListNext/__stories__/components/InfinityScrollList.tsx b/src/components/ListNext/__stories__/components/InfinityScrollList.tsx new file mode 100644 index 0000000000..a43ac22e01 --- /dev/null +++ b/src/components/ListNext/__stories__/components/InfinityScrollList.tsx @@ -0,0 +1,145 @@ +import React from 'react'; + +import identity from 'lodash/identity'; + +import {Button} from '../../../Button'; +import {Loader} from '../../../Loader'; +import {TextInput} from '../../../controls'; +import {Flex} from '../../../layout'; +import {IntersectionContainer} from '../../components/IntersectionContainer/IntersectionContainer'; +import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; +import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; +import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; +import {useList} from '../../hooks/useList'; +import {useListFilter} from '../../hooks/useListFilter'; +import {useListKeydown} from '../../hooks/useListKeydown'; +import type {ListItemId, ListSizeTypes} from '../../types'; +import {useInfinityFetch} from '../utils/useInfinityFetch'; + +export interface InfinityScrollListProps { + size: ListSizeTypes; +} + +export const InfinityScrollList = ({size}: InfinityScrollListProps) => { + const containerRef = React.useRef(null); + const {data, onFetchMore, canFetchMore, isLoading} = useInfinityFetch<{title: string}>(); + const filterState = useListFilter({items: data}); + + const [listParsedState, listState] = useList({ + items: filterState.items, + }); + + const onItemClick = (id: ListItemId) => { + if (id in listParsedState.groupsState) { + listState.setExpanded((state) => ({ + ...state, + [id]: id in state ? !state[id] : false, + })); + } else { + listState.setSelected((state) => ({...state, [id]: !state[id]})); + } + + listState.setActiveItemId(id); + }; + + useListKeydown({ + containerRef, + onItemClick, + ...listParsedState, + ...listState, + }); + + const handleReset = () => { + filterState.reset(); + listState.setExpanded({}); + listState.setSelected({}); + listState.setActiveItemId(undefined); + }; + + const handleAccept = () => { + alert( + JSON.stringify( + Object.keys(listState.selected).map((id) => listParsedState.byId[id]), + null, + 2, + ), + ); + }; + + return ( + + + {data.length > 0 && ( + + + + + {filterState.items.map((item, index) => ( + + {(id) => ( + { + if (isLastItem) { + return ( + + {node} + + ); + } + + return node; + }, + getItemContent: identity, + })} + /> + )} + + ))} + + + )} + + {isLoading && ( + + + + )} + + + + + + + ); +}; diff --git a/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx b/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx new file mode 100644 index 0000000000..13f998dd21 --- /dev/null +++ b/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx @@ -0,0 +1,123 @@ +import React from 'react'; + +import identity from 'lodash/identity'; + +import {Button} from '../../../Button'; +import {Popup} from '../../../Popup'; +import {Flex} from '../../../layout'; +import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; +import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; +import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; +import {bListRadiuses} from '../../constants'; +import {useList} from '../../hooks/useList'; +import {useListKeydown} from '../../hooks/useListKeydown'; +import type {ListItemId, ListSizeTypes} from '../../types'; +import {scrollToListItem} from '../../utils/scrollToListItem'; +import {createRandomizedData} from '../utils/makeData'; + +export interface PopupWithTogglerListProps { + itemsCount: number; + size: ListSizeTypes; +} + +const COMPONENT_WIDTH = 300; + +export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListProps) => { + const containerRef = React.useRef(null); + const controlWrapRef = React.useRef(null); + const controlRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const items = React.useMemo( + () => createRandomizedData<{title: string}>(itemsCount), + [itemsCount], + ); + + const [listParsedState, listState] = useList({ + items, + }); + + const [selectedId] = React.useMemo(() => Object.keys(listState.selected), [listState.selected]); + + // restoring focus when popup opens + React.useLayoutEffect(() => { + if (open) { + containerRef.current?.focus(); + listState.setActiveItemId(selectedId ?? listParsedState.flattenIdsOrder[0]); + + if (selectedId) { + scrollToListItem(selectedId, containerRef.current); + } + } + // subscribe only in open event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const onItemClick = (id: ListItemId) => { + if (id in listParsedState.groupsState) { + listState.setExpanded((state) => ({ + ...state, + [id]: id in state ? !state[id] : false, + })); + listState.setActiveItemId(id); + } else { + // only one item active + listState.setSelected((state) => ({ + [id]: !state[id], + })); + setOpen(false); + listState.setActiveItemId(undefined); + } + }; + + useListKeydown({ + containerRef, + onItemClick, + ...listParsedState, + ...listState, + }); + + return ( + + + } + placement={['bottom-start', 'bottom-end', 'top-start', 'top-end']} + offset={[0, 10]} + open={open} + onClose={() => setOpen(false)} + disablePortal + restoreFocus + restoreFocusRef={controlRef} + > + + {items.map((item, index) => ( + + {(id) => ( + + )} + + ))} + + + + ); +}; diff --git a/src/components/ListNext/__stories__/components/RecursiveList.tsx b/src/components/ListNext/__stories__/components/RecursiveList.tsx new file mode 100644 index 0000000000..ab82e1a84c --- /dev/null +++ b/src/components/ListNext/__stories__/components/RecursiveList.tsx @@ -0,0 +1,98 @@ +import React from 'react'; + +import identity from 'lodash/identity'; + +import {TextInput} from '../../../controls'; +import {Flex} from '../../../layout'; +import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; +import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; +import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; +import {useList} from '../../hooks/useList'; +import {useListFilter} from '../../hooks/useListFilter'; +import {useListKeydown} from '../../hooks/useListKeydown'; +import type {ListItemId, ListSizeTypes} from '../../types'; +import {createRandomizedData} from '../utils/makeData'; + +export interface RecursiveListProps { + itemsCount: number; + size: ListSizeTypes; +} + +export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { + const containerRef = React.useRef(null); + + const items = React.useMemo( + () => createRandomizedData<{title: string}>(itemsCount), + [itemsCount], + ); + + const filterState = useListFilter({items}); + + const [listParsedState, listState] = useList({ + items: filterState.items, + }); + + const onItemClick = React.useCallback( + (id: ListItemId) => { + if (id in listParsedState.groupsState) { + listState.setExpanded((state) => ({ + ...state, + [id]: id in state ? !state[id] : false, + })); + } else { + // just toggle item by id + listState.setSelected((state) => ({ + ...state, + [id]: !state[id], + })); + } + + listState.setActiveItemId(id); + }, + [listParsedState.groupsState, listState], + ); + + useListKeydown({ + containerRef, + onItemClick, + ...listParsedState, + ...listState, + }); + + return ( + + + + {filterState.items.map((item, index) => ( + + {(id) => ( + + )} + + ))} + + + ); +}; diff --git a/src/components/ListNext/__stories__/utils/makeData.ts b/src/components/ListNext/__stories__/utils/makeData.ts new file mode 100644 index 0000000000..0fd442a056 --- /dev/null +++ b/src/components/ListNext/__stories__/utils/makeData.ts @@ -0,0 +1,48 @@ +import {faker} from '@faker-js/faker/locale/en'; + +import type {ListItemType} from '../../types'; + +const RANDOM_WORDS = Array(50) + .fill(null) + .map(() => faker.person.fullName()); + +export function createRandomizedData( + num = 1000, + hasDepth = true, + getData?: (title: string) => T, +): ListItemType[] { + const data = []; + + for (let i = 0; i < num; i++) { + data.push(createRandomizedItem(hasDepth ? 0 : 3, getData)); + } + + return data; +} + +function base(title: string): T { + return {title} as T; +} + +function createRandomizedItem( + depth: number, + getData: (title: string) => T = base, +): ListItemType { + const item: ListItemType = { + data: getData(RANDOM_WORDS[Math.floor(Math.random() * RANDOM_WORDS.length)]), + }; + + const numChildren = depth < 3 ? Math.floor(Math.random() * 5) : 0; + + if (numChildren > 0) { + item.children = []; + } + + for (let i = 0; i < numChildren; i++) { + if (item.children) { + item.children.push(createRandomizedItem(depth + 1, getData)); + } + } + + return item; +} diff --git a/src/components/ListNext/__stories__/utils/useInfinityFetch.ts b/src/components/ListNext/__stories__/utils/useInfinityFetch.ts new file mode 100644 index 0000000000..6560912f10 --- /dev/null +++ b/src/components/ListNext/__stories__/utils/useInfinityFetch.ts @@ -0,0 +1,52 @@ +import React from 'react'; + +import type {ListItemType} from '../../types'; + +import {createRandomizedData} from './makeData'; + +function fetchData({ + itemsCount = 20, + timeout = 1000, + withChildren = false, +}: { + itemsCount: number; + timeout?: number; + withChildren?: boolean; +}) { + return new Promise[]>((res) => + setTimeout(() => res(createRandomizedData(itemsCount, withChildren)), timeout), + ); +} + +export function useInfinityFetch(itemsCount = 10, withChildren = false) { + const [data, setData] = React.useState[]>([]); + const [isLoading, setIsLoading] = React.useState(false); + const [canFetchMore, setCanFetchMore] = React.useState(true); + + const onFetchMore = React.useCallback(async () => { + setIsLoading(true); + setCanFetchMore(false); + + try { + const newData = await fetchData({itemsCount, withChildren}); + setData((x) => x.concat(newData)); + } finally { + setIsLoading(false); + setCanFetchMore(true); + } + }, [itemsCount, withChildren]); + + React.useEffect(() => { + onFetchMore(); + + // Just fetch on first render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + data, + onFetchMore, + canFetchMore, + isLoading, + }; +} diff --git a/src/components/ListNext/components/IntersectionContainer/IntersectionContainer.tsx b/src/components/ListNext/components/IntersectionContainer/IntersectionContainer.tsx new file mode 100644 index 0000000000..49b5b597db --- /dev/null +++ b/src/components/ListNext/components/IntersectionContainer/IntersectionContainer.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import {useIntersection} from '../../../../hooks'; + +interface IntersectionContainerProps { + children: React.JSX.Element; + onIntersect?: () => void; +} + +export const IntersectionContainer = ({children, onIntersect}: IntersectionContainerProps) => { + // `state` instead of `ref` here to trigger component rerender + const [ref, setRef] = React.useState(null); + + useIntersection({element: ref, onIntersect}); + + if (onIntersect) { + return
{children}
; + } + + return children; +}; diff --git a/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx b/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx new file mode 100644 index 0000000000..2799d88e9e --- /dev/null +++ b/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx @@ -0,0 +1,55 @@ +import type { + ItemsParsedState, + ListGroupState, + ListItemId, + ListSizeTypes, + RenderItem, +} from '../../types'; + +type ItemRendererProps = { + id: ListItemId; + size?: ListSizeTypes; + byId: Record; + itemsState: ItemsParsedState; + groupsState: ListGroupState; + selected: Record; + expanded: Record; + disabled: Record; + activeItemId?: ListItemId; + lastItemId: ListItemId; + onItemClick?(id: ListItemId): void; + renderItem: RenderItem; +}; + +export const ItemRenderer = ({ + byId, + disabled, + expanded, + groupsState, + onItemClick, + id, + size = 'm', + itemsState, + lastItemId, + selected, + activeItemId, + renderItem, +}: ItemRendererProps) => { + return renderItem( + byId[id], + { + id, + size, + expanded: expanded[id], + active: id === activeItemId, + disabled: disabled[id], + selected: selected[id], + onClick: onItemClick ? () => onItemClick(id) : undefined, + }, + { + itemState: itemsState[id], + groupState: groupsState[id], + isLastItem: id === lastItemId, + }, + ); +}; diff --git a/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx b/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx new file mode 100644 index 0000000000..187c8f866a --- /dev/null +++ b/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +import type {TreeSelectProps} from 'src/unstable'; + +import type {GetItemContent, RenderItem, RenderItemContext} from '../../types'; +import {ListGroupItemView} from '../ListGroupItemView/ListGroupItemView'; +import {ListItemView} from '../ListItemView/ListItemView'; + +interface BuilderProps extends Pick, 'groupsBehavior' | 'groupAction'> { + itemWrapper?(node: React.JSX.Element, context: RenderItemContext): React.JSX.Element; + /** + * Known how map data (T) to list item props + */ + getItemContent: GetItemContent; +} + +export const defaultItemRendererBuilder = function ({ + groupsBehavior = 'expandable', + groupAction = 'items-count', + getItemContent, + itemWrapper, +}: BuilderProps): RenderItem { + return (item, state, {isLastItem, itemState, groupState}) => { + const itemContent = getItemContent(item, { + id: state.id, + isGroup: Boolean(groupState), + isLastItem, + }); + + let node: React.ReactNode = groupState ? ( + + ) : ( + + ); + + if (itemWrapper) { + node = itemWrapper(node, {isLastItem, itemState, groupState}); + } + + return node; + }; +}; diff --git a/src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx b/src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx new file mode 100644 index 0000000000..613715c119 --- /dev/null +++ b/src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import type {ListItemId, ListItemType} from '../../types'; +import {ListItemRecursiveRenderer} from '../ListRecursiveRenderer/ListRecursiveRenderer'; +import {VirtualizedListContainer} from '../VirtualizedListContainer/VirtualizedListContainer.async'; + +interface ListBodyRendererProps { + expanded: Record; + itemSize(index: number): number; + virtualized?: boolean; + items: ListItemType[]; + flattenIdsOrder: ListItemId[]; + children(id: ListItemId): React.JSX.Element; +} + +export const ListBodyRenderer = ({ + virtualized, + items, + flattenIdsOrder, + itemSize, + expanded, + children, +}: ListBodyRendererProps) => { + if (virtualized) { + return ( + + {children} + + ); + } + + return ( + + {items.map((itemSchema, index) => ( + + {children} + + ))} + + ); +}; diff --git a/src/components/ListNext/components/ListContainerView/ListContainerView.scss b/src/components/ListNext/components/ListContainerView/ListContainerView.scss new file mode 100644 index 0000000000..378dfc1b38 --- /dev/null +++ b/src/components/ListNext/components/ListContainerView/ListContainerView.scss @@ -0,0 +1,17 @@ +@use '../../../variables'; + +$block: '.#{variables.$ns}list-container-view'; + +#{$block} { + box-sizing: border-box; + width: 100%; + outline: none; + + &_virtualized { + height: var(--g-list-height, 300px); + } + + &:not(#{$block}_virtualized) { + overflow: auto; + } +} diff --git a/src/components/ListNext/components/ListContainerView/ListContainerView.tsx b/src/components/ListNext/components/ListContainerView/ListContainerView.tsx new file mode 100644 index 0000000000..930fac00cd --- /dev/null +++ b/src/components/ListNext/components/ListContainerView/ListContainerView.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import {Flex} from '../../../layout'; +import {block} from '../../../utils/cn'; + +import './ListContainerView.scss'; + +const b = block('list-container-view'); + +export interface ListContainerViewProps { + id?: string; + className?: string; + virtualized?: boolean; + children: React.ReactNode; +} + +export const ListContainerView = React.forwardRef( + function ListContainerView({children, id, className, virtualized, ...props}, ref) { + return ( + + {children} + + ); + }, +); diff --git a/src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx b/src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx new file mode 100644 index 0000000000..1bc92ca676 --- /dev/null +++ b/src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx @@ -0,0 +1,61 @@ +/* eslint-disable react/display-name */ +import React from 'react'; + +import {ChevronDown, ChevronUp} from '@gravity-ui/icons'; + +import {Icon} from '../../../Icon'; +import {Label} from '../../../Label'; +import {Text} from '../../../Text'; +import {ListItemView, ListItemViewProps} from '../ListItemView/ListItemView'; + +export const ExpandIcon = ({expanded, size}: {expanded: boolean; size?: number}) => { + return ; +}; + +export interface ListGroupItemViewProps extends ListItemViewProps { + childrenCount?: number; + expanded?: boolean; + /** + * Show default expand icon view. + * You can override this behavior by passing custom icon in start or end slot + */ + defaultExpandIcon?: boolean; +} + +export const ListGroupItemView = ({ + title, + childrenCount, + expanded = true, + defaultExpandIcon = true, + endSlot, + disabled, + startSlot, + ...props +}: ListGroupItemViewProps) => { + return ( + + {title} + + ) : ( + title + ) + } + endSlot={ + endSlot ?? + (typeof childrenCount === 'number' ? : null) + } + startSlot={startSlot ?? (defaultExpandIcon ? : null)} + selectable={false} + activeOnHover={false} + {...props} + /> + ); +}; diff --git a/src/components/ListNext/components/ListItemView/ListItemView.scss b/src/components/ListNext/components/ListItemView/ListItemView.scss new file mode 100644 index 0000000000..8d07b72b90 --- /dev/null +++ b/src/components/ListNext/components/ListItemView/ListItemView.scss @@ -0,0 +1,59 @@ +@use '../../../variables'; + +$block: '.#{variables.$ns}list-item-view'; + +#{$block} { + flex-shrink: 0; + + &:hover#{$block}_activeOnHover, + &_active#{$block}_activeOnHover, + &_active { + background: var(--g-color-base-simple-hover); + } + + &_clickable { + cursor: pointer; + } + + &_selected, + &_selected:hover#{$block}_activeOnHover { + background: var(--g-color-base-selection); + } + + &_hidden { + display: none; + } + + &__slot { + &_indent_1 { + width: 16px; + } + &_indent_2 { + width: 32px; + } + &_indent_3 { + width: 48px; + } + &_indent_4 { + width: 64px; + } + &_indent_5 { + width: 80px; + } + &_indent_6 { + width: 96px; + } + &_indent_7 { + width: 112px; + } + &_indent_8 { + width: 128px; + } + &_indent_9 { + width: 144px; + } + &_indent_10 { + width: 160px; + } + } +} diff --git a/src/components/ListNext/components/ListItemView/ListItemView.tsx b/src/components/ListNext/components/ListItemView/ListItemView.tsx new file mode 100644 index 0000000000..748221b6be --- /dev/null +++ b/src/components/ListNext/components/ListItemView/ListItemView.tsx @@ -0,0 +1,168 @@ +import React from 'react'; + +import {Check} from '@gravity-ui/icons'; +import type {QAProps} from 'src/components/types'; + +import {Icon} from '../../../Icon'; +import {Text, colorText} from '../../../Text'; +import {Flex, FlexProps, spacing} from '../../../layout'; +import {block} from '../../../utils/cn'; +import {LIST_ITEM_DATA_ATR, bListRadiuses, modToHeight} from '../../constants'; +import type {ListItemId, ListSizeTypes} from '../../types'; +import {createListItemId} from '../../utils/createListItemId'; + +import './ListItemView.scss'; + +const b = block('list-item-view'); + +export interface ListItemViewProps extends QAProps, Omit, 'title'> { + /** + * Ability to override default html tag + */ + as?: keyof JSX.IntrinsicElements; + /** + * @default `m` + */ + size?: ListSizeTypes; + height?: number; + selected?: boolean; + active?: boolean; + /** + * display: hidden; + */ + hidden?: boolean; + disabled?: boolean; + /** + * By default hovered elements has active styles. You can disable this behavior + */ + activeOnHover?: boolean; + /** + * Build in indentation component to render nested views structure + */ + indentation?: number; + /** + * Show selected icon if selected and reserve space for this icon + */ + selectable?: boolean; + /** + * Note: if passed and `disabled` option is `true` click will not be appear + */ + onClick?(): void; + style?: React.CSSProperties; + title: React.ReactNode; + subtitle?: React.ReactNode; + startSlot?: React.ReactNode; + endSlot?: React.ReactNode; + corners?: boolean; + className?: string; + /** + * `[${LIST_ITEM_DATA_ATR}="${id}"]` data attribute to find element. + * For example for scroll to + */ + id: ListItemId; +} + +interface SlotProps extends FlexProps { + indentation?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; +} + +export const Slot = ({children, indentation: indent = 1, className, ...props}: SlotProps) => { + return ( + + {children} + + ); +}; + +const renderSafeIndentation = (indentation?: number) => { + if (indentation && indentation >= 1 && indentation < 11) { + return ; + } + return null; +}; + +export const ListItemView = React.forwardRef( + ( + { + id, + as = 'li', + startSlot, + endSlot, + title, + subtitle, + size = 'm', + active, + hidden, + selected, + disabled, + corners = true, + activeOnHover = true, + indentation, + className, + height, + selectable = true, + onClick: _onClick, + ...rest + }: ListItemViewProps, + ref?: any, + ) => { + const onClick = disabled ? undefined : _onClick; + + return ( + + + {selectable && ( + + {selected ? ( + + ) : null} + + )} + + {renderSafeIndentation(indentation)} + + {startSlot} + + {typeof title === 'string' ? ( + {title} + ) : ( + title + )} + {typeof subtitle === 'string' ? ( + {subtitle} + ) : ( + subtitle + )} + + + {endSlot} + + ); + }, +); + +ListItemView.displayName = 'ListItemView'; diff --git a/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx b/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx new file mode 100644 index 0000000000..f72cbb0641 --- /dev/null +++ b/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {UserAvatar} from '../../../../UserAvatar'; +import {Flex} from '../../../../layout'; +import {ListItemView, ListItemViewProps} from '../ListItemView'; + +export default { + title: 'Unstable/useList/ListItemView', + component: ListItemView, +} as Meta; + +const title = 'title'; +const subtitle = 'subtitle'; + +const stories: ListItemViewProps[] = [ + { + id: '1', + title, + activeOnHover: false, + subtitle, + disabled: true, + startSlot: ( + + ), + }, + { + id: '2', + title, + subtitle, + activeOnHover: false, + }, + { + id: '3', + title, + subtitle, + selected: true, + startSlot: ( + + ), + }, + { + id: '4', + title, + selected: true, + disabled: true, + height: 60, + startSlot: ( + + ), + }, + { + id: '5', + title, + }, + { + id: '6', + title, + subtitle, + startSlot: ( + + ), + indentation: 1, + }, + { + id: '7', + title: 'Group 1', + }, +]; + +const DefaultTemplate: StoryFn = () => ( + + {stories.map((props, i) => ( + + ))} + +); +export const Examples = DefaultTemplate.bind({}); diff --git a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.scss b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.scss new file mode 100644 index 0000000000..519a20484f --- /dev/null +++ b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.scss @@ -0,0 +1,8 @@ +@use '../../../variables'; + +$block: '.#{variables.$ns}list-recursive-renderer'; + +#{$block} { + padding: 0; + margin: 0; +} diff --git a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx new file mode 100644 index 0000000000..df28937eab --- /dev/null +++ b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import {block} from '../../../utils/cn'; +import type {ListItemId, ListItemType} from '../../types'; +import {getListItemId} from '../../utils/getListItemId'; + +import './ListRecursiveRenderer.scss'; + +const b = block('list-recursive-renderer'); + +export interface ListRecursiveRendererProps { + itemSchema: ListItemType; + expanded?: Record; + children(id: ListItemId): React.JSX.Element; + index: number; + parentId?: string; + className?: string; + getId?(item: T): ListItemId; + style?: React.CSSProperties; +} + +export function ListItemRecursiveRenderer({ + itemSchema, + index, + parentId, + ...props +}: ListRecursiveRendererProps) { + const groupedId = getListItemId(index, parentId); + const id = + typeof props.getId === 'function' + ? props.getId(itemSchema.data) + : itemSchema.id || groupedId; + + const node = props.children(id); + + if (itemSchema.children) { + const isExpanded = props.expanded && id in props.expanded ? props.expanded[id] : true; + + return ( +
    + {node} + {isExpanded && + itemSchema.children.map((item, index) => ( + + ))} +
+ ); + } + + return node; +} diff --git a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx b/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx new file mode 100644 index 0000000000..69a52f3a64 --- /dev/null +++ b/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import {Loader} from '../../../Loader'; +import {Flex} from '../../../layout'; + +import type {ListContainerRenderProps} from './types'; + +const VirtualizedListContainerOrigin = React.lazy(() => + import('./VirtualizedListContainer').then(({VirtualizedListContainer}) => ({ + default: VirtualizedListContainer, + })), +); + +export const VirtualizedListContainer = (props: ListContainerRenderProps) => { + return ( + + + + } + > + + + ); +}; diff --git a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx b/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx new file mode 100644 index 0000000000..5628e2c163 --- /dev/null +++ b/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; +import {VariableSizeList as List} from 'react-window'; + +import type {ListContainerRenderProps} from './types'; + +const DEFAULT_OVERSCAN_COUNT = 10; + +/** + * Ready to use tin wrapper around `react-window` + * + * @return - + */ +export function VirtualizedListContainer({ + items, + className, + children, + ...props +}: ListContainerRenderProps) { + return ( + + {({width, height}: Size) => ( + + {({index, style, data}) => ( +
+ {children(data[index], index)} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/ListNext/components/VirtualizedListContainer/types.ts b/src/components/ListNext/components/VirtualizedListContainer/types.ts new file mode 100644 index 0000000000..ee0222674f --- /dev/null +++ b/src/components/ListNext/components/VirtualizedListContainer/types.ts @@ -0,0 +1,10 @@ +import type {VariableSizeListProps} from 'react-window'; + +export interface ListContainerRenderProps + extends Omit< + VariableSizeListProps, + 'children' | 'itemData' | 'itemCount' | 'width' | 'height' + > { + items: T[]; + children(props: T, index: number): React.ReactNode; +} diff --git a/src/components/ListNext/constants.ts b/src/components/ListNext/constants.ts new file mode 100644 index 0000000000..7652eeca50 --- /dev/null +++ b/src/components/ListNext/constants.ts @@ -0,0 +1,20 @@ +import {block} from '../utils/cn'; + +import type {ListSizeTypes} from './types'; + +import './ListRadiuses.scss'; + +export const LIST_ITEM_DATA_ATR = 'data-list-item'; + +const _bListRadiuses = block('list-radiuses'); +export const bListRadiuses = ({size}: {size: ListSizeTypes}, className?: string) => + _bListRadiuses({[size]: true}, className); + +export const GROUPED_ID_SEPARATOR = '-'; + +export const modToHeight = { + s: [24, 44], + m: [28, 48], + l: [36, 52], + xl: [44, 58], +} as const; diff --git a/src/components/ListNext/hooks/useFlattenListItems.ts b/src/components/ListNext/hooks/useFlattenListItems.ts new file mode 100644 index 0000000000..b37084d3aa --- /dev/null +++ b/src/components/ListNext/hooks/useFlattenListItems.ts @@ -0,0 +1,24 @@ +/* eslint-disable valid-jsdoc */ +import React from 'react'; + +import type {ListItemId, ListItemType} from '../types'; +import {flattenItems} from '../utils/flattenItems'; + +interface UseFlattenListItemsProps { + items: ListItemType[]; + expanded?: Record; + getId?(item: T): ListItemId; +} + +/** + * Pick ids from items and flatten children. + * Returns flatten ids list tree structure representation. + * Not included items if they in `expanded` map + */ +export function useFlattenListItems({items, expanded, getId}: UseFlattenListItemsProps) { + const order = React.useMemo(() => { + return flattenItems(items, expanded, getId); + }, [items, expanded, getId]); + + return order; +} diff --git a/src/components/ListNext/hooks/useList.ts b/src/components/ListNext/hooks/useList.ts new file mode 100644 index 0000000000..3917159168 --- /dev/null +++ b/src/components/ListNext/hooks/useList.ts @@ -0,0 +1,31 @@ +import type {ListItemId, ListItemType} from '../types'; + +import {useFlattenListItems} from './useFlattenListItems'; +import {useListParsedState} from './useListParsedState'; +import {useListState} from './useListState'; + +interface UseListProps { + items: ListItemType[]; + /** + * Control expanded items state from external source + */ + expanded?: Record; + getId?(item: T): ListItemId; +} + +export const useList = ({items, expanded, getId}: UseListProps) => { + const {byId, groupsState, itemsState, lastItemId} = useListParsedState({ + items, + getId, + }); + + const state = useListState(); + + const flattenIdsOrder = useFlattenListItems({ + items, + expanded: expanded || state.expanded, + getId, + }); + + return [{flattenIdsOrder, byId, groupsState, itemsState, lastItemId}, state] as const; +}; diff --git a/src/components/ListNext/hooks/useListFilter.ts b/src/components/ListNext/hooks/useListFilter.ts new file mode 100644 index 0000000000..da227465b0 --- /dev/null +++ b/src/components/ListNext/hooks/useListFilter.ts @@ -0,0 +1,97 @@ +import React from 'react'; + +import debounce from 'lodash/debounce'; + +import type {ListItemType} from '../types'; +import {defaultFilterItems} from '../utils/defaultFilterItems'; + +function defaultFilterFn(value: string, item: T): boolean { + return item && typeof item === 'object' && 'title' in item && typeof item.title === 'string' + ? item.title.includes(value) + : true; +} + +interface UseListFilterProps { + items: ListItemType[]; + /** + * Override default filtration logic + */ + filterItems?(value: string, items: ListItemType[]): ListItemType[]; + /** + * Override only logic with item affiliation + */ + filterItem?(value: string, item: T): boolean; + debounceTimeout?: number; + initialFilterValue?: string; +} + +/** + * Ready-to-use logic for filtering tree-like data structures + * ```tsx + * const {item: filteredItems,...listFiltration} = useListFIlter({items}); + * const [listParsedState, listState] = useList({items: filteredItems}); + * + * + * ``` + * @returns - + */ +export function useListFilter({ + items: externalItems, + initialFilterValue = '', + filterItem, + filterItems, + debounceTimeout = 300, +}: UseListFilterProps) { + const filterRef = React.useRef(null); + const [filter, setFilter] = React.useState(initialFilterValue); + const [prevItems, setPrevItems] = React.useState(externalItems); + const [items, setItems] = React.useState(externalItems); + + const filterItemsFn = React.useCallback( + (nextFilterValue: string, items: ListItemType[]) => { + if (filterItems) { + return () => filterItems(nextFilterValue, items); + } + + if (nextFilterValue) { + const filterItemFn = filterItem || defaultFilterFn; + + return () => + defaultFilterItems(items, (item) => filterItemFn(nextFilterValue, item)); + } + + return () => items; + }, + [filterItem, filterItems], + ); + + if (externalItems !== prevItems) { + setItems(filterItemsFn(filter, externalItems)); + setPrevItems(externalItems); + } + + const reset = React.useCallback(() => { + setFilter(initialFilterValue); + setItems(externalItems); + }, [externalItems, initialFilterValue]); + + const onChange = React.useMemo(() => { + const debouncedFn = debounce( + (value) => setItems(filterItemsFn(value, externalItems)), + debounceTimeout, + ); + + return (nextFilterValue: string) => { + setFilter(nextFilterValue); + debouncedFn(nextFilterValue); + }; + }, [debounceTimeout, externalItems, filterItemsFn]); + + return { + filterRef, + filter, + reset, + items, + onChange, + }; +} diff --git a/src/components/ListNext/hooks/useListKeydown.tsx b/src/components/ListNext/hooks/useListKeydown.tsx new file mode 100644 index 0000000000..5705e5784b --- /dev/null +++ b/src/components/ListNext/hooks/useListKeydown.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import type {ListItemId} from '../types'; +import {findNextIndex} from '../utils/findNextIndex'; +import {scrollToListItem} from '../utils/scrollToListItem'; + +interface UseListKeydownProps { + flattenIdsOrder: ListItemId[]; + onItemClick?(itemId: ListItemId): void; + containerRef?: React.RefObject; + activeItemId?: ListItemId; + setActiveItemId?(id: ListItemId): void; + disabled?: Record; + enactive?: boolean; +} + +// Use this hook if you need keyboard support for tree structure lists +export const useListKeydown = ({ + flattenIdsOrder, + onItemClick, + containerRef, + disabled = {}, + activeItemId, + setActiveItemId, + enactive, +}: UseListKeydownProps) => { + const activateItem = React.useCallback( + (index?: number, scrollTo = true) => { + if (typeof index === 'number' && flattenIdsOrder[index]) { + if (scrollTo) { + scrollToListItem(flattenIdsOrder[index], containerRef?.current); + } + + setActiveItemId?.(flattenIdsOrder[index]); + } + }, + [containerRef, flattenIdsOrder, setActiveItemId], + ); + + const handleKeyMove = React.useCallback( + (event: KeyboardEvent, step: number, defaultItemIndex = 0) => { + event.preventDefault(); + + const maybeIndex = flattenIdsOrder.findIndex((i) => i === activeItemId); + + const nextIndex = findNextIndex({ + list: flattenIdsOrder, + index: (maybeIndex > -1 ? maybeIndex : defaultItemIndex) + step, + step: Math.sign(step), + disabledItems: disabled, + }); + + activateItem(nextIndex); + }, + [activateItem, activeItemId, disabled, flattenIdsOrder], + ); + + React.useLayoutEffect(() => { + const anchor = containerRef?.current; + + if (enactive || !anchor) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case 'ArrowDown': { + handleKeyMove(event, 1, -1); + break; + } + case 'ArrowUp': { + handleKeyMove(event, -1); + break; + } + case ' ': + case 'Enter': { + if (activeItemId && !disabled[activeItemId]) { + event.preventDefault(); + + onItemClick?.(activeItemId); + } + break; + } + default: { + } + } + }; + + anchor.addEventListener('keydown', handleKeyDown); + + return () => { + anchor.removeEventListener('keydown', handleKeyDown); + }; + }, [activeItemId, containerRef, disabled, enactive, handleKeyMove, onItemClick]); +}; diff --git a/src/components/ListNext/hooks/useListParsedState.ts b/src/components/ListNext/hooks/useListParsedState.ts new file mode 100644 index 0000000000..52d3403822 --- /dev/null +++ b/src/components/ListNext/hooks/useListParsedState.ts @@ -0,0 +1,25 @@ +/* eslint-disable valid-jsdoc */ +import React from 'react'; + +import type {ListItemId, ListItemType} from '../types'; +import {getListParsedState} from '../utils/getListParsedState'; + +interface UseListParsedStateProps { + items: ListItemType[]; + /** + * List item id dependant of data + */ + getId?(item: T): ListItemId; +} + +/** + * From the tree structure of list items we get meta information and + * flatten list in right order without taking elements that hidden in expanded groups + */ +export function useListParsedState({items, getId}: UseListParsedStateProps) { + const result = React.useMemo(() => { + return getListParsedState(items, getId); + }, [getId, items]); + + return result; +} diff --git a/src/components/ListNext/hooks/useListState.ts b/src/components/ListNext/hooks/useListState.ts new file mode 100644 index 0000000000..67f6d60e98 --- /dev/null +++ b/src/components/ListNext/hooks/useListState.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import React from 'react'; + +import type {ListItemId} from '../types'; + +interface UseListStateProps { + disabled?: Record; + selected?: Record; + expanded?: Record; + initialActiveItemId?: ListItemId; + controlled?: boolean; +} + +function useControlledState(value: T, defaultValue: T, controlled = false) { + const initialValueRef = React.useRef(value); + const [state, setState] = React.useState(value || defaultValue); + + if (initialValueRef.current !== value && controlled) { + initialValueRef.current = value; + setState(value); + } + + return [state, setState] as const; +} + +export const useListState = (props: UseListStateProps = {}) => { + // state default value infered by second argument + const [disabled, setDisabled] = useControlledState(props.disabled!, {}, props.controlled); + const [selected, setSelected] = useControlledState(props.selected!, {}, props.controlled); + const [expanded, setExpanded] = useControlledState(props.expanded!, {}, props.controlled); + const [activeItemId, setActiveItemId] = useControlledState( + props.initialActiveItemId, + undefined, + props.controlled, + ); + + return { + disabled, + setDisabled, + selected, + setSelected, + expanded, + setExpanded, + activeItemId, + setActiveItemId, + }; +}; diff --git a/src/components/ListNext/index.ts b/src/components/ListNext/index.ts new file mode 100644 index 0000000000..7eab416a89 --- /dev/null +++ b/src/components/ListNext/index.ts @@ -0,0 +1,17 @@ +export * from './hooks/useListFilter'; +export * from './hooks/useList'; +export * from './hooks/useListKeydown'; +export * from './hooks/useListState'; +export * from './types'; +export * from './components/ListItemView/ListItemView'; +export * from './components/ListGroupItemView/ListGroupItemView'; +export * from './components/ListRecursiveRenderer/ListRecursiveRenderer'; +export * from './components/VirtualizedListContainer/VirtualizedListContainer.async'; +export * from './components/ItemRenderer/ItemRenderer'; +export * from './components/ItemRenderer/defaultItemRendererBuilder'; +export * from './components/ListContainerView/ListContainerView'; +export * from './components/ListBodyRenderer/ListBodyRenderer'; +export * from './utils/computeItemSize'; +export * from './utils/scrollToListItem'; +export * from './utils/getListParsedState'; +export {bListRadiuses, modToHeight} from './constants'; diff --git a/src/components/ListNext/types.ts b/src/components/ListNext/types.ts new file mode 100644 index 0000000000..6803650533 --- /dev/null +++ b/src/components/ListNext/types.ts @@ -0,0 +1,97 @@ +export type ListItemId = string; + +export type ListSizeTypes = 's' | 'm' | 'l' | 'xl'; + +export interface ListItemType { + /** + * If you need to control the state from the outside, + * you can set a unique id for each element + */ + id?: string; + /** + * Initial disabled item state + */ + disabled?: boolean; + /** + * Initial selected item state + */ + selected?: boolean; + /** + * Default expanded state if group + */ + expanded?: boolean; + data: T; + children?: ListItemType[]; +} + +export type GroupParsedState = { + childrenCount: number; + childrenIds: ListItemId[]; + // initial group item state + expanded?: boolean; +}; + +export type ListGroupState = Record; + +export type ItemParsedState = { + parentId?: ListItemId; + indentation: number; + // initial item state + selected: boolean; + disabled: boolean; +}; +export type ItemsParsedState = Record; + +export type ParsedState = { + /** + * Stored internal meta info about item + * Note: Groups are also items + */ + itemsState: ItemsParsedState; + /** + * Normalized original data + */ + byId: Record; + /** + * Stored info about group items: + */ + groupsState: ListGroupState; + lastItemId: ListItemId; +}; + +export type RenderItemContext = { + itemState: ItemParsedState; + /** + * Exists if item is group + */ + groupState?: GroupParsedState; + isLastItem: boolean; +}; + +export type RenderItem = ( + item: T, + // required item props to render + state: { + size: ListSizeTypes; + id: ListItemId; + onClick?(): void; + selected: boolean; + disabled: boolean; + expanded: boolean; + active: boolean; + }, + // internal list context props + context: RenderItemContext, +) => React.JSX.Element; + +export type KnownItemStructure = { + title: React.ReactNode; + subtitle?: React.ReactNode; + startSlot?: React.ReactNode; + endSlot?: React.ReactNode; +}; + +export type GetItemContent = ( + item: T, + context: {id: ListItemId; isGroup: boolean; isLastItem: boolean}, +) => KnownItemStructure; diff --git a/src/components/ListNext/utils/computeItemSize.ts b/src/components/ListNext/utils/computeItemSize.ts new file mode 100644 index 0000000000..189180b480 --- /dev/null +++ b/src/components/ListNext/utils/computeItemSize.ts @@ -0,0 +1,6 @@ +import {modToHeight} from '../constants'; +import type {ListSizeTypes} from '../types'; + +export const computeItemSize = (size: ListSizeTypes, hasSubRows = false) => { + return modToHeight[size][Number(hasSubRows)]; +}; diff --git a/src/components/ListNext/utils/createListItemId.ts b/src/components/ListNext/utils/createListItemId.ts new file mode 100644 index 0000000000..ac011883ef --- /dev/null +++ b/src/components/ListNext/utils/createListItemId.ts @@ -0,0 +1,2 @@ +export const createListItemId = (itemId: string, listId?: string) => + listId ? `${listId}-${itemId}` : `${itemId}`; diff --git a/src/components/ListNext/utils/defaultFilterItems.ts b/src/components/ListNext/utils/defaultFilterItems.ts new file mode 100644 index 0000000000..e821499ae3 --- /dev/null +++ b/src/components/ListNext/utils/defaultFilterItems.ts @@ -0,0 +1,33 @@ +import type {ListItemType} from '../types'; + +export function defaultFilterItems( + items: ListItemType[], + filterFn: (data: T) => boolean, +): ListItemType[] { + if (process.env.NODE_ENV !== 'production') { + console.time('defaultFilterItems'); + } + + const getChildren = (result: ListItemType[], item: ListItemType) => { + if (item.children) { + const children = item.children.reduce(getChildren, []); + + if (children.length) { + result.push({data: item.data, children}); + } else if (filterFn(item.data)) { + result.push({data: item.data, children: []}); + } + } else if (filterFn(item.data)) { + result.push({data: item.data}); + } + + return result; + }; + + const res = items.reduce[]>(getChildren, []); + + if (process.env.NODE_ENV !== 'production') { + console.timeEnd('defaultFilterItems'); + } + return res; +} diff --git a/src/components/ListNext/utils/findNextIndex.ts b/src/components/ListNext/utils/findNextIndex.ts new file mode 100644 index 0000000000..c00a429b50 --- /dev/null +++ b/src/components/ListNext/utils/findNextIndex.ts @@ -0,0 +1,20 @@ +interface FindNextItemsProps { + list: string[]; + index: number; + step: number; + disabledItems?: Record; +} + +export const findNextIndex = ({list, index, step, disabledItems = {}}: FindNextItemsProps) => { + const dataLength = list.length; + let currentIndex = (index + dataLength) % dataLength; + + for (let i = 0; i < dataLength; i += 1) { + if (list[currentIndex] && !disabledItems[currentIndex]) { + return currentIndex; + } + currentIndex = (currentIndex + dataLength + step) % dataLength; + } + + return undefined; +}; diff --git a/src/components/ListNext/utils/flattenItems.ts b/src/components/ListNext/utils/flattenItems.ts new file mode 100644 index 0000000000..80f0bad1a4 --- /dev/null +++ b/src/components/ListNext/utils/flattenItems.ts @@ -0,0 +1,46 @@ +import type {ListItemId, ListItemType} from '../types'; + +import {getListItemId} from './getListItemId'; + +export function flattenItems( + items: ListItemType[], + groupsExpandedState: Record = {}, + getId?: (item: T) => ListItemId, +): ListItemId[] { + if (process.env.NODE_ENV !== 'production') { + console.time('flattenItems'); + } + + const getNestedIds = ( + order: string[], + item: ListItemType, + index: number, + parentId?: string, + ) => { + const groupedId = getListItemId(index, parentId); + const id = typeof getId === 'function' ? getId(item.data) : item.id || groupedId; + + order.push(id); + + if (item.children) { + // don't include collapsed groups + if (!(id in groupsExpandedState && !groupsExpandedState[id])) { + order.push( + ...item.children.reduce( + (acc, item, idx) => getNestedIds(acc, item, idx, id), + [], + ), + ); + } + } + + return order; + }; + + const result = items.reduce((acc, item, index) => getNestedIds(acc, item, index), []); + + if (process.env.NODE_ENV !== 'production') { + console.timeEnd('flattenItems'); + } + return result; +} diff --git a/src/components/ListNext/utils/getListItemId.ts b/src/components/ListNext/utils/getListItemId.ts new file mode 100644 index 0000000000..c09f97da11 --- /dev/null +++ b/src/components/ListNext/utils/getListItemId.ts @@ -0,0 +1,7 @@ +import {GROUPED_ID_SEPARATOR} from '../constants'; +import type {ListItemId} from '../types'; + +export const getListItemId = (index: string | number, parentId?: string): ListItemId => + parentId ? `${parentId}${GROUPED_ID_SEPARATOR}${index}` : `${index}`; + +export const parseGroupItemId = (id: ListItemId): string[] => id.split(GROUPED_ID_SEPARATOR); diff --git a/src/components/ListNext/utils/getListParsedState.ts b/src/components/ListNext/utils/getListParsedState.ts new file mode 100644 index 0000000000..758237f1de --- /dev/null +++ b/src/components/ListNext/utils/getListParsedState.ts @@ -0,0 +1,85 @@ +import type {ListItemId, ListItemType, ParsedState} from '../types'; + +import {getListItemId, parseGroupItemId} from './getListItemId'; + +interface TraverseItemsProps { + /** + * For example T is entity type with id what represents db id + * So now you can use it id as a list item id in internal state + */ + getId?(item: T): ListItemId; + item: ListItemType; + index: number; + parentId?: ListItemId; + parentGroupedId?: string; +} + +export function getListParsedState( + items: ListItemType[], + getId?: (item: T) => ListItemId, +): ParsedState { + if (process.env.NODE_ENV !== 'production') { + console.time('getListParsedState'); + } + const result: ParsedState = { + byId: {}, + groupsState: {}, + itemsState: {}, + lastItemId: '', + }; + + const traverseItems = ({item, index, parentGroupedId, parentId}: TraverseItemsProps) => { + const groupedId = getListItemId(index, parentGroupedId); + const id = typeof getId === 'function' ? getId(item.data) : item.id || groupedId; + + result.byId[id] = item.data; + + if (!result.itemsState[id]) { + result.itemsState[id] = { + indentation: 0, + selected: false, + disabled: false, + }; + } + + if (typeof parentId !== 'undefined') { + result.itemsState[id].parentId = parentId; + } + + if (typeof item.selected !== 'undefined') { + result.itemsState[id].selected = item.selected; + } + + if (typeof item.disabled !== 'undefined') { + result.itemsState[id].disabled = item.disabled; + } + + if (groupedId) { + result.itemsState[id].indentation = parseGroupItemId(groupedId).length - 1; + } + + result.lastItemId = id; + + if (item.children) { + result.groupsState[id] = { + expanded: item.expanded, + childrenCount: item.children.length, + childrenIds: [], + }; + + item.children.forEach((item, index) => { + result.groupsState[id].childrenIds.push(getListItemId(index, groupedId)); + + traverseItems({item, index, parentGroupedId: groupedId, parentId: id}); + }); + } + }; + + items.forEach((item, index) => traverseItems({item, index})); + + if (process.env.NODE_ENV !== 'production') { + console.timeEnd('getListParsedState'); + } + + return result; +} diff --git a/src/components/ListNext/utils/scrollToListItem.ts b/src/components/ListNext/utils/scrollToListItem.ts new file mode 100644 index 0000000000..1d2086eff3 --- /dev/null +++ b/src/components/ListNext/utils/scrollToListItem.ts @@ -0,0 +1,21 @@ +import {LIST_ITEM_DATA_ATR} from '../constants'; +import type {ListItemId} from '../types'; + +import {createListItemId} from './createListItemId'; + +export const scrollToListItem = ( + itemId: ListItemId, + containerRef?: HTMLDivElement | HTMLUListElement | null, +) => { + if (document) { + const element = (containerRef || document).querySelector( + `[${LIST_ITEM_DATA_ATR}="${createListItemId(itemId)}"]`, + ); + + if (element) { + element.scrollIntoView({ + block: 'nearest', + }); + } + } +}; diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts index b8433df9bf..cac9103820 100644 --- a/src/components/Select/types.ts +++ b/src/components/Select/types.ts @@ -13,7 +13,7 @@ export type SelectRenderClearArgs = { export type SelectRenderControlProps = { onClear: () => void; onClick: () => void; - onKeyDown: (e: React.KeyboardEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; renderClear?: (args: SelectRenderClearArgs) => React.ReactNode; ref: React.Ref; open: boolean; diff --git a/src/components/TreeSelect/TreeSelect.scss b/src/components/TreeSelect/TreeSelect.scss new file mode 100644 index 0000000000..a12d97fa41 --- /dev/null +++ b/src/components/TreeSelect/TreeSelect.scss @@ -0,0 +1,10 @@ +@use '../variables'; + +$block: '.#{variables.$ns}tree-select'; + +#{$block} { + &__popup { + overflow: hidden; + min-width: 300px; + } +} diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx new file mode 100644 index 0000000000..af60980b10 --- /dev/null +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -0,0 +1,279 @@ +import React from 'react'; + +import {useForkRef, useUniqId} from '../../hooks'; +import { + ItemRenderer, + ListBodyRenderer, + ListContainerView, + type ListItemId, + bListRadiuses, + computeItemSize, + defaultItemRendererBuilder, + scrollToListItem, + useList, + useListKeydown, +} from '../ListNext'; +import {SelectControl} from '../Select/components'; +import {SelectPopup} from '../Select/components/SelectPopup/SelectPopup'; +import {Flex} from '../layout'; +import {useMobile} from '../mobile'; +import {block} from '../utils/cn'; + +import {useTreeSelectSelection} from './hooks/useTreeSelectSelection'; +import type {RenderControlProps, TreeSelectProps} from './types'; + +import './TreeSelect.scss'; + +const b = block('tree-select'); + +export const TreeSelect = React.forwardRef(function TreeSelect( + { + id, + slotBeforeListBody, + slotAfterListBody, + size = 'm', + items, + defaultOpen, + popupClassName, + open: propsOpen, + multiple, + popupWidth, + listContainerClassName, + expandedItemsMap, + defaultValue, + virtualized, + popupDisablePortal, + groupAction = 'items-count', + disabledItemsStateMap, + value: propsValue, + groupsBehavior = 'expandable', + containerWrapper, + onClose, + onUpdate, + getItemContent, + getId, + onOpenChange, + renderControl, + itemWrapper, + renderItem: propsRenderItem, + }: 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 handleControlRef = useForkRef(ref, controlRef); + + const [{byId, flattenIdsOrder, groupsState, itemsState, lastItemId}, listState] = useList({ + items, + expanded: expandedItemsMap, + getId, + }); + + const { + value, + open, + toggleOpen, + handleClearValue, + handleMultipleSelection, + handleSingleSelection, + } = useTreeSelectSelection({ + onUpdate, + value: propsValue, + defaultValue, + defaultOpen, + open: propsOpen, + onClose, + onOpenChange, + }); + + const lastSelectedItemId = value[value.length - 1]; + const expanded = expandedItemsMap || listState.expanded; + const disabled = disabledItemsStateMap || listState.disabled; + const selected = React.useMemo( + () => + value.reduce>((acc, value) => { + acc[value] = true; + return acc; + }, {}), + [value], + ); + + const handleItemClick = React.useCallback( + (id: ListItemId) => { + if (listState.disabled[id]) return; + + listState.setActiveItemId(id); + + const isGroup = id in groupsState; + + if (isGroup && groupsBehavior === 'expandable') { + // toggle group selection + listState.setExpanded((state) => ({ + ...state, + // by default all groups expanded + [id]: typeof state[id] === 'boolean' ? !state[id] : false, + })); + } else if (multiple) { + handleMultipleSelection(id); + } else { + handleSingleSelection(id); + toggleOpen(false); + } + }, + [ + groupsState, + groupsBehavior, + handleMultipleSelection, + handleSingleSelection, + listState, + multiple, + toggleOpen, + ], + ); + + // restoring focus when popup opens + React.useLayoutEffect(() => { + if (open) { + containerRef.current?.focus(); + + const firstItemId = flattenIdsOrder[0]; + + listState.setActiveItemId(lastSelectedItemId ?? firstItemId); + + if (lastSelectedItemId) { + scrollToListItem(lastSelectedItemId, containerRef.current); + } + } + // subscribe only in open event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useListKeydown({ + containerRef, + activeItemId: listState.activeItemId, + setActiveItemId: listState.setActiveItemId, + onItemClick: handleItemClick, + flattenIdsOrder, + disabled, + }); + + const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]); + + let containerNode = ( + + + computeItemSize( + size, + Boolean( + getItemContent(byId[flattenIdsOrder[index]], { + isLastItem: lastItemId === flattenIdsOrder[index], + id: flattenIdsOrder[index], + isGroup: flattenIdsOrder[index] in groupsState, + }).subtitle, + ), + ) + } + > + {(id) => ( + + )} + + + ); + + if (containerWrapper) { + // the full list of properties will be updated as the component develops + containerNode = containerWrapper(containerNode, {items}); + } + + const controlProps: RenderControlProps = { + open, + toggleOpen, + clearValue: handleClearValue, + ref: handleControlRef, + size, + value, + id: treeSelectId, + activeItemId: listState.activeItemId, + }; + + const togglerNode = renderControl ? ( + renderControl(controlProps) + ) : ( + + getItemContent(byId[id], { + id, + isGroup: id in groupsState, + isLastItem: lastItemId === id, + }).title, + ), + ).join(', ')} + view="normal" + pin="round-round" + popupId={`tree-select-popup-${treeSelectId}`} + selectId={`tree-select-${treeSelectId}`} + /> + ); + + return ( + + {togglerNode} + + {slotBeforeListBody} + {containerNode} + {slotAfterListBody} + + + ); +}) as (props: TreeSelectProps & {ref?: React.Ref}) => React.ReactElement; diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx new file mode 100644 index 0000000000..bb234c8158 --- /dev/null +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -0,0 +1,286 @@ +import React from 'react'; + +import {ChevronDown, ChevronUp, Database, PlugConnection} from '@gravity-ui/icons'; +import type {Meta, StoryFn} from '@storybook/react'; +import identity from 'lodash/identity'; + +import {Button} from '../../Button'; +import {Icon} from '../../Icon'; +import type {GetItemContent, ListItemId, ListItemType} from '../../ListNext'; +import {getListParsedState} from '../../ListNext'; +import {createRandomizedData} from '../../ListNext/__stories__/utils/makeData'; +import {useInfinityFetch} from '../../ListNext/__stories__/utils/useInfinityFetch'; +import {IntersectionContainer} from '../../ListNext/components/IntersectionContainer/IntersectionContainer'; +import {useListFilter} from '../../ListNext/hooks/useListFilter'; +import {Loader} from '../../Loader'; +import {Text} from '../../Text'; +import {TextInput} from '../../controls'; +import {Flex, spacing} from '../../layout'; +import {TreeSelect} from '../TreeSelect'; +import type {TreeSelectProps} from '../types'; + +export default { + title: 'Unstable/TreeSelect', + component: TreeSelect, +} as Meta; + +const DefaultExample: StoryFn< + Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { + itemsCount?: number; + } +> = ({itemsCount = 5, ...props}) => { + const items = React.useMemo(() => createRandomizedData(itemsCount), [itemsCount]); + const [value, setValue] = React.useState([]); + + return ( + + + + ); +}; +export const Default = DefaultExample.bind({}); +DefaultExample.args = { + size: 'l', +}; + +const getItemsExpandedState = (items: ListItemType[]) => { + return Object.entries(getListParsedState(items).groupsState).reduce< + Record + >((acc, [groupId, {expanded}]) => { + acc[groupId] = true; + + if (typeof expanded !== 'undefined') { + acc[groupId] = expanded; + } + return acc; + }, {}); +}; + +const WithGroupSelectionControlledStateAndCustomIconsExample: StoryFn< + Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { + itemsCount?: number; + } +> = ({itemsCount = 5, ...props}) => { + const items = React.useMemo(() => createRandomizedData(itemsCount), [itemsCount]); + const [value, setValue] = React.useState([]); + const [expandedItemsMap, setExpanded] = React.useState>(() => + getItemsExpandedState(items), + ); + + const getItemContent: GetItemContent<{title: string}> = ({title}, {isGroup, id}) => ({ + title, + startSlot: , + endSlot: isGroup ? ( + + ) : undefined, + }); + + return ( + + + + ); +}; +export const WithGroupSelectionControlledStateAndCustomIcons = + WithGroupSelectionControlledStateAndCustomIconsExample.bind({}); +WithGroupSelectionControlledStateAndCustomIcons.args = { + size: 'l', + multiple: true, + groupsBehavior: 'selectable', +}; + +const InfinityScrollExample: StoryFn< + Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { + itemsCount?: number; + } +> = ({itemsCount = 5, ...props}) => { + const [value, setValue] = React.useState([]); + const { + data = [], + onFetchMore, + canFetchMore, + isLoading, + } = useInfinityFetch<{title: string}>(itemsCount, true); + + return ( + + { + if (isLastItem) { + return ( + + {node} + + ); + } + + return node; + }} + virtualized + items={data} + onUpdate={setValue} + slotAfterListBody={ + isLoading && ( + + + + ) + } + /> + + ); +}; +export const InfinityScroll = InfinityScrollExample.bind({}); +InfinityScrollExample.args = { + size: 'm', + multiple: true, +}; + +const WithFiltrationAndControlsExample: StoryFn< + Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { + itemsCount?: number; + } +> = ({itemsCount = 5, ...props}) => { + const items = React.useMemo(() => createRandomizedData(itemsCount), [itemsCount]); + const [open, onOpenChange] = React.useState(true); + const [value, setValue] = React.useState([]); + const filterState = useListFilter({items}); + + return ( + + + } + containerWrapper={(node, context) => { + if (context.items.length === 0 && items.length > 0) { + return ( + + Nothing found + + ); + } + + return node; + }} + slotAfterListBody={ + + + + + } + value={value} + getItemContent={identity} + items={filterState.items} + onUpdate={setValue} + /> + + ); +}; +export const WithFiltrationAndControls = WithFiltrationAndControlsExample.bind({}); +WithFiltrationAndControlsExample.args = { + size: 'l', +}; + +const emptyItems: ListItemType<{title: string}>[] = []; + +const WithCustomEmptyContentExample: StoryFn< + Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> +> = (props) => { + return ( + + { + if (context.items.length === 0) { + return ( + + Nothing found + + ); + } + + return node; + }} + getItemContent={(x) => x} + /> + + ); +}; +export const WithCustomEmptyContent = WithCustomEmptyContentExample.bind({}); +WithCustomEmptyContentExample.args = { + size: 'l', +}; diff --git a/src/components/TreeSelect/hooks/useTreeSelectSelection.ts b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts new file mode 100644 index 0000000000..5070b677ac --- /dev/null +++ b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts @@ -0,0 +1,79 @@ +import React from 'react'; + +import type {UseOpenProps} from '../../../hooks/useSelect/types'; +import {useOpenState} from '../../../hooks/useSelect/useOpenState'; +import type {ListItemId} from '../../ListNext/types'; + +type UseTreeSelectSelectionProps = { + value?: ListItemId[]; + defaultValue?: ListItemId[]; + onUpdate?: (value: ListItemId[]) => void; +} & UseOpenProps; + +export const useTreeSelectSelection = ({ + defaultOpen, + onClose, + onOpenChange, + open: openProps, + value: valueProps, + defaultValue = [], + onUpdate, +}: UseTreeSelectSelectionProps) => { + const [innerValue, setInnerValue] = React.useState(defaultValue); + + const value = valueProps || innerValue; + const uncontrolled = !valueProps; + + const {toggleOpen, open} = useOpenState({ + defaultOpen, + onClose, + onOpenChange, + open: openProps, + }); + + const handleSingleSelection = React.useCallback( + (id: ListItemId) => { + if (!value.includes(id)) { + const nextValue = [id]; + onUpdate?.(nextValue); + + if (uncontrolled) { + setInnerValue(nextValue); + } + } + + toggleOpen(false); + }, + [value, uncontrolled, onUpdate, toggleOpen], + ); + + const handleMultipleSelection = React.useCallback( + (id: ListItemId) => { + const alreadySelected = value.includes(id); + const nextValue = alreadySelected + ? value.filter((iteratedVal) => iteratedVal !== id) + : [...value, id]; + + onUpdate?.(nextValue); + + if (uncontrolled) { + setInnerValue(nextValue); + } + }, + [value, uncontrolled, onUpdate], + ); + + const handleClearValue = React.useCallback(() => { + onUpdate?.([]); + setInnerValue([]); + }, [onUpdate]); + + return { + open, + value, + toggleOpen, + handleSingleSelection, + handleMultipleSelection, + handleClearValue, + }; +}; diff --git a/src/components/TreeSelect/index.ts b/src/components/TreeSelect/index.ts new file mode 100644 index 0000000000..97fbb66516 --- /dev/null +++ b/src/components/TreeSelect/index.ts @@ -0,0 +1,2 @@ +export {TreeSelect} from './TreeSelect'; +export type {TreeSelectProps} from './types'; diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts new file mode 100644 index 0000000000..f15ef89fce --- /dev/null +++ b/src/components/TreeSelect/types.ts @@ -0,0 +1,83 @@ +import type React from 'react'; + +import type { + GetItemContent, + ListItemId, + ListItemType, + ListSizeTypes, + RenderItem, + RenderItemContext, +} from '../ListNext/types'; +import type {QAProps} from '../types'; + +export type RenderControlProps = { + open: boolean; + toggleOpen(): void; + clearValue(): void; + ref: React.Ref; + size: ListSizeTypes; + value: ListItemId[]; + id: string; + activeItemId?: ListItemId; +}; + +export interface TreeSelectProps extends QAProps { + value?: string[]; + defaultOpen?: boolean; + defaultValue?: ListItemId[]; + items: ListItemType[]; + open?: boolean; + id?: string | undefined; + popupClassName?: string; + popupWidth?: number; + popupDisablePortal?: boolean; + disabledItemsStateMap: Record; + expandedItemsMap: Record; + multiple?: boolean; + /** + * Is it possible to select group elements or not + * @default - 'expandable + */ + groupsBehavior?: 'expandable' | 'selectable'; + virtualized?: boolean; + /** + * If you need custom action button in group, + * use `getItemContent` and pass it as a `endIcon` prop. + * ```tsx + * getItemContent={({title}: T, {isGroup}) => ({ + * title, + * endIcon: isGroup ? buttonNodeWithLogic : undefined + * })} + * ``` + */ + groupAction?: 'none' | 'items-count'; + size: ListSizeTypes; + slotBeforeListBody?: React.ReactNode; + slotAfterListBody?: React.ReactNode; + listContainerClassName?: string; + /** + * Define custom id depended on item data value to use in controlled state component variant + */ + getId?(item: T): ListItemId; + /** + * Ability to override custom toggler btn + */ + renderControl?(props: RenderControlProps): React.JSX.Element; + /** + * Required function to map you custom data to list item props. + * This function need to calculate item size by availability of `subtitle` prop + */ + getItemContent: GetItemContent; + /** + * For example wrap item with divider or some custom react node + */ + itemWrapper?(node: React.JSX.Element, context: RenderItemContext): React.JSX.Element; + onClose?(): void; + containerWrapper?( + originalNode: React.JSX.Element, + context: {items: ListItemType[]}, + ): React.JSX.Element; + renderItem?: RenderItem; + onUpdate?(value: string[]): void; + onOpenChange?(open: boolean): void; +} diff --git a/src/hooks/useSelect/useOpenState.ts b/src/hooks/useSelect/useOpenState.ts index db38e8b902..50f0e24d42 100644 --- a/src/hooks/useSelect/useOpenState.ts +++ b/src/hooks/useSelect/useOpenState.ts @@ -4,7 +4,7 @@ import type {UseOpenProps} from './types'; export const useOpenState = (props: UseOpenProps) => { const [open, setOpenState] = React.useState(props.defaultOpen || false); - const {onOpenChange} = props; + const {onOpenChange, onClose} = props; const isControlled = typeof props.open === 'boolean'; const openValue = isControlled ? (props.open as boolean) : open; @@ -17,8 +17,12 @@ export const useOpenState = (props: UseOpenProps) => { setOpenState(newOpen); } } + + if (newOpen === false && onClose) { + onClose(); + } }, - [openValue, onOpenChange, isControlled], + [openValue, onOpenChange, isControlled, onClose], ); return { diff --git a/src/hooks/useSelect/useSelect.ts b/src/hooks/useSelect/useSelect.ts index 94f30f99ac..7c5c5745ad 100644 --- a/src/hooks/useSelect/useSelect.ts +++ b/src/hooks/useSelect/useSelect.ts @@ -3,13 +3,26 @@ import React from 'react'; import type {UseSelectOption, UseSelectProps, UseSelectResult} from './types'; import {useOpenState} from './useOpenState'; -export const useSelect = (props: UseSelectProps): UseSelectResult => { - const {value: valueProps, defaultValue = [], multiple, onUpdate} = props; +export const useSelect = ({ + defaultOpen, + onClose, + onOpenChange, + open, + value: valueProps, + defaultValue = [], + multiple, + onUpdate, +}: UseSelectProps): UseSelectResult => { const [innerValue, setInnerValue] = React.useState(defaultValue); const [activeIndex, setActiveIndex] = React.useState(); const value = valueProps || innerValue; const uncontrolled = !valueProps; - const {toggleOpen, ...openState} = useOpenState(props); + const {toggleOpen, ...openState} = useOpenState({ + defaultOpen, + onClose, + onOpenChange, + open, + }); const handleSingleSelection = React.useCallback( (option: UseSelectOption) => { diff --git a/src/unstable.ts b/src/unstable.ts new file mode 100644 index 0000000000..5c03064507 --- /dev/null +++ b/src/unstable.ts @@ -0,0 +1,2 @@ +export * from './components/ListNext'; +export * from './components/TreeSelect'; From 145ac92f5f3c53c267dcf6c4a85d291f91bc01bd Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Wed, 29 Nov 2023 12:32:03 +0300 Subject: [PATCH 02/14] fix: fix example with expanded state --- src/components/TreeSelect/__stories__/TreeSelect.stories.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index bb234c8158..a855b09b15 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -84,6 +84,7 @@ const WithGroupSelectionControlledStateAndCustomIconsExample: StoryFn< onClick={(e) => { e.stopPropagation(); setExpanded((prevExpandedState) => ({ + ...prevExpandedState, // by default all groups expanded [id]: id in prevExpandedState ? !prevExpandedState[id] : false, })); From 0edab0e9fa337c30fc327f77dc69ad1351fc8eb7 Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Thu, 14 Dec 2023 11:35:32 +0300 Subject: [PATCH 03/14] fix: fix some list methods --- .../components/ItemRenderer/ItemRenderer.tsx | 10 ++-- .../defaultItemRendererBuilder.tsx | 48 +++++++++++-------- .../ListNext/components/ItemRenderer/index.ts | 3 ++ .../ListNext/components/ItemRenderer/types.ts | 26 ++++++++++ .../ListRecursiveRenderer.tsx | 1 + .../VirtualizedListContainer.tsx | 6 +-- src/components/ListNext/index.ts | 3 +- src/components/ListNext/types.ts | 26 ---------- .../ListNext/utils/getListParsedState.ts | 1 - src/components/TreeSelect/TreeSelect.tsx | 12 ++--- .../__stories__/TreeSelect.stories.tsx | 12 +++-- src/components/TreeSelect/types.ts | 9 ++-- src/unstable.ts | 2 +- 13 files changed, 84 insertions(+), 75 deletions(-) create mode 100644 src/components/ListNext/components/ItemRenderer/index.ts create mode 100644 src/components/ListNext/components/ItemRenderer/types.ts diff --git a/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx b/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx index 2799d88e9e..3cbf77a340 100644 --- a/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx +++ b/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx @@ -1,10 +1,6 @@ -import type { - ItemsParsedState, - ListGroupState, - ListItemId, - ListSizeTypes, - RenderItem, -} from '../../types'; +import type {ItemsParsedState, ListGroupState, ListItemId, ListSizeTypes} from '../../types'; + +import type {RenderItem} from './types'; type ItemRendererProps = { id: ListItemId; diff --git a/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx b/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx index 187c8f866a..2e458b2c9f 100644 --- a/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx +++ b/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx @@ -2,12 +2,17 @@ import React from 'react'; import type {TreeSelectProps} from 'src/unstable'; -import type {GetItemContent, RenderItem, RenderItemContext} from '../../types'; +import type {GetItemContent} from '../../types'; import {ListGroupItemView} from '../ListGroupItemView/ListGroupItemView'; import {ListItemView} from '../ListItemView/ListItemView'; +import type {RenderItem, RenderItemContext} from './types'; + interface BuilderProps extends Pick, 'groupsBehavior' | 'groupAction'> { - itemWrapper?(node: React.JSX.Element, context: RenderItemContext): React.JSX.Element; + itemWrapper?( + getOriginalNode: () => React.JSX.Element, + context: RenderItemContext, + ): React.JSX.Element; /** * Known how map data (T) to list item props */ @@ -27,24 +32,27 @@ export const defaultItemRendererBuilder = function ({ isLastItem, }); - let node: React.ReactNode = groupState ? ( - - ) : ( - - ); - - if (itemWrapper) { - node = itemWrapper(node, {isLastItem, itemState, groupState}); - } + const getNode = () => + groupState ? ( + + ) : ( + + ); + + const node = itemWrapper + ? itemWrapper(getNode, {isLastItem, itemState, groupState}) + : getNode(); return node; }; diff --git a/src/components/ListNext/components/ItemRenderer/index.ts b/src/components/ListNext/components/ItemRenderer/index.ts new file mode 100644 index 0000000000..d6d6f0a319 --- /dev/null +++ b/src/components/ListNext/components/ItemRenderer/index.ts @@ -0,0 +1,3 @@ +export * from './ItemRenderer'; +export * from './defaultItemRendererBuilder'; +export * from './types'; diff --git a/src/components/ListNext/components/ItemRenderer/types.ts b/src/components/ListNext/components/ItemRenderer/types.ts new file mode 100644 index 0000000000..60d7bf2940 --- /dev/null +++ b/src/components/ListNext/components/ItemRenderer/types.ts @@ -0,0 +1,26 @@ +import type {GroupParsedState, ItemParsedState, ListItemId, ListSizeTypes} from '../../types'; + +export type RenderItemContext = { + itemState: ItemParsedState; + /** + * Exists if item is group + */ + groupState?: GroupParsedState; + isLastItem: boolean; +}; + +export type RenderItem = ( + item: T, + // required item props to render + state: { + size: ListSizeTypes; + id: ListItemId; + onClick?(): void; + selected: boolean; + disabled: boolean; + expanded: boolean; + active: boolean; + }, + // internal list context props + context: RenderItemContext, +) => React.JSX.Element; diff --git a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx index df28937eab..06dc521908 100644 --- a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx +++ b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx @@ -19,6 +19,7 @@ export interface ListRecursiveRendererProps { style?: React.CSSProperties; } +// Saves the nested html structure for tree data structure export function ListItemRecursiveRenderer({ itemSchema, index, diff --git a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx b/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx index 5628e2c163..e700e0378e 100644 --- a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx +++ b/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx @@ -1,7 +1,7 @@ import React from 'react'; import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; -import {VariableSizeList as List} from 'react-window'; +import {VariableSizeList} from 'react-window'; import type {ListContainerRenderProps} from './types'; @@ -21,7 +21,7 @@ export function VirtualizedListContainer({ return ( {({width, height}: Size) => ( - ({ {children(data[index], index)} )} - + )} ); diff --git a/src/components/ListNext/index.ts b/src/components/ListNext/index.ts index 7eab416a89..7364d2f61e 100644 --- a/src/components/ListNext/index.ts +++ b/src/components/ListNext/index.ts @@ -7,8 +7,7 @@ export * from './components/ListItemView/ListItemView'; export * from './components/ListGroupItemView/ListGroupItemView'; export * from './components/ListRecursiveRenderer/ListRecursiveRenderer'; export * from './components/VirtualizedListContainer/VirtualizedListContainer.async'; -export * from './components/ItemRenderer/ItemRenderer'; -export * from './components/ItemRenderer/defaultItemRendererBuilder'; +export * from './components/ItemRenderer'; export * from './components/ListContainerView/ListContainerView'; export * from './components/ListBodyRenderer/ListBodyRenderer'; export * from './utils/computeItemSize'; diff --git a/src/components/ListNext/types.ts b/src/components/ListNext/types.ts index 6803650533..4f013ae835 100644 --- a/src/components/ListNext/types.ts +++ b/src/components/ListNext/types.ts @@ -25,7 +25,6 @@ export interface ListItemType { } export type GroupParsedState = { - childrenCount: number; childrenIds: ListItemId[]; // initial group item state expanded?: boolean; @@ -59,31 +58,6 @@ export type ParsedState = { lastItemId: ListItemId; }; -export type RenderItemContext = { - itemState: ItemParsedState; - /** - * Exists if item is group - */ - groupState?: GroupParsedState; - isLastItem: boolean; -}; - -export type RenderItem = ( - item: T, - // required item props to render - state: { - size: ListSizeTypes; - id: ListItemId; - onClick?(): void; - selected: boolean; - disabled: boolean; - expanded: boolean; - active: boolean; - }, - // internal list context props - context: RenderItemContext, -) => React.JSX.Element; - export type KnownItemStructure = { title: React.ReactNode; subtitle?: React.ReactNode; diff --git a/src/components/ListNext/utils/getListParsedState.ts b/src/components/ListNext/utils/getListParsedState.ts index 758237f1de..8884bf18cf 100644 --- a/src/components/ListNext/utils/getListParsedState.ts +++ b/src/components/ListNext/utils/getListParsedState.ts @@ -63,7 +63,6 @@ export function getListParsedState( if (item.children) { result.groupsState[id] = { expanded: item.expanded, - childrenCount: item.children.length, childrenIds: [], }; diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index af60980b10..670acc7497 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -164,7 +164,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]); - let containerNode = ( + const getContainerNode = () => ( ( ); - if (containerWrapper) { - // the full list of properties will be updated as the component develops - containerNode = containerWrapper(containerNode, {items}); - } - const controlProps: RenderControlProps = { open, toggleOpen, @@ -271,7 +266,10 @@ export const TreeSelect = React.forwardRef(function TreeSelect( id={`tree-select-popup-${treeSelectId}`} > {slotBeforeListBody} - {containerNode} + {containerWrapper + ? // the full list of properties will be updated as the component develops + containerWrapper(getContainerNode, {items}) + : getContainerNode()} {slotAfterListBody} diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index a855b09b15..f8fcdc7397 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -144,7 +144,9 @@ const InfinityScrollExample: StoryFn< value={value} popupClassName={spacing({p: 2})} getItemContent={identity} - itemWrapper={(node, {isLastItem}) => { + itemWrapper={(getOriginalNode, {isLastItem}) => { + const node = getOriginalNode(); + if (isLastItem) { return ( } - containerWrapper={(node, context) => { + containerWrapper={(getOriginalNode, context) => { if (context.items.length === 0 && items.length > 0) { return ( @@ -216,7 +218,7 @@ const WithFiltrationAndControlsExample: StoryFn< ); } - return node; + return getOriginalNode(); }} slotAfterListBody={ @@ -265,7 +267,7 @@ const WithCustomEmptyContentExample: StoryFn< { + containerWrapper={(getOriginalNode, context) => { if (context.items.length === 0) { return ( @@ -274,7 +276,7 @@ const WithCustomEmptyContentExample: StoryFn< ); } - return node; + return getOriginalNode(); }} getItemContent={(x) => x} /> diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index f15ef89fce..2810df3a2b 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -7,7 +7,7 @@ import type { ListSizeTypes, RenderItem, RenderItemContext, -} from '../ListNext/types'; +} from '../ListNext'; import type {QAProps} from '../types'; export type RenderControlProps = { @@ -71,10 +71,13 @@ export interface TreeSelectProps extends QAProps { /** * For example wrap item with divider or some custom react node */ - itemWrapper?(node: React.JSX.Element, context: RenderItemContext): React.JSX.Element; + itemWrapper?( + getOriginalNode: () => React.JSX.Element, + context: RenderItemContext, + ): React.JSX.Element; onClose?(): void; containerWrapper?( - originalNode: React.JSX.Element, + getOriginalNode: () => React.JSX.Element, context: {items: ListItemType[]}, ): React.JSX.Element; renderItem?: RenderItem; diff --git a/src/unstable.ts b/src/unstable.ts index 5c03064507..83d038af79 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -1,2 +1,2 @@ -export * from './components/ListNext'; +export {useList, useListFilter, useListKeydown} from './components/ListNext'; export * from './components/TreeSelect'; From 783c60e520378478dd0194b6eb8b59f1874e7e1a Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Tue, 19 Dec 2023 18:15:16 +0300 Subject: [PATCH 04/14] feat(TreeSelect): support dnd --- src/components/ListNext/ListRadiuses.scss | 19 -- .../__stories__/DndExample.stories.tsx | 32 ++ .../__stories__/FlattenRenderer.stories.tsx | 8 +- .../ListInfinityScroll.stories.tsx | 16 +- .../__stories__/PopupWithToggler.stories.tsx | 6 +- .../__stories__/RecursiveRenderer.stories.tsx | 6 +- .../__stories__/components/FlattenList.tsx | 39 +-- .../components/InfinityScrollList.tsx | 69 ++-- .../__stories__/components/ListWithDnd.tsx | 132 ++++++++ .../components/PopupWithTogglerList.tsx | 50 +-- .../__stories__/components/RecursiveList.tsx | 45 +-- .../VirtualizedListContainer.async.tsx | 4 +- .../VirtualizedListContainer.tsx | 5 - .../VirtualizedListContainer/index.ts | 1 + .../VirtualizedListContainer/types.ts | 0 .../ListNext/__stories__/utils/makeData.ts | 34 +- .../__stories__/utils/reorderArray.ts | 11 + .../__stories__/utils/useInfinityFetch.ts | 5 +- .../components/ItemRenderer/ItemRenderer.tsx | 51 --- .../defaultItemRendererBuilder.tsx | 59 ---- .../ListNext/components/ItemRenderer/index.ts | 3 - .../ListNext/components/ItemRenderer/types.ts | 26 -- .../ListBodyRenderer/ListBodyRenderer.tsx | 46 --- .../ListContainerView/ListContainerView.tsx | 14 +- .../ListGroupItemView/ListGroupItemView.tsx | 61 ---- .../components/ListItemView/ListItemView.scss | 4 + .../components/ListItemView/ListItemView.tsx | 56 +++- .../__stories__/ListItemView.stories.tsx | 10 +- .../ListRecursiveRenderer.tsx | 21 +- src/components/ListNext/constants.ts | 10 - .../ListNext/hooks/useFlattenListItems.ts | 10 +- src/components/ListNext/hooks/useList.ts | 17 +- .../ListNext/hooks/useListKeydown.tsx | 16 +- src/components/ListNext/hooks/useListState.ts | 39 +-- src/components/ListNext/index.ts | 7 +- src/components/ListNext/types.ts | 65 +++- .../ListNext/utils/defaultFilterItems.ts | 18 +- src/components/ListNext/utils/flattenItems.ts | 12 +- .../ListNext/utils/getItemRenderState.tsx | 65 ++++ .../ListNext/utils/getListItemId.ts | 24 +- .../ListNext/utils/getListParsedState.ts | 84 ++++- src/components/ListNext/utils/groupItemId.ts | 7 + .../ListNext/utils/isTreeItemGuard.ts | 5 + src/components/TreeSelect/TreeSelect.tsx | 216 ++++++------- .../__stories__/TreeSelect.stories.tsx | 299 ++++-------------- .../components/InfinityScrollExample.tsx | 77 +++++ .../components/RenderVirtualizedContainer.tsx | 25 ++ .../components/WithDndListExample.tsx | 133 ++++++++ .../WithFiltrationAndControlsExample.tsx | 95 ++++++ ...pSelectionControlledStateAndCustomIcon.tsx | 95 ++++++ .../WithItemLinksAndActionsExample.tsx | 111 +++++++ .../TreeListContainer/TreeListContainer.tsx | 30 ++ src/components/TreeSelect/types.ts | 80 +++-- src/components/borderRadius/BorderRadius.scss | 22 ++ src/components/borderRadius/borderRadius.ts | 10 + src/components/borderRadius/index.ts | 1 + src/components/index.ts | 1 + 57 files changed, 1477 insertions(+), 930 deletions(-) delete mode 100644 src/components/ListNext/ListRadiuses.scss create mode 100644 src/components/ListNext/__stories__/DndExample.stories.tsx create mode 100644 src/components/ListNext/__stories__/components/ListWithDnd.tsx rename src/components/ListNext/{ => __stories__}/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx (89%) rename src/components/ListNext/{ => __stories__}/components/VirtualizedListContainer/VirtualizedListContainer.tsx (93%) create mode 100644 src/components/ListNext/__stories__/components/VirtualizedListContainer/index.ts rename src/components/ListNext/{ => __stories__}/components/VirtualizedListContainer/types.ts (100%) create mode 100644 src/components/ListNext/__stories__/utils/reorderArray.ts delete mode 100644 src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx delete mode 100644 src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx delete mode 100644 src/components/ListNext/components/ItemRenderer/index.ts delete mode 100644 src/components/ListNext/components/ItemRenderer/types.ts delete mode 100644 src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx delete mode 100644 src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx create mode 100644 src/components/ListNext/utils/getItemRenderState.tsx create mode 100644 src/components/ListNext/utils/groupItemId.ts create mode 100644 src/components/ListNext/utils/isTreeItemGuard.ts create mode 100644 src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx create mode 100644 src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx create mode 100644 src/components/TreeSelect/__stories__/components/WithDndListExample.tsx create mode 100644 src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx create mode 100644 src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx create mode 100644 src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx create mode 100644 src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx create mode 100644 src/components/borderRadius/BorderRadius.scss create mode 100644 src/components/borderRadius/borderRadius.ts create mode 100644 src/components/borderRadius/index.ts diff --git a/src/components/ListNext/ListRadiuses.scss b/src/components/ListNext/ListRadiuses.scss deleted file mode 100644 index 742058a92d..0000000000 --- a/src/components/ListNext/ListRadiuses.scss +++ /dev/null @@ -1,19 +0,0 @@ -/* stylelint-disable declaration-no-important */ -@use '../variables'; - -$block: '.#{variables.$ns}list-radiuses'; - -#{$block} { - &_s { - border-radius: 5px !important; - } - &_m { - border-radius: 6px !important; - } - &_l { - border-radius: 8px !important; - } - &_xl { - border-radius: 10px !important; - } -} diff --git a/src/components/ListNext/__stories__/DndExample.stories.tsx b/src/components/ListNext/__stories__/DndExample.stories.tsx new file mode 100644 index 0000000000..0e8a79c55a --- /dev/null +++ b/src/components/ListNext/__stories__/DndExample.stories.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {Flex} from '../../layout'; + +import {ListWithDnd as ListWithDndExample, ListWithDndProps} from './components/ListWithDnd'; + +export default { + title: 'Unstable/useList/ListWithDnd', + component: ListWithDndExample, + parameters: { + // Strict mode ruins sortable list due to this react-beautiful-dnd issue + // https://github.com/atlassian/react-beautiful-dnd/issues/2350 + disableStrictMode: true, + }, +} as Meta; + +const ListWithDndTemplate: StoryFn = (props) => { + return ( + + + + ); +}; + +export const ListWithDnd = ListWithDndTemplate.bind({}); + +ListWithDnd.args = { + size: 's', + itemsCount: 10, +}; diff --git a/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx b/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx index 351b0d7b27..2de4bb56f8 100644 --- a/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx +++ b/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx @@ -7,11 +7,11 @@ import {Flex} from '../../layout'; import {FlattenList, FlattenListProps} from './components/FlattenList'; export default { - title: 'Unstable/useList/FlattenRenderer(Virtualized)', + title: 'Unstable/useList/VirtualizedList', component: FlattenList, } as Meta; -const DefaultTemplate: StoryFn = (props) => { +const VirtualizedListTemplate: StoryFn = (props) => { return ( @@ -19,9 +19,9 @@ const DefaultTemplate: StoryFn = (props) => { ); }; -export const Examples = DefaultTemplate.bind({}); +export const VirtualizedList = VirtualizedListTemplate.bind({}); -Examples.args = { +VirtualizedList.args = { size: 's', itemsCount: 1000, }; diff --git a/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx b/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx index 9785f85fd0..f129b2be98 100644 --- a/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx +++ b/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx @@ -2,17 +2,21 @@ import React from 'react'; import type {Meta, StoryFn} from '@storybook/react'; -import {InfinityScrollList, InfinityScrollListProps} from './components/InfinityScrollList'; +import { + InfinityScrollList as InfinityScrollListExample, + InfinityScrollListProps, +} from './components/InfinityScrollList'; export default { title: 'Unstable/useList/InfinityScrollList', - component: InfinityScrollList, + component: InfinityScrollListExample, } as Meta; -const ListInfinityScroll: StoryFn = (props) => { - return ; +const InfinityScrollListTemplate: StoryFn = (props) => { + return ; }; -export const Examples = ListInfinityScroll.bind({}); -Examples.args = { + +export const InfinityScrollList = InfinityScrollListTemplate.bind({}); +InfinityScrollList.args = { size: 'm', }; diff --git a/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx b/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx index 46acf307da..77b7157192 100644 --- a/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx +++ b/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx @@ -11,15 +11,15 @@ export default { component: PopupWithTogglerList, } as Meta; -const PopupWithTogglerScroll: StoryFn = (props) => { +const PopupWithTogglerTemplate: StoryFn = (props) => { return ( ); }; -export const Examples = PopupWithTogglerScroll.bind({}); -Examples.args = { +export const PopupWithToggler = PopupWithTogglerTemplate.bind({}); +PopupWithToggler.args = { itemsCount: 10, size: 'm', }; diff --git a/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx b/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx index 3ef13b962c..67ca690442 100644 --- a/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx +++ b/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx @@ -11,7 +11,7 @@ export default { component: RecursiveList, } as Meta; -const DefaultTemplate: StoryFn = (props) => { +const RecursiveRendererTemplate: StoryFn = (props) => { return ( @@ -19,9 +19,9 @@ const DefaultTemplate: StoryFn = (props) => { ); }; -export const Examples = DefaultTemplate.bind({}); +export const RecursiveRenderer = RecursiveRendererTemplate.bind({}); -Examples.args = { +RecursiveRenderer.args = { size: 's', itemsCount: 10, }; diff --git a/src/components/ListNext/__stories__/components/FlattenList.tsx b/src/components/ListNext/__stories__/components/FlattenList.tsx index ed0480b913..67d686563a 100644 --- a/src/components/ListNext/__stories__/components/FlattenList.tsx +++ b/src/components/ListNext/__stories__/components/FlattenList.tsx @@ -1,21 +1,22 @@ import React from 'react'; import get from 'lodash/get'; -import identity from 'lodash/identity'; import {TextInput} from '../../../controls'; import {Flex} from '../../../layout'; -import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; -import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; -import {VirtualizedListContainer} from '../../components/VirtualizedListContainer/VirtualizedListContainer'; +import {ListItemView} from '../../components/ListItemView/ListItemView'; import {useList} from '../../hooks/useList'; import {useListFilter} from '../../hooks/useListFilter'; import {useListKeydown} from '../../hooks/useListKeydown'; +import {useListState} from '../../hooks/useListState'; import type {ListItemId, ListSizeTypes} from '../../types'; import {computeItemSize} from '../../utils/computeItemSize'; +import {getItemRenderState} from '../../utils/getItemRenderState'; import {createRandomizedData} from '../utils/makeData'; +import {VirtualizedListContainer} from './VirtualizedListContainer'; + export interface FlattenListProps { itemsCount: number; size: ListSizeTypes; @@ -24,14 +25,16 @@ export interface FlattenListProps { export const FlattenList = ({itemsCount, size}: FlattenListProps) => { const containerRef = React.useRef(null); const items = React.useMemo( - () => createRandomizedData<{title: string}>(itemsCount), + () => createRandomizedData<{title: string}>({num: itemsCount}), [itemsCount], ); const filterState = useListFilter({items}); - const [listParsedState, listState] = useList({ - items, + const listState = useListState(); + + const listParsedState = useList({ + items: filterState.items, }); const onItemClick = React.useCallback( @@ -84,18 +87,16 @@ export const FlattenList = ({itemsCount, size}: FlattenListProps) => { ) } > - {(id) => ( - - )} + {(id) => { + const [item, state, _context] = getItemRenderState({ + id, + size, + onItemClick, + ...listParsedState, + ...listState, + }); + return ; + }} diff --git a/src/components/ListNext/__stories__/components/InfinityScrollList.tsx b/src/components/ListNext/__stories__/components/InfinityScrollList.tsx index a43ac22e01..458429f398 100644 --- a/src/components/ListNext/__stories__/components/InfinityScrollList.tsx +++ b/src/components/ListNext/__stories__/components/InfinityScrollList.tsx @@ -1,20 +1,19 @@ import React from 'react'; -import identity from 'lodash/identity'; - import {Button} from '../../../Button'; import {Loader} from '../../../Loader'; import {TextInput} from '../../../controls'; import {Flex} from '../../../layout'; import {IntersectionContainer} from '../../components/IntersectionContainer/IntersectionContainer'; -import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; -import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemView} from '../../components/ListItemView/ListItemView'; import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; import {useList} from '../../hooks/useList'; import {useListFilter} from '../../hooks/useListFilter'; import {useListKeydown} from '../../hooks/useListKeydown'; +import {useListState} from '../../hooks/useListState'; import type {ListItemId, ListSizeTypes} from '../../types'; +import {getItemRenderState} from '../../utils/getItemRenderState'; import {useInfinityFetch} from '../utils/useInfinityFetch'; export interface InfinityScrollListProps { @@ -26,7 +25,9 @@ export const InfinityScrollList = ({size}: InfinityScrollListProps) => { const {data, onFetchMore, canFetchMore, isLoading} = useInfinityFetch<{title: string}>(); const filterState = useListFilter({items: data}); - const [listParsedState, listState] = useList({ + const listState = useListState(); + + const listParsedState = useList({ items: filterState.items, }); @@ -60,7 +61,7 @@ export const InfinityScrollList = ({size}: InfinityScrollListProps) => { const handleAccept = () => { alert( JSON.stringify( - Object.keys(listState.selected).map((id) => listParsedState.byId[id]), + Object.keys(listState.selectedById).map((id) => listParsedState.byId[id]), null, 2, ), @@ -80,43 +81,39 @@ export const InfinityScrollList = ({size}: InfinityScrollListProps) => { /> - {filterState.items.map((item, index) => ( + {listParsedState.items.map((item, index) => ( - {(id) => ( - { - if (isLastItem) { - return ( - - {node} - - ); + {(id) => { + const [data, state, context] = getItemRenderState({ + id, + size, + onItemClick, + ...listParsedState, + ...listState, + }); + const node = ; + + if (context.isLastItem) { + return ( + + {node} + + ); + } - return node; - }, - getItemContent: identity, - })} - /> - )} + return node; + }} ))} diff --git a/src/components/ListNext/__stories__/components/ListWithDnd.tsx b/src/components/ListNext/__stories__/components/ListWithDnd.tsx new file mode 100644 index 0000000000..a98aee6da5 --- /dev/null +++ b/src/components/ListNext/__stories__/components/ListWithDnd.tsx @@ -0,0 +1,132 @@ +import React from 'react'; + +import {Grip} from '@gravity-ui/icons'; +import { + DragDropContext, + Draggable, + DraggableProvided, + DraggableStateSnapshot, + Droppable, + DroppableProvided, +} from 'react-beautiful-dnd'; + +import {Icon} from '../../../Icon'; +import {TextInput} from '../../../controls'; +import {Flex} from '../../../layout'; +import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemView} from '../../components/ListItemView/ListItemView'; +import {useList} from '../../hooks/useList'; +import {useListFilter} from '../../hooks/useListFilter'; +import {useListKeydown} from '../../hooks/useListKeydown'; +import {useListState} from '../../hooks/useListState'; +import type {ListItemId, ListSizeTypes} from '../../types'; +import {getItemRenderState} from '../../utils/getItemRenderState'; +import {createRandomizedData} from '../utils/makeData'; +import {reorderArray} from '../utils/reorderArray'; + +export interface ListWithDndProps { + itemsCount: number; + size: ListSizeTypes; +} + +export const ListWithDnd = ({size, itemsCount}: ListWithDndProps) => { + const containerRef = React.useRef(null); + + const [items, setItems] = React.useState( + createRandomizedData<{title: string}>({num: itemsCount, depth: 0}), + ); + + const filterState = useListFilter({items}); + + const listState = useListState(); + + const listParsedState = useList({ + items: filterState.items, + }); + + const onItemClick = React.useCallback( + (id: ListItemId) => { + if (id in listParsedState.groupsState) { + listState.setExpanded((state) => ({ + ...state, + [id]: id in state ? !state[id] : false, + })); + } else { + // just toggle item by id + listState.setSelected((state) => ({ + [id]: !state[id], + })); + } + + listState.setActiveItemId(id); + }, + [listParsedState.groupsState, listState], + ); + + useListKeydown({ + containerRef, + onItemClick, + ...listParsedState, + ...listState, + }); + + return ( + + + { + if (destination?.index && destination?.index !== source.index) { + setItems((items) => reorderArray(items, source.index, destination.index)); + } + }} + > + + {(droppableProvided: DroppableProvided) => ( +
+ + {listParsedState.flattenIdsOrder.map((id, index) => { + const [data, state, _listContext] = getItemRenderState({ + id, + size, + onItemClick, + ...listParsedState, + ...listState, + }); + + return ( + + {( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( + } + /> + )} + + ); + })} + {droppableProvided.placeholder} + +
+ )} +
+
+
+ ); +}; diff --git a/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx b/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx index 13f998dd21..8a12f506b7 100644 --- a/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx @@ -1,18 +1,17 @@ import React from 'react'; -import identity from 'lodash/identity'; - import {Button} from '../../../Button'; import {Popup} from '../../../Popup'; +import {borderRadius} from '../../../borderRadius'; import {Flex} from '../../../layout'; -import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; -import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemView} from '../../components/ListItemView/ListItemView'; import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; -import {bListRadiuses} from '../../constants'; import {useList} from '../../hooks/useList'; import {useListKeydown} from '../../hooks/useListKeydown'; +import {useListState} from '../../hooks/useListState'; import type {ListItemId, ListSizeTypes} from '../../types'; +import {getItemRenderState} from '../../utils/getItemRenderState'; import {scrollToListItem} from '../../utils/scrollToListItem'; import {createRandomizedData} from '../utils/makeData'; @@ -29,15 +28,21 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro const controlRef = React.useRef(null); const [open, setOpen] = React.useState(false); const items = React.useMemo( - () => createRandomizedData<{title: string}>(itemsCount), + () => createRandomizedData<{title: string}>({num: itemsCount}), [itemsCount], ); - const [listParsedState, listState] = useList({ + const listState = useListState(); + + const listParsedState = useList({ items, + expandedById: listState.expandedById, }); - const [selectedId] = React.useMemo(() => Object.keys(listState.selected), [listState.selected]); + const [selectedId] = React.useMemo( + () => Object.keys(listState.selectedById), + [listState.selectedById], + ); // restoring focus when popup opens React.useLayoutEffect(() => { @@ -83,8 +88,8 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro {selectedId ? listParsedState.byId[selectedId]?.title : 'Select person'} } placement={['bottom-start', 'bottom-end', 'top-start', 'top-end']} offset={[0, 10]} @@ -100,20 +105,19 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro itemSchema={item} key={index} index={index} - expanded={listState.expanded} + expandedById={listState.expandedById} > - {(id) => ( - - )} + {(id) => { + const [data, state, _context] = getItemRenderState({ + id, + size, + onItemClick, + ...listParsedState, + ...listState, + }); + + return ; + }} ))} diff --git a/src/components/ListNext/__stories__/components/RecursiveList.tsx b/src/components/ListNext/__stories__/components/RecursiveList.tsx index ab82e1a84c..81d9b8402e 100644 --- a/src/components/ListNext/__stories__/components/RecursiveList.tsx +++ b/src/components/ListNext/__stories__/components/RecursiveList.tsx @@ -1,17 +1,16 @@ import React from 'react'; -import identity from 'lodash/identity'; - import {TextInput} from '../../../controls'; import {Flex} from '../../../layout'; -import {ItemRenderer} from '../../components/ItemRenderer/ItemRenderer'; -import {defaultItemRendererBuilder} from '../../components/ItemRenderer/defaultItemRendererBuilder'; import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemView} from '../../components/ListItemView/ListItemView'; import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; import {useList} from '../../hooks/useList'; import {useListFilter} from '../../hooks/useListFilter'; import {useListKeydown} from '../../hooks/useListKeydown'; +import {useListState} from '../../hooks/useListState'; import type {ListItemId, ListSizeTypes} from '../../types'; +import {getItemRenderState} from '../../utils/getItemRenderState'; import {createRandomizedData} from '../utils/makeData'; export interface RecursiveListProps { @@ -23,14 +22,17 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { const containerRef = React.useRef(null); const items = React.useMemo( - () => createRandomizedData<{title: string}>(itemsCount), + () => createRandomizedData<{title: string}>({num: itemsCount}), [itemsCount], ); const filterState = useListFilter({items}); - const [listParsedState, listState] = useList({ + const listState = useListState(); + + const listParsedState = useList({ items: filterState.items, + expandedById: listState.expandedById, }); const onItemClick = React.useCallback( @@ -76,20 +78,25 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { itemSchema={item} key={index} index={index} - expanded={listState.expanded} + expandedById={listState.expandedById} > - {(id) => ( - - )} + {(id) => { + const [data, state, listContext] = getItemRenderState({ + id, + size, + onItemClick, + ...listParsedState, + ...listState, + }); + + return ( + + ); + }} ))} diff --git a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx b/src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx similarity index 89% rename from src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx rename to src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx index 69a52f3a64..9d18048fdf 100644 --- a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx +++ b/src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import {Loader} from '../../../Loader'; -import {Flex} from '../../../layout'; +import {Loader} from '../../../../Loader'; +import {Flex} from '../../../../layout'; import type {ListContainerRenderProps} from './types'; diff --git a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx b/src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx similarity index 93% rename from src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx rename to src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx index e700e0378e..5491b6d088 100644 --- a/src/components/ListNext/components/VirtualizedListContainer/VirtualizedListContainer.tsx +++ b/src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx @@ -7,11 +7,6 @@ import type {ListContainerRenderProps} from './types'; const DEFAULT_OVERSCAN_COUNT = 10; -/** - * Ready to use tin wrapper around `react-window` - * - * @return - - */ export function VirtualizedListContainer({ items, className, diff --git a/src/components/ListNext/__stories__/components/VirtualizedListContainer/index.ts b/src/components/ListNext/__stories__/components/VirtualizedListContainer/index.ts new file mode 100644 index 0000000000..506d9b433a --- /dev/null +++ b/src/components/ListNext/__stories__/components/VirtualizedListContainer/index.ts @@ -0,0 +1 @@ +export * from './VirtualizedListContainer.async'; diff --git a/src/components/ListNext/components/VirtualizedListContainer/types.ts b/src/components/ListNext/__stories__/components/VirtualizedListContainer/types.ts similarity index 100% rename from src/components/ListNext/components/VirtualizedListContainer/types.ts rename to src/components/ListNext/__stories__/components/VirtualizedListContainer/types.ts diff --git a/src/components/ListNext/__stories__/utils/makeData.ts b/src/components/ListNext/__stories__/utils/makeData.ts index 0fd442a056..0e4a86f7c9 100644 --- a/src/components/ListNext/__stories__/utils/makeData.ts +++ b/src/components/ListNext/__stories__/utils/makeData.ts @@ -1,38 +1,42 @@ import {faker} from '@faker-js/faker/locale/en'; -import type {ListItemType} from '../../types'; +import type {ListItemType, ListTreeItemType} from '../../types'; const RANDOM_WORDS = Array(50) .fill(null) .map(() => faker.person.fullName()); -export function createRandomizedData( - num = 1000, - hasDepth = true, - getData?: (title: string) => T, -): ListItemType[] { +function base(title: string): T { + return {title} as T; +} + +export function createRandomizedData({ + num, + depth = 3, + getData, +}: { + num: number; + depth?: number; + getData?: (title: string) => T; +}): ListItemType[] { const data = []; for (let i = 0; i < num; i++) { - data.push(createRandomizedItem(hasDepth ? 0 : 3, getData)); + data.push(createRandomizedItem(depth, getData)); } return data; } -function base(title: string): T { - return {title} as T; -} - function createRandomizedItem( depth: number, getData: (title: string) => T = base, -): ListItemType { - const item: ListItemType = { +): ListTreeItemType { + const item: ListTreeItemType = { data: getData(RANDOM_WORDS[Math.floor(Math.random() * RANDOM_WORDS.length)]), }; - const numChildren = depth < 3 ? Math.floor(Math.random() * 5) : 0; + const numChildren = depth > 0 ? Math.floor(Math.random() * 5) : 0; if (numChildren > 0) { item.children = []; @@ -40,7 +44,7 @@ function createRandomizedItem( for (let i = 0; i < numChildren; i++) { if (item.children) { - item.children.push(createRandomizedItem(depth + 1, getData)); + item.children.push(createRandomizedItem(depth - 1, getData)); } } diff --git a/src/components/ListNext/__stories__/utils/reorderArray.ts b/src/components/ListNext/__stories__/utils/reorderArray.ts new file mode 100644 index 0000000000..31f55ad2a9 --- /dev/null +++ b/src/components/ListNext/__stories__/utils/reorderArray.ts @@ -0,0 +1,11 @@ +export const reorderArray = ( + list: T[], + startIndex: number, + endIndex: number, +): T[] => { + const result = [...list]; + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +}; diff --git a/src/components/ListNext/__stories__/utils/useInfinityFetch.ts b/src/components/ListNext/__stories__/utils/useInfinityFetch.ts index 6560912f10..31e082b8dd 100644 --- a/src/components/ListNext/__stories__/utils/useInfinityFetch.ts +++ b/src/components/ListNext/__stories__/utils/useInfinityFetch.ts @@ -14,7 +14,10 @@ function fetchData({ withChildren?: boolean; }) { return new Promise[]>((res) => - setTimeout(() => res(createRandomizedData(itemsCount, withChildren)), timeout), + setTimeout( + () => res(createRandomizedData({num: itemsCount, depth: withChildren ? undefined : 0})), + timeout, + ), ); } diff --git a/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx b/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx deleted file mode 100644 index 3cbf77a340..0000000000 --- a/src/components/ListNext/components/ItemRenderer/ItemRenderer.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type {ItemsParsedState, ListGroupState, ListItemId, ListSizeTypes} from '../../types'; - -import type {RenderItem} from './types'; - -type ItemRendererProps = { - id: ListItemId; - size?: ListSizeTypes; - byId: Record; - itemsState: ItemsParsedState; - groupsState: ListGroupState; - selected: Record; - expanded: Record; - disabled: Record; - activeItemId?: ListItemId; - lastItemId: ListItemId; - onItemClick?(id: ListItemId): void; - renderItem: RenderItem; -}; - -export const ItemRenderer = ({ - byId, - disabled, - expanded, - groupsState, - onItemClick, - id, - size = 'm', - itemsState, - lastItemId, - selected, - activeItemId, - renderItem, -}: ItemRendererProps) => { - return renderItem( - byId[id], - { - id, - size, - expanded: expanded[id], - active: id === activeItemId, - disabled: disabled[id], - selected: selected[id], - onClick: onItemClick ? () => onItemClick(id) : undefined, - }, - { - itemState: itemsState[id], - groupState: groupsState[id], - isLastItem: id === lastItemId, - }, - ); -}; diff --git a/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx b/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx deleted file mode 100644 index 2e458b2c9f..0000000000 --- a/src/components/ListNext/components/ItemRenderer/defaultItemRendererBuilder.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; - -import type {TreeSelectProps} from 'src/unstable'; - -import type {GetItemContent} from '../../types'; -import {ListGroupItemView} from '../ListGroupItemView/ListGroupItemView'; -import {ListItemView} from '../ListItemView/ListItemView'; - -import type {RenderItem, RenderItemContext} from './types'; - -interface BuilderProps extends Pick, 'groupsBehavior' | 'groupAction'> { - itemWrapper?( - getOriginalNode: () => React.JSX.Element, - context: RenderItemContext, - ): React.JSX.Element; - /** - * Known how map data (T) to list item props - */ - getItemContent: GetItemContent; -} - -export const defaultItemRendererBuilder = function ({ - groupsBehavior = 'expandable', - groupAction = 'items-count', - getItemContent, - itemWrapper, -}: BuilderProps): RenderItem { - return (item, state, {isLastItem, itemState, groupState}) => { - const itemContent = getItemContent(item, { - id: state.id, - isGroup: Boolean(groupState), - isLastItem, - }); - - const getNode = () => - groupState ? ( - - ) : ( - - ); - - const node = itemWrapper - ? itemWrapper(getNode, {isLastItem, itemState, groupState}) - : getNode(); - - return node; - }; -}; diff --git a/src/components/ListNext/components/ItemRenderer/index.ts b/src/components/ListNext/components/ItemRenderer/index.ts deleted file mode 100644 index d6d6f0a319..0000000000 --- a/src/components/ListNext/components/ItemRenderer/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './ItemRenderer'; -export * from './defaultItemRendererBuilder'; -export * from './types'; diff --git a/src/components/ListNext/components/ItemRenderer/types.ts b/src/components/ListNext/components/ItemRenderer/types.ts deleted file mode 100644 index 60d7bf2940..0000000000 --- a/src/components/ListNext/components/ItemRenderer/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type {GroupParsedState, ItemParsedState, ListItemId, ListSizeTypes} from '../../types'; - -export type RenderItemContext = { - itemState: ItemParsedState; - /** - * Exists if item is group - */ - groupState?: GroupParsedState; - isLastItem: boolean; -}; - -export type RenderItem = ( - item: T, - // required item props to render - state: { - size: ListSizeTypes; - id: ListItemId; - onClick?(): void; - selected: boolean; - disabled: boolean; - expanded: boolean; - active: boolean; - }, - // internal list context props - context: RenderItemContext, -) => React.JSX.Element; diff --git a/src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx b/src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx deleted file mode 100644 index 613715c119..0000000000 --- a/src/components/ListNext/components/ListBodyRenderer/ListBodyRenderer.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; - -import type {ListItemId, ListItemType} from '../../types'; -import {ListItemRecursiveRenderer} from '../ListRecursiveRenderer/ListRecursiveRenderer'; -import {VirtualizedListContainer} from '../VirtualizedListContainer/VirtualizedListContainer.async'; - -interface ListBodyRendererProps { - expanded: Record; - itemSize(index: number): number; - virtualized?: boolean; - items: ListItemType[]; - flattenIdsOrder: ListItemId[]; - children(id: ListItemId): React.JSX.Element; -} - -export const ListBodyRenderer = ({ - virtualized, - items, - flattenIdsOrder, - itemSize, - expanded, - children, -}: ListBodyRendererProps) => { - if (virtualized) { - return ( - - {children} - - ); - } - - return ( - - {items.map((itemSchema, index) => ( - - {children} - - ))} - - ); -}; diff --git a/src/components/ListNext/components/ListContainerView/ListContainerView.tsx b/src/components/ListNext/components/ListContainerView/ListContainerView.tsx index 930fac00cd..cb2fa3161f 100644 --- a/src/components/ListNext/components/ListContainerView/ListContainerView.tsx +++ b/src/components/ListNext/components/ListContainerView/ListContainerView.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import type {QAProps} from 'src/components/types'; + import {Flex} from '../../../layout'; import {block} from '../../../utils/cn'; @@ -7,15 +9,21 @@ import './ListContainerView.scss'; const b = block('list-container-view'); -export interface ListContainerViewProps { +export interface ListContainerViewProps extends QAProps, React.HTMLAttributes<'div'> { id?: string; className?: string; + /** + * Removes `overflow: auto` from container + */ virtualized?: boolean; children: React.ReactNode; } export const ListContainerView = React.forwardRef( - function ListContainerView({children, id, className, virtualized, ...props}, ref) { + function ListContainerView( + {role = 'listbox', children, id, className, virtualized, ...props}, + ref, + ) { return ( diff --git a/src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx b/src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx deleted file mode 100644 index 1bc92ca676..0000000000 --- a/src/components/ListNext/components/ListGroupItemView/ListGroupItemView.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* eslint-disable react/display-name */ -import React from 'react'; - -import {ChevronDown, ChevronUp} from '@gravity-ui/icons'; - -import {Icon} from '../../../Icon'; -import {Label} from '../../../Label'; -import {Text} from '../../../Text'; -import {ListItemView, ListItemViewProps} from '../ListItemView/ListItemView'; - -export const ExpandIcon = ({expanded, size}: {expanded: boolean; size?: number}) => { - return ; -}; - -export interface ListGroupItemViewProps extends ListItemViewProps { - childrenCount?: number; - expanded?: boolean; - /** - * Show default expand icon view. - * You can override this behavior by passing custom icon in start or end slot - */ - defaultExpandIcon?: boolean; -} - -export const ListGroupItemView = ({ - title, - childrenCount, - expanded = true, - defaultExpandIcon = true, - endSlot, - disabled, - startSlot, - ...props -}: ListGroupItemViewProps) => { - return ( - - {title} - - ) : ( - title - ) - } - endSlot={ - endSlot ?? - (typeof childrenCount === 'number' ? : null) - } - startSlot={startSlot ?? (defaultExpandIcon ? : null)} - selectable={false} - activeOnHover={false} - {...props} - /> - ); -}; diff --git a/src/components/ListNext/components/ListItemView/ListItemView.scss b/src/components/ListNext/components/ListItemView/ListItemView.scss index 8d07b72b90..5835c9508c 100644 --- a/src/components/ListNext/components/ListItemView/ListItemView.scss +++ b/src/components/ListNext/components/ListItemView/ListItemView.scss @@ -20,6 +20,10 @@ $block: '.#{variables.$ns}list-item-view'; background: var(--g-color-base-selection); } + &_dragging { + background: var(--g-color-base-simple-hover-solid); + } + &_hidden { display: none; } diff --git a/src/components/ListNext/components/ListItemView/ListItemView.tsx b/src/components/ListNext/components/ListItemView/ListItemView.tsx index 748221b6be..a7a325fa09 100644 --- a/src/components/ListNext/components/ListItemView/ListItemView.tsx +++ b/src/components/ListNext/components/ListItemView/ListItemView.tsx @@ -1,13 +1,15 @@ import React from 'react'; import {Check} from '@gravity-ui/icons'; -import type {QAProps} from 'src/components/types'; +import {ArrowToggle} from '../../../ArrowToggle'; import {Icon} from '../../../Icon'; import {Text, colorText} from '../../../Text'; +import {borderRadius} from '../../../borderRadius'; import {Flex, FlexProps, spacing} from '../../../layout'; +import type {QAProps} from '../../../types'; import {block} from '../../../utils/cn'; -import {LIST_ITEM_DATA_ATR, bListRadiuses, modToHeight} from '../../constants'; +import {LIST_ITEM_DATA_ATR, modToHeight} from '../../constants'; import type {ListItemId, ListSizeTypes} from '../../types'; import {createListItemId} from '../../utils/createListItemId'; @@ -55,6 +57,8 @@ export interface ListItemViewProps extends QAProps, Omit { + const isGroup = typeof expanded === 'boolean'; const onClick = disabled ? undefined : _onClick; return ( @@ -145,20 +158,31 @@ export const ListItemView = React.forwardRef( {renderSafeIndentation(indentation)} - {startSlot} + {startSlot ?? + (isGroup ? : null)} + {typeof title === 'string' ? ( - {title} + + {title} + ) : ( title )} {typeof subtitle === 'string' ? ( - {subtitle} + + {subtitle} + ) : ( subtitle )} + {endSlot} ); diff --git a/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx b/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx index f72cbb0641..c6babd5fd9 100644 --- a/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx +++ b/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx @@ -4,11 +4,11 @@ import type {Meta, StoryFn} from '@storybook/react'; import {UserAvatar} from '../../../../UserAvatar'; import {Flex} from '../../../../layout'; -import {ListItemView, ListItemViewProps} from '../ListItemView'; +import {ListItemView as ListItemViewComponent, ListItemViewProps} from '../ListItemView'; export default { title: 'Unstable/useList/ListItemView', - component: ListItemView, + component: ListItemViewComponent, } as Meta; const title = 'title'; @@ -69,11 +69,11 @@ const stories: ListItemViewProps[] = [ }, ]; -const DefaultTemplate: StoryFn = () => ( +const ListItemViewTemplate: StoryFn = () => ( {stories.map((props, i) => ( - + ))} ); -export const Examples = DefaultTemplate.bind({}); +export const ListItemView = ListItemViewTemplate.bind({}); diff --git a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx index 06dc521908..f73ab801ed 100644 --- a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx +++ b/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx @@ -1,16 +1,17 @@ import React from 'react'; import {block} from '../../../utils/cn'; -import type {ListItemId, ListItemType} from '../../types'; +import type {ListItemId, ListItemType, ListState} from '../../types'; import {getListItemId} from '../../utils/getListItemId'; +import {getGroupItemId} from '../../utils/groupItemId'; +import {isTreeItemGuard} from '../../utils/isTreeItemGuard'; import './ListRecursiveRenderer.scss'; const b = block('list-recursive-renderer'); -export interface ListRecursiveRendererProps { +export interface ListRecursiveRendererProps extends Partial> { itemSchema: ListItemType; - expanded?: Record; children(id: ListItemId): React.JSX.Element; index: number; parentId?: string; @@ -26,19 +27,17 @@ export function ListItemRecursiveRenderer({ parentId, ...props }: ListRecursiveRendererProps) { - const groupedId = getListItemId(index, parentId); - const id = - typeof props.getId === 'function' - ? props.getId(itemSchema.data) - : itemSchema.id || groupedId; + const groupedId = getGroupItemId(index, parentId); + const id = getListItemId({item: itemSchema, groupedId, getId: props.getId}); const node = props.children(id); - if (itemSchema.children) { - const isExpanded = props.expanded && id in props.expanded ? props.expanded[id] : true; + if (isTreeItemGuard(itemSchema) && itemSchema.children) { + const isExpanded = + props.expandedById && id in props.expandedById ? props.expandedById[id] : true; return ( -
    +
      {node} {isExpanded && itemSchema.children.map((item, index) => ( diff --git a/src/components/ListNext/constants.ts b/src/components/ListNext/constants.ts index 7652eeca50..21b4796591 100644 --- a/src/components/ListNext/constants.ts +++ b/src/components/ListNext/constants.ts @@ -1,15 +1,5 @@ -import {block} from '../utils/cn'; - -import type {ListSizeTypes} from './types'; - -import './ListRadiuses.scss'; - export const LIST_ITEM_DATA_ATR = 'data-list-item'; -const _bListRadiuses = block('list-radiuses'); -export const bListRadiuses = ({size}: {size: ListSizeTypes}, className?: string) => - _bListRadiuses({[size]: true}, className); - export const GROUPED_ID_SEPARATOR = '-'; export const modToHeight = { diff --git a/src/components/ListNext/hooks/useFlattenListItems.ts b/src/components/ListNext/hooks/useFlattenListItems.ts index b37084d3aa..b764306da6 100644 --- a/src/components/ListNext/hooks/useFlattenListItems.ts +++ b/src/components/ListNext/hooks/useFlattenListItems.ts @@ -6,19 +6,19 @@ import {flattenItems} from '../utils/flattenItems'; interface UseFlattenListItemsProps { items: ListItemType[]; - expanded?: Record; + expandedById?: Record; getId?(item: T): ListItemId; } /** * Pick ids from items and flatten children. * Returns flatten ids list tree structure representation. - * Not included items if they in `expanded` map + * Not included items if they in `expandedById` map */ -export function useFlattenListItems({items, expanded, getId}: UseFlattenListItemsProps) { +export function useFlattenListItems({items, expandedById, getId}: UseFlattenListItemsProps) { const order = React.useMemo(() => { - return flattenItems(items, expanded, getId); - }, [items, expanded, getId]); + return flattenItems(items, expandedById, getId); + }, [items, expandedById, getId]); return order; } diff --git a/src/components/ListNext/hooks/useList.ts b/src/components/ListNext/hooks/useList.ts index 3917159168..9a8dbf295c 100644 --- a/src/components/ListNext/hooks/useList.ts +++ b/src/components/ListNext/hooks/useList.ts @@ -1,31 +1,32 @@ -import type {ListItemId, ListItemType} from '../types'; +/* eslint-disable valid-jsdoc */ +import type {ListItemId, ListItemType, ListParsedState} from '../types'; import {useFlattenListItems} from './useFlattenListItems'; import {useListParsedState} from './useListParsedState'; -import {useListState} from './useListState'; interface UseListProps { items: ListItemType[]; /** * Control expanded items state from external source */ - expanded?: Record; + expandedById?: Record; getId?(item: T): ListItemId; } -export const useList = ({items, expanded, getId}: UseListProps) => { +/** + * Take array of items as a argument and returns parsed representation of this data structure to work with + */ +export const useList = ({items, expandedById, getId}: UseListProps): ListParsedState => { const {byId, groupsState, itemsState, lastItemId} = useListParsedState({ items, getId, }); - const state = useListState(); - const flattenIdsOrder = useFlattenListItems({ items, - expanded: expanded || state.expanded, + expandedById, getId, }); - return [{flattenIdsOrder, byId, groupsState, itemsState, lastItemId}, state] as const; + return {items, flattenIdsOrder, byId, groupsState, itemsState, lastItemId}; }; diff --git a/src/components/ListNext/hooks/useListKeydown.tsx b/src/components/ListNext/hooks/useListKeydown.tsx index 5705e5784b..b230cb2809 100644 --- a/src/components/ListNext/hooks/useListKeydown.tsx +++ b/src/components/ListNext/hooks/useListKeydown.tsx @@ -1,16 +1,14 @@ import React from 'react'; -import type {ListItemId} from '../types'; +import type {ListItemId, ListState} from '../types'; import {findNextIndex} from '../utils/findNextIndex'; import {scrollToListItem} from '../utils/scrollToListItem'; -interface UseListKeydownProps { +interface UseListKeydownProps extends Partial> { flattenIdsOrder: ListItemId[]; onItemClick?(itemId: ListItemId): void; containerRef?: React.RefObject; - activeItemId?: ListItemId; setActiveItemId?(id: ListItemId): void; - disabled?: Record; enactive?: boolean; } @@ -19,7 +17,7 @@ export const useListKeydown = ({ flattenIdsOrder, onItemClick, containerRef, - disabled = {}, + disabledById = {}, activeItemId, setActiveItemId, enactive, @@ -47,12 +45,12 @@ export const useListKeydown = ({ list: flattenIdsOrder, index: (maybeIndex > -1 ? maybeIndex : defaultItemIndex) + step, step: Math.sign(step), - disabledItems: disabled, + disabledItems: disabledById, }); activateItem(nextIndex); }, - [activateItem, activeItemId, disabled, flattenIdsOrder], + [activateItem, activeItemId, disabledById, flattenIdsOrder], ); React.useLayoutEffect(() => { @@ -74,7 +72,7 @@ export const useListKeydown = ({ } case ' ': case 'Enter': { - if (activeItemId && !disabled[activeItemId]) { + if (activeItemId && !disabledById[activeItemId]) { event.preventDefault(); onItemClick?.(activeItemId); @@ -91,5 +89,5 @@ export const useListKeydown = ({ return () => { anchor.removeEventListener('keydown', handleKeyDown); }; - }, [activeItemId, containerRef, disabled, enactive, handleKeyMove, onItemClick]); + }, [activeItemId, containerRef, disabledById, enactive, handleKeyMove, onItemClick]); }; diff --git a/src/components/ListNext/hooks/useListState.ts b/src/components/ListNext/hooks/useListState.ts index 67f6d60e98..5b3165abe9 100644 --- a/src/components/ListNext/hooks/useListState.ts +++ b/src/components/ListNext/hooks/useListState.ts @@ -1,45 +1,28 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import React from 'react'; -import type {ListItemId} from '../types'; +import type {ListState} from '../types'; -interface UseListStateProps { - disabled?: Record; - selected?: Record; - expanded?: Record; - initialActiveItemId?: ListItemId; - controlled?: boolean; -} +interface UseListStateProps extends Partial {} -function useControlledState(value: T, defaultValue: T, controlled = false) { - const initialValueRef = React.useRef(value); +function useControlledState(value: T, defaultValue: T) { const [state, setState] = React.useState(value || defaultValue); - if (initialValueRef.current !== value && controlled) { - initialValueRef.current = value; - setState(value); - } - - return [state, setState] as const; + return [value || state, setState] as const; } export const useListState = (props: UseListStateProps = {}) => { - // state default value infered by second argument - const [disabled, setDisabled] = useControlledState(props.disabled!, {}, props.controlled); - const [selected, setSelected] = useControlledState(props.selected!, {}, props.controlled); - const [expanded, setExpanded] = useControlledState(props.expanded!, {}, props.controlled); - const [activeItemId, setActiveItemId] = useControlledState( - props.initialActiveItemId, - undefined, - props.controlled, - ); + const [disabledById, setDisabled] = useControlledState(props.disabledById!, {}); + const [selectedById, setSelected] = useControlledState(props.selectedById!, {}); + const [expandedById, setExpanded] = useControlledState(props.expandedById!, {}); + const [activeItemId, setActiveItemId] = useControlledState(props.activeItemId, undefined); return { - disabled, + disabledById, setDisabled, - selected, + selectedById, setSelected, - expanded, + expandedById, setExpanded, activeItemId, setActiveItemId, diff --git a/src/components/ListNext/index.ts b/src/components/ListNext/index.ts index 7364d2f61e..a2e315811d 100644 --- a/src/components/ListNext/index.ts +++ b/src/components/ListNext/index.ts @@ -4,13 +4,10 @@ export * from './hooks/useListKeydown'; export * from './hooks/useListState'; export * from './types'; export * from './components/ListItemView/ListItemView'; -export * from './components/ListGroupItemView/ListGroupItemView'; export * from './components/ListRecursiveRenderer/ListRecursiveRenderer'; -export * from './components/VirtualizedListContainer/VirtualizedListContainer.async'; -export * from './components/ItemRenderer'; export * from './components/ListContainerView/ListContainerView'; -export * from './components/ListBodyRenderer/ListBodyRenderer'; export * from './utils/computeItemSize'; +export * from './utils/getItemRenderState'; export * from './utils/scrollToListItem'; export * from './utils/getListParsedState'; -export {bListRadiuses, modToHeight} from './constants'; +export {modToHeight} from './constants'; diff --git a/src/components/ListNext/types.ts b/src/components/ListNext/types.ts index 4f013ae835..33d055d10b 100644 --- a/src/components/ListNext/types.ts +++ b/src/components/ListNext/types.ts @@ -1,8 +1,7 @@ export type ListItemId = string; export type ListSizeTypes = 's' | 'm' | 'l' | 'xl'; - -export interface ListItemType { +interface ListItemInitialProps { /** * If you need to control the state from the outside, * you can set a unique id for each element @@ -20,10 +19,17 @@ export interface ListItemType { * Default expanded state if group */ expanded?: boolean; +} + +export type ListFlattenItemType = T & ListItemInitialProps; + +export interface ListTreeItemType extends ListItemInitialProps { data: T; - children?: ListItemType[]; + children?: ListTreeItemType[]; } +export type ListItemType = ListTreeItemType | ListFlattenItemType; + export type GroupParsedState = { childrenIds: ListItemId[]; // initial group item state @@ -41,6 +47,41 @@ export type ItemParsedState = { }; export type ItemsParsedState = Record; +export type KnownItemStructure = { + title: React.ReactNode; + subtitle?: React.ReactNode; + startSlot?: React.ReactNode; + endSlot?: React.ReactNode; +}; + +export interface OverrideItemContext { + id: ListItemId; + isGroup: boolean; + disabled: boolean; + isLastItem: boolean; +} + +export type RenderItemContext = { + itemState: ItemParsedState; + /** + * Exists if item is group + */ + groupState?: GroupParsedState; + isLastItem: boolean; +}; + +export type RenderItemState = { + size: ListSizeTypes; + id: ListItemId; + onClick?(): void; + selected: boolean; + disabled: boolean; + expanded?: boolean; + active: boolean; + indentation: number; + selectable?: boolean; +}; + export type ParsedState = { /** * Stored internal meta info about item @@ -58,14 +99,14 @@ export type ParsedState = { lastItemId: ListItemId; }; -export type KnownItemStructure = { - title: React.ReactNode; - subtitle?: React.ReactNode; - startSlot?: React.ReactNode; - endSlot?: React.ReactNode; +export type ListState = { + disabledById: Record; + selectedById: Record; + expandedById: Record; + activeItemId?: ListItemId; }; -export type GetItemContent = ( - item: T, - context: {id: ListItemId; isGroup: boolean; isLastItem: boolean}, -) => KnownItemStructure; +export type ListParsedState = ParsedState & { + items: ListItemType[]; + flattenIdsOrder: ListItemId[]; +}; diff --git a/src/components/ListNext/utils/defaultFilterItems.ts b/src/components/ListNext/utils/defaultFilterItems.ts index e821499ae3..7ff304c13e 100644 --- a/src/components/ListNext/utils/defaultFilterItems.ts +++ b/src/components/ListNext/utils/defaultFilterItems.ts @@ -1,5 +1,8 @@ -import type {ListItemType} from '../types'; +import type {ListItemType, ListTreeItemType} from '../types'; +import {isTreeItemGuard} from './isTreeItemGuard'; + +// TODO(aisaev188): unit tests export function defaultFilterItems( items: ListItemType[], filterFn: (data: T) => boolean, @@ -9,16 +12,19 @@ export function defaultFilterItems( } const getChildren = (result: ListItemType[], item: ListItemType) => { - if (item.children) { + if (isTreeItemGuard(item) && item.children) { const children = item.children.reduce(getChildren, []); if (children.length) { - result.push({data: item.data, children}); + result.push({...item, data: item.data, children} as ListTreeItemType); } else if (filterFn(item.data)) { - result.push({data: item.data, children: []}); + result.push({...item, data: item.data, children: []}); } - } else if (filterFn(item.data)) { - result.push({data: item.data}); + } else if (isTreeItemGuard(item) && filterFn(item.data)) { + const {children: _children, ...newItem} = item; + result.push(newItem); + } else if (!isTreeItemGuard(item) && filterFn(item)) { + result.push(item); } return result; diff --git a/src/components/ListNext/utils/flattenItems.ts b/src/components/ListNext/utils/flattenItems.ts index 80f0bad1a4..e170da6b9f 100644 --- a/src/components/ListNext/utils/flattenItems.ts +++ b/src/components/ListNext/utils/flattenItems.ts @@ -1,10 +1,12 @@ import type {ListItemId, ListItemType} from '../types'; import {getListItemId} from './getListItemId'; +import {getGroupItemId} from './groupItemId'; +import {isTreeItemGuard} from './isTreeItemGuard'; export function flattenItems( items: ListItemType[], - groupsExpandedState: Record = {}, + expandedById: Record = {}, getId?: (item: T) => ListItemId, ): ListItemId[] { if (process.env.NODE_ENV !== 'production') { @@ -17,14 +19,14 @@ export function flattenItems( index: number, parentId?: string, ) => { - const groupedId = getListItemId(index, parentId); - const id = typeof getId === 'function' ? getId(item.data) : item.id || groupedId; + const groupedId = getGroupItemId(index, parentId); + const id = getListItemId({groupedId, item, getId}); order.push(id); - if (item.children) { + if (isTreeItemGuard(item) && item.children) { // don't include collapsed groups - if (!(id in groupsExpandedState && !groupsExpandedState[id])) { + if (!(id in expandedById && !expandedById[id])) { order.push( ...item.children.reduce( (acc, item, idx) => getNestedIds(acc, item, idx, id), diff --git a/src/components/ListNext/utils/getItemRenderState.tsx b/src/components/ListNext/utils/getItemRenderState.tsx new file mode 100644 index 0000000000..7d213c9d33 --- /dev/null +++ b/src/components/ListNext/utils/getItemRenderState.tsx @@ -0,0 +1,65 @@ +import type { + ItemsParsedState, + ListGroupState, + ListItemId, + ListSizeTypes, + ListState, + RenderItemContext, + RenderItemState, +} from '../types'; + +type ItemRendererProps = ListState & { + size?: ListSizeTypes; + byId: Record; + itemsState: ItemsParsedState; + groupsState: ListGroupState; + lastItemId: ListItemId; + onItemClick?(id: ListItemId): void; + id: ListItemId; +}; + +/** + * Map list state and parsed list state to item render props + */ +export const getItemRenderState = ( + { + byId, + disabledById, + expandedById, + groupsState, + onItemClick, + size = 'm', + itemsState, + lastItemId, + selectedById, + activeItemId, + id, + }: ItemRendererProps, + {defaultExpanded = true}: {defaultExpanded?: boolean} = {}, +) => { + const context: RenderItemContext = { + itemState: itemsState[id], + groupState: groupsState[id], + isLastItem: id === lastItemId, + }; + + let expanded; + + // isGroup + if (groupsState[id]) { + expanded = expandedById[id] ?? defaultExpanded; + } + + const state: RenderItemState = { + id, + size, + expanded, + active: id === activeItemId, + indentation: context.itemState.indentation, + disabled: disabledById[id], + selected: selectedById[id], + onClick: onItemClick ? () => onItemClick(id) : undefined, + }; + + return [byId[id], state, context] as const; +}; diff --git a/src/components/ListNext/utils/getListItemId.ts b/src/components/ListNext/utils/getListItemId.ts index c09f97da11..666978d73c 100644 --- a/src/components/ListNext/utils/getListItemId.ts +++ b/src/components/ListNext/utils/getListItemId.ts @@ -1,7 +1,21 @@ -import {GROUPED_ID_SEPARATOR} from '../constants'; -import type {ListItemId} from '../types'; +import type {ListItemId, ListItemType} from '../types'; -export const getListItemId = (index: string | number, parentId?: string): ListItemId => - parentId ? `${parentId}${GROUPED_ID_SEPARATOR}${index}` : `${index}`; +import {isTreeItemGuard} from './isTreeItemGuard'; -export const parseGroupItemId = (id: ListItemId): string[] => id.split(GROUPED_ID_SEPARATOR); +interface GetListItemIdProps { + item: ListItemType; + groupedId: ListItemId; + getId?(data: T): ListItemId; +} + +export const getListItemId = ({item, groupedId, getId}: GetListItemIdProps) => { + let id = groupedId; + + if (typeof getId === 'function') { + id = getId(isTreeItemGuard(item) ? item.data : item); + } else if (item.id) { + id = item.id; + } + + return id; +}; diff --git a/src/components/ListNext/utils/getListParsedState.ts b/src/components/ListNext/utils/getListParsedState.ts index 8884bf18cf..bdfa616ccd 100644 --- a/src/components/ListNext/utils/getListParsedState.ts +++ b/src/components/ListNext/utils/getListParsedState.ts @@ -1,26 +1,44 @@ -import type {ListItemId, ListItemType, ParsedState} from '../types'; - -import {getListItemId, parseGroupItemId} from './getListItemId'; - -interface TraverseItemsProps { +import type { + ListFlattenItemType, + ListItemId, + ListItemType, + ListTreeItemType, + ParsedState, +} from '../types'; + +import {getListItemId} from './getListItemId'; +import {getGroupItemId, parseGroupItemId} from './groupItemId'; +import {isTreeItemGuard} from './isTreeItemGuard'; + +interface TraverseItemProps { + item: ListFlattenItemType; + index: number; +} +interface TraverseTreeItemProps { /** * For example T is entity type with id what represents db id * So now you can use it id as a list item id in internal state */ getId?(item: T): ListItemId; - item: ListItemType; + item: ListTreeItemType; index: number; parentId?: ListItemId; parentGroupedId?: string; } +// TODO(aisaev188): unit tests export function getListParsedState( items: ListItemType[], + /** + * For example T is entity type with id what represents db id + * So now you can use it id as a list item id in internal state + */ getId?: (item: T) => ListItemId, ): ParsedState { if (process.env.NODE_ENV !== 'production') { console.time('getListParsedState'); } + const result: ParsedState = { byId: {}, groupsState: {}, @@ -28,9 +46,42 @@ export function getListParsedState( lastItemId: '', }; - const traverseItems = ({item, index, parentGroupedId, parentId}: TraverseItemsProps) => { - const groupedId = getListItemId(index, parentGroupedId); - const id = typeof getId === 'function' ? getId(item.data) : item.id || groupedId; + const traverseItem = ({item, index}: TraverseItemProps) => { + const id = getListItemId({groupedId: String(index), item, getId}); + + result.byId[id] = item; + + if (!result.itemsState[id]) { + result.itemsState[id] = { + indentation: 0, + selected: false, + disabled: false, + }; + } + + if (typeof item.selected !== 'undefined') { + result.itemsState[id].selected = item.selected; + } + + if (typeof item.disabled !== 'undefined') { + result.itemsState[id].disabled = item.disabled; + } + + result.lastItemId = id; + }; + + const traverseTreeItem = ({ + item, + index, + parentGroupedId, + parentId, + }: TraverseTreeItemProps) => { + const groupedId = getGroupItemId(index, parentGroupedId); + const id = getListItemId({groupedId, item, getId}); + + if (parentId) { + result.groupsState[parentId].childrenIds.push(id); + } result.byId[id] = item.data; @@ -66,15 +117,20 @@ export function getListParsedState( childrenIds: [], }; - item.children.forEach((item, index) => { - result.groupsState[id].childrenIds.push(getListItemId(index, groupedId)); - - traverseItems({item, index, parentGroupedId: groupedId, parentId: id}); + item.children.forEach((treeItem, index) => { + traverseTreeItem({ + item: treeItem, + index, + parentGroupedId: groupedId, + parentId: id, + }); }); } }; - items.forEach((item, index) => traverseItems({item, index})); + items.forEach((item, index) => + isTreeItemGuard(item) ? traverseTreeItem({item, index}) : traverseItem({item, index}), + ); if (process.env.NODE_ENV !== 'production') { console.timeEnd('getListParsedState'); diff --git a/src/components/ListNext/utils/groupItemId.ts b/src/components/ListNext/utils/groupItemId.ts new file mode 100644 index 0000000000..215f365b09 --- /dev/null +++ b/src/components/ListNext/utils/groupItemId.ts @@ -0,0 +1,7 @@ +import {GROUPED_ID_SEPARATOR} from '../constants'; +import type {ListItemId} from '../types'; + +export const getGroupItemId = (index: string | number, parentId?: string): ListItemId => + parentId ? `${parentId}${GROUPED_ID_SEPARATOR}${index}` : `${index}`; + +export const parseGroupItemId = (id: ListItemId): string[] => id.split(GROUPED_ID_SEPARATOR); diff --git a/src/components/ListNext/utils/isTreeItemGuard.ts b/src/components/ListNext/utils/isTreeItemGuard.ts new file mode 100644 index 0000000000..7fa3d80fbe --- /dev/null +++ b/src/components/ListNext/utils/isTreeItemGuard.ts @@ -0,0 +1,5 @@ +import type {ListItemType, ListTreeItemType} from '../types'; + +export const isTreeItemGuard = (item?: ListItemType): item is ListTreeItemType => { + return item !== null && typeof item === 'object' && ('children' in item || 'data' in item); +}; diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index 670acc7497..df374aa8b5 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -2,23 +2,22 @@ import React from 'react'; import {useForkRef, useUniqId} from '../../hooks'; import { - ItemRenderer, - ListBodyRenderer, - ListContainerView, type ListItemId, - bListRadiuses, - computeItemSize, - defaultItemRendererBuilder, + ListItemView, + getItemRenderState, scrollToListItem, useList, useListKeydown, + useListState, } from '../ListNext'; import {SelectControl} from '../Select/components'; import {SelectPopup} from '../Select/components/SelectPopup/SelectPopup'; +import {borderRadius} from '../borderRadius'; import {Flex} from '../layout'; import {useMobile} from '../mobile'; import {block} from '../utils/cn'; +import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; import {useTreeSelectSelection} from './hooks/useTreeSelectSelection'; import type {RenderControlProps, TreeSelectProps} from './types'; @@ -38,24 +37,22 @@ export const TreeSelect = React.forwardRef(function TreeSelect( open: propsOpen, multiple, popupWidth, - listContainerClassName, - expandedItemsMap, + expandedById, + disabledById, + activeItemId, defaultValue, - virtualized, popupDisablePortal, - groupAction = 'items-count', - disabledItemsStateMap, - value: propsValue, groupsBehavior = 'expandable', - containerWrapper, + value: propsValue, onClose, onUpdate, - getItemContent, getId, onOpenChange, + renderControlContent, renderControl, - itemWrapper, - renderItem: propsRenderItem, + renderItem, + renderContainer: RenderContainer = TreeListContainer, + onItemClick, }: TreeSelectProps, ref: React.Ref, ) { @@ -68,12 +65,6 @@ export const TreeSelect = React.forwardRef(function TreeSelect( const containerRef = React.useRef(null); const handleControlRef = useForkRef(ref, controlRef); - const [{byId, flattenIdsOrder, groupsState, itemsState, lastItemId}, listState] = useList({ - items, - expanded: expandedItemsMap, - getId, - }); - const { value, open, @@ -91,9 +82,6 @@ export const TreeSelect = React.forwardRef(function TreeSelect( onOpenChange, }); - const lastSelectedItemId = value[value.length - 1]; - const expanded = expandedItemsMap || listState.expanded; - const disabled = disabledItemsStateMap || listState.disabled; const selected = React.useMemo( () => value.reduce>((acc, value) => { @@ -103,35 +91,66 @@ export const TreeSelect = React.forwardRef(function TreeSelect( [value], ); + const listState = useListState({ + expandedById, + disabledById, + activeItemId, + selectedById: selected, + }); + + const listParsedState = useList({ + items, + expandedById: listState.expandedById, + getId, + }); + const handleItemClick = React.useCallback( (id: ListItemId) => { - if (listState.disabled[id]) return; + // onItemClick = null - switch off default click behavior + if (onItemClick === 'disabled') return undefined; - listState.setActiveItemId(id); + const defaultHandleClick = () => { + if (listState.disabledById[id]) return; - const isGroup = id in groupsState; + listState.setActiveItemId(id); - if (isGroup && groupsBehavior === 'expandable') { - // toggle group selection - listState.setExpanded((state) => ({ - ...state, - // by default all groups expanded - [id]: typeof state[id] === 'boolean' ? !state[id] : false, - })); - } else if (multiple) { - handleMultipleSelection(id); - } else { - handleSingleSelection(id); - toggleOpen(false); + const isGroup = id in listParsedState.groupsState; + + if (isGroup && groupsBehavior === 'expandable') { + // toggle group selection + listState.setExpanded((state) => ({ + ...state, + // by default all groups expanded + [id]: typeof state[id] === 'boolean' ? !state[id] : false, + })); + } else if (multiple) { + handleMultipleSelection(id); + } else { + handleSingleSelection(id); + toggleOpen(false); + } + }; + + if (onItemClick) { + return onItemClick(defaultHandleClick, { + id, + isGroup: id in listParsedState.groupsState, + isLastItem: listParsedState.lastItemId === id, + disabled: listState.disabledById[id], + }); } + + return defaultHandleClick(); }, [ - groupsState, + onItemClick, + listState, + listParsedState.groupsState, + listParsedState.lastItemId, groupsBehavior, + multiple, handleMultipleSelection, handleSingleSelection, - listState, - multiple, toggleOpen, ], ); @@ -139,9 +158,10 @@ export const TreeSelect = React.forwardRef(function TreeSelect( // restoring focus when popup opens React.useLayoutEffect(() => { if (open) { + const lastSelectedItemId = value[value.length - 1]; containerRef.current?.focus(); - const firstItemId = flattenIdsOrder[0]; + const firstItemId = listParsedState.flattenIdsOrder[0]; listState.setActiveItemId(lastSelectedItemId ?? firstItemId); @@ -155,68 +175,13 @@ export const TreeSelect = React.forwardRef(function TreeSelect( useListKeydown({ containerRef, - activeItemId: listState.activeItemId, - setActiveItemId: listState.setActiveItemId, onItemClick: handleItemClick, - flattenIdsOrder, - disabled, + ...listParsedState, + ...listState, }); const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]); - const getContainerNode = () => ( - - - computeItemSize( - size, - Boolean( - getItemContent(byId[flattenIdsOrder[index]], { - isLastItem: lastItemId === flattenIdsOrder[index], - id: flattenIdsOrder[index], - isGroup: flattenIdsOrder[index] in groupsState, - }).subtitle, - ), - ) - } - > - {(id) => ( - - )} - - - ); - const controlProps: RenderControlProps = { open, toggleOpen, @@ -234,14 +199,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( - getItemContent(byId[id], { - id, - isGroup: id in groupsState, - isLastItem: lastItemId === id, - }).title, - ), + value.map((id) => renderControlContent(listParsedState.byId[id]).title), ).join(', ')} view="normal" pin="round-round" @@ -255,21 +213,49 @@ export const TreeSelect = React.forwardRef(function TreeSelect( {togglerNode} {slotBeforeListBody} - {containerWrapper - ? // the full list of properties will be updated as the component develops - containerWrapper(getContainerNode, {items}) - : getContainerNode()} + { + const [item, state, context] = getItemRenderState({ + id, + size, + onItemClick: handleItemClick, + ...listParsedState, + ...listState, + }); + + // assign components scope logic + state.selectable = context.groupState + ? groupsBehavior === 'selectable' + : undefined; + + if (renderItem) { + return renderItem(item, state, context, renderContextProps); + } + + return ( + + ); + }} + /> {slotAfterListBody} diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index f8fcdc7397..c1c68be115 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -1,289 +1,112 @@ import React from 'react'; -import {ChevronDown, ChevronUp, Database, PlugConnection} from '@gravity-ui/icons'; import type {Meta, StoryFn} from '@storybook/react'; import identity from 'lodash/identity'; -import {Button} from '../../Button'; -import {Icon} from '../../Icon'; -import type {GetItemContent, ListItemId, ListItemType} from '../../ListNext'; -import {getListParsedState} from '../../ListNext'; import {createRandomizedData} from '../../ListNext/__stories__/utils/makeData'; -import {useInfinityFetch} from '../../ListNext/__stories__/utils/useInfinityFetch'; -import {IntersectionContainer} from '../../ListNext/components/IntersectionContainer/IntersectionContainer'; -import {useListFilter} from '../../ListNext/hooks/useListFilter'; -import {Loader} from '../../Loader'; -import {Text} from '../../Text'; -import {TextInput} from '../../controls'; -import {Flex, spacing} from '../../layout'; +import {Flex} from '../../layout'; import {TreeSelect} from '../TreeSelect'; import type {TreeSelectProps} from '../types'; +import { + InfinityScrollExample, + InfinityScrollExampleProps, +} from './components/InfinityScrollExample'; +import {WithDndListExample, WithDndListExampleProps} from './components/WithDndListExample'; +import { + WithFiltrationAndControlsExample, + WithFiltrationAndControlsExampleProps, +} from './components/WithFiltrationAndControlsExample'; +import { + WithGroupSelectionControlledStateAndCustomIconExample, + WithGroupSelectionControlledStateAndCustomIconExampleProps, +} from './components/WithGroupSelectionControlledStateAndCustomIcon'; +import { + WithItemLinksAndActionsExample, + WithItemLinksAndActionsExampleProps, +} from './components/WithItemLinksAndActionsExample'; + export default { title: 'Unstable/TreeSelect', component: TreeSelect, } as Meta; -const DefaultExample: StoryFn< - Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { +const DefaultTemplate: StoryFn< + Omit< + TreeSelectProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'renderControlContent' + > & { itemsCount?: number; } > = ({itemsCount = 5, ...props}) => { - const items = React.useMemo(() => createRandomizedData(itemsCount), [itemsCount]); + const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); const [value, setValue] = React.useState([]); return ( ); }; -export const Default = DefaultExample.bind({}); -DefaultExample.args = { - size: 'l', +export const Default = DefaultTemplate.bind({}); +Default.args = { + size: 'm', }; -const getItemsExpandedState = (items: ListItemType[]) => { - return Object.entries(getListParsedState(items).groupsState).reduce< - Record - >((acc, [groupId, {expanded}]) => { - acc[groupId] = true; - - if (typeof expanded !== 'undefined') { - acc[groupId] = expanded; - } - return acc; - }, {}); +const WithGroupSelectionControlledStateAndCustomIconTemplate: StoryFn< + WithGroupSelectionControlledStateAndCustomIconExampleProps +> = (props) => { + return ; }; -const WithGroupSelectionControlledStateAndCustomIconsExample: StoryFn< - Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { - itemsCount?: number; - } -> = ({itemsCount = 5, ...props}) => { - const items = React.useMemo(() => createRandomizedData(itemsCount), [itemsCount]); - const [value, setValue] = React.useState([]); - const [expandedItemsMap, setExpanded] = React.useState>(() => - getItemsExpandedState(items), - ); - - const getItemContent: GetItemContent<{title: string}> = ({title}, {isGroup, id}) => ({ - title, - startSlot: , - endSlot: isGroup ? ( - - ) : undefined, - }); - - return ( - - - - ); -}; -export const WithGroupSelectionControlledStateAndCustomIcons = - WithGroupSelectionControlledStateAndCustomIconsExample.bind({}); -WithGroupSelectionControlledStateAndCustomIcons.args = { - size: 'l', +export const WithGroupSelectionControlledStateAndCustomIcon = + WithGroupSelectionControlledStateAndCustomIconTemplate.bind({}); +WithGroupSelectionControlledStateAndCustomIcon.args = { multiple: true, groupsBehavior: 'selectable', }; -const InfinityScrollExample: StoryFn< - Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { - itemsCount?: number; - } -> = ({itemsCount = 5, ...props}) => { - const [value, setValue] = React.useState([]); - const { - data = [], - onFetchMore, - canFetchMore, - isLoading, - } = useInfinityFetch<{title: string}>(itemsCount, true); - - return ( - - { - const node = getOriginalNode(); - - if (isLastItem) { - return ( - - {node} - - ); - } - - return node; - }} - virtualized - items={data} - onUpdate={setValue} - slotAfterListBody={ - isLoading && ( - - - - ) - } - /> - - ); +const InfinityScrollTemplate: StoryFn = (props) => { + return ; }; -export const InfinityScroll = InfinityScrollExample.bind({}); -InfinityScrollExample.args = { +export const InfinityScroll = InfinityScrollTemplate.bind({}); +InfinityScroll.args = { size: 'm', multiple: true, }; -const WithFiltrationAndControlsExample: StoryFn< - Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> & { - itemsCount?: number; - } -> = ({itemsCount = 5, ...props}) => { - const items = React.useMemo(() => createRandomizedData(itemsCount), [itemsCount]); - const [open, onOpenChange] = React.useState(true); - const [value, setValue] = React.useState([]); - const filterState = useListFilter({items}); - - return ( - - - } - containerWrapper={(getOriginalNode, context) => { - if (context.items.length === 0 && items.length > 0) { - return ( - - Nothing found - - ); - } - - return getOriginalNode(); - }} - slotAfterListBody={ - - - - - } - value={value} - getItemContent={identity} - items={filterState.items} - onUpdate={setValue} - /> - - ); +const WithFiltrationAndControlsTemplate: StoryFn = ( + props, +) => { + return ; }; -export const WithFiltrationAndControls = WithFiltrationAndControlsExample.bind({}); -WithFiltrationAndControlsExample.args = { +export const WithFiltrationAndControls = WithFiltrationAndControlsTemplate.bind({}); +WithFiltrationAndControls.args = { size: 'l', }; -const emptyItems: ListItemType<{title: string}>[] = []; - -const WithCustomEmptyContentExample: StoryFn< - Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> -> = (props) => { - return ( - - { - if (context.items.length === 0) { - return ( - - Nothing found - - ); - } +const WithItemLinksAndActionsTemplate: StoryFn = (props) => { + return ; +}; +export const WithItemLinksAndActions = WithItemLinksAndActionsTemplate.bind({}); +WithItemLinksAndActions.args = {}; - return getOriginalNode(); - }} - getItemContent={(x) => x} - /> - - ); +const WithDndListTemplate: StoryFn = (props) => { + return ; }; -export const WithCustomEmptyContent = WithCustomEmptyContentExample.bind({}); -WithCustomEmptyContentExample.args = { +export const WithDndList = WithDndListTemplate.bind({}); + +WithDndList.args = { size: 'l', }; +WithDndList.parameters = { + // Strict mode ruins sortable list due to this react-beautiful-dnd issue + // https://github.com/atlassian/react-beautiful-dnd/issues/2350 + disableStrictMode: true, +}; diff --git a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx new file mode 100644 index 0000000000..8dc445b84a --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -0,0 +1,77 @@ +import React from 'react'; + +import identity from 'lodash/identity'; + +import {Label} from '../../../Label'; +import {ListItemView} from '../../../ListNext'; +import {useInfinityFetch} from '../../../ListNext/__stories__/utils/useInfinityFetch'; +import {IntersectionContainer} from '../../../ListNext/components/IntersectionContainer/IntersectionContainer'; +import {Loader} from '../../../Loader'; +import {Flex, spacing} from '../../../layout'; +import {TreeSelect} from '../../TreeSelect'; +import type {TreeSelectProps} from '../../types'; + +import {RenderVirtualizedContainer} from './RenderVirtualizedContainer'; +export interface InfinityScrollExampleProps + extends Omit< + TreeSelectProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'getItemContent' + > { + itemsCount?: number; +} + +export const InfinityScrollExample = ({itemsCount = 5, ...props}: InfinityScrollExampleProps) => { + const [value, setValue] = React.useState([]); + const { + data = [], + onFetchMore, + canFetchMore, + isLoading, + } = useInfinityFetch<{title: string}>(itemsCount, true); + + return ( + + { + const node = ( + {groupState.childrenIds.length} + ) : undefined + } + /> + ); + + if (isLastItem) { + return ( + + {node} + + ); + } + + return node; + }} + renderContainer={RenderVirtualizedContainer} + items={data} + onUpdate={setValue} + slotAfterListBody={ + isLoading && ( + + + + ) + } + /> + + ); +}; diff --git a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx new file mode 100644 index 0000000000..265148e6b9 --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import {ListContainerView, computeItemSize} from '../../../ListNext'; +import {VirtualizedListContainer} from '../../../ListNext/__stories__/components/VirtualizedListContainer'; +import type {RenderContainerProps} from '../../types'; + +// custom container renderer example +export const RenderVirtualizedContainer = ({ + id, + containerRef, + flattenIdsOrder, + renderItem, + size, +}: RenderContainerProps) => { + return ( + + computeItemSize(size)} + > + {renderItem} + + + ); +}; diff --git a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx new file mode 100644 index 0000000000..704a5eae6c --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx @@ -0,0 +1,133 @@ +import React from 'react'; + +import {Grip} from '@gravity-ui/icons'; +import identity from 'lodash/identity'; +import { + DragDropContext, + Draggable, + DraggableProvided, + DraggableRubric, + DraggableStateSnapshot, + Droppable, + DroppableProvided, + OnDragEndResponder, +} from 'react-beautiful-dnd'; + +import {Icon} from '../../../Icon'; +import {ListContainerView, ListItemView, ListItemViewProps} from '../../../ListNext'; +import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; +import {reorderArray} from '../../../ListNext/__stories__/utils/reorderArray'; +import {Flex} from '../../../layout'; +import {TreeSelect} from '../../TreeSelect'; +import type {TreeSelectProps} from '../../types'; + +const DraggableListItem = ({ + provided, + ...props +}: {provided?: DraggableProvided} & ListItemViewProps) => { + return ( + + ); +}; + +export interface WithDndListExampleProps + extends Omit< + TreeSelectProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'getItemContent' + > {} + +export const WithDndListExample = (props: WithDndListExampleProps) => { + const [items, setItems] = React.useState(() => createRandomizedData({num: 10, depth: 0})); + const [value, setValue] = React.useState([]); + + const handleDrugEnd: OnDragEndResponder = ({destination, source}) => { + if (destination?.index && destination?.index !== source.index) { + setItems((items) => reorderArray(items, source.index, destination.index)); + } + }; + + return ( + + { + if (!isGroup && !disabled) { + setValue([id]); + } + }} + renderContainer={({renderItem, flattenIdsOrder, containerRef, id}) => { + return ( + + { + return renderItem(flattenIdsOrder[rubric.source.index], { + provided, + isDragging: snapshot.isDragging, + }); + }} + > + {(droppableProvided: DroppableProvided) => ( + +
      + {flattenIdsOrder.map((id) => renderItem(id))} + {droppableProvided.placeholder} +
      +
      + )} +
      +
      + ); + }} + renderItem={(item, state, _listContext, renderContextProps) => { + const commonProps = { + ...state, + ...item, + endSlot: , + }; + + // here passed props from `renderContainer` method. + if (renderContextProps) { + return ( + + ); + } + return ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( + + )} + + ); + }} + /> +
      + ); +}; diff --git a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx new file mode 100644 index 0000000000..d01e479582 --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import identity from 'lodash/identity'; + +import {Button} from '../../../Button'; +import {useListFilter} from '../../../ListNext'; +import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; +import {Text} from '../../../Text'; +import {TextInput} from '../../../controls'; +import {Flex, spacing} from '../../../layout'; +import {TreeSelect} from '../../TreeSelect'; +import type {TreeSelectProps} from '../../types'; + +import {RenderVirtualizedContainer} from './RenderVirtualizedContainer'; + +export interface WithFiltrationAndControlsExampleProps + extends Omit< + TreeSelectProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'getItemContent' + > { + itemsCount?: number; +} + +export const WithFiltrationAndControlsExample = ({ + itemsCount = 5, + ...props +}: WithFiltrationAndControlsExampleProps) => { + const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); + const [open, onOpenChange] = React.useState(true); + const [value, setValue] = React.useState([]); + const filterState = useListFilter({items}); + + return ( + + + } + renderContainer={(props) => { + if (props.items.length === 0 && items.length > 0) { + return ( + + Nothing found + + ); + } + + return ; + }} + slotAfterListBody={ + + + + + } + value={value} + renderControlContent={identity} + items={filterState.items} + onUpdate={setValue} + /> + + ); +}; diff --git a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx new file mode 100644 index 0000000000..a6bdf563eb --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import {ChevronDown, ChevronUp, Database, PlugConnection} from '@gravity-ui/icons'; +import identity from 'lodash/identity'; + +import {Button} from '../../../Button'; +import {Icon} from '../../../Icon'; +import {ListItemId, ListItemType, ListItemView, getListParsedState} from '../../../ListNext'; +import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; +import {Flex, spacing} from '../../../layout'; +import {TreeSelect} from '../../TreeSelect'; +import type {TreeSelectProps} from '../../types'; + +const getItemsExpandedState = (items: ListItemType[]) => { + return Object.entries(getListParsedState(items).groupsState).reduce< + Record + >((acc, [groupId, {expanded}]) => { + acc[groupId] = true; + + if (typeof expanded !== 'undefined') { + acc[groupId] = expanded; + } + return acc; + }, {}); +}; + +export interface WithGroupSelectionControlledStateAndCustomIconExampleProps + extends Omit< + TreeSelectProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'getItemContent' | 'size' + > { + itemsCount?: number; +} + +export const WithGroupSelectionControlledStateAndCustomIconExample = ({ + itemsCount = 5, + ...props +}: WithGroupSelectionControlledStateAndCustomIconExampleProps) => { + const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); + + const [value, setValue] = React.useState([]); + const [expandedById, setExpanded] = React.useState>(() => + getItemsExpandedState(items), + ); + + return ( + + { + return ( + + } + endSlot={ + groupState ? ( + + ) : undefined + } + /> + ); + }} + items={items} + onUpdate={setValue} + /> + + ); +}; diff --git a/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx new file mode 100644 index 0000000000..41a769bc23 --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import {ChevronDown, ChevronUp, FolderOpen} from '@gravity-ui/icons'; +import identity from 'lodash/identity'; + +import {Button} from '../../../Button'; +import {DropdownMenu} from '../../../DropdownMenu'; +import {Icon} from '../../../Icon'; +import {ListItemId, ListItemView} from '../../../ListNext'; +import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; +import {Flex, spacing} from '../../../layout'; +import {TreeSelect} from '../../TreeSelect'; +import type {TreeSelectProps} from '../../types'; + +export interface WithItemLinksAndActionsExampleProps + extends Omit< + TreeSelectProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'getItemContent' | 'size' | 'open' | 'onOpenChange' + > {} + +export const WithItemLinksAndActionsExample = (props: WithItemLinksAndActionsExampleProps) => { + const [open, setOpen] = React.useState(false); + const items = React.useMemo(() => createRandomizedData({num: 10, depth: 1}), []); + const [value, setValue] = React.useState([]); + const [expandedById, setExpanded] = React.useState>({}); + + return ( + + { + if (!isGroup && !disabled) { + setValue([id]); + } + + // navigation logic here to support keyboard + setOpen((x) => !x); + }} + expandedById={expandedById} + renderItem={(item, state, {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 ? ( + + ) : ( + + ) + } + /> + + ); + }} + /> + + ); +}; diff --git a/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx b/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx new file mode 100644 index 0000000000..24bc21c8e5 --- /dev/null +++ b/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import type {RenderContainerProps} from 'src/components/TreeSelect/types'; + +import {ListContainerView} from '../../../ListNext'; +import {ListItemRecursiveRenderer} from '../../../ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer'; + +export const TreeListContainer = ({ + items, + id, + containerRef, + expandedById, + renderItem, + className, +}: RenderContainerProps & {className?: string}) => { + return ( + + {items.map((itemSchema, index) => ( + + {renderItem} + + ))} + + ); +}; diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index 2810df3a2b..49a6918768 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -1,12 +1,15 @@ import type React from 'react'; import type { - GetItemContent, + KnownItemStructure, ListItemId, ListItemType, + ListParsedState, ListSizeTypes, - RenderItem, + ListState, + OverrideItemContext, RenderItemContext, + RenderItemState, } from '../ListNext'; import type {QAProps} from '../types'; @@ -21,8 +24,25 @@ export type RenderControlProps = { activeItemId?: ListItemId; }; -export interface TreeSelectProps extends QAProps { - value?: string[]; +export type RenderItem = ( + item: T, + // required item props to render + state: RenderItemState, + // internal list context props + context: RenderItemContext, + renderContextProps?: Object, +) => React.JSX.Element; + +export type RenderContainerProps = ListParsedState & + ListState & { + id: string; + size: ListSizeTypes; + renderItem(id: ListItemId, renderContextProps?: Object): React.JSX.Element; + containerRef: React.RefObject; + }; + +export interface TreeSelectProps extends QAProps, Partial> { + value?: ListItemId[]; defaultOpen?: boolean; defaultValue?: ListItemId[]; items: ListItemType[]; @@ -31,30 +51,27 @@ export interface TreeSelectProps extends QAProps { popupClassName?: string; popupWidth?: number; popupDisablePortal?: boolean; - disabledItemsStateMap: Record; - expandedItemsMap: Record; multiple?: boolean; /** - * Is it possible to select group elements or not + * The ability to set the default behavior for group elements + * + * - `expandable`. Click on group item will be produce internal `expanded` state toggle + * - `selectable`. Click on group item will be produce internal `selected` state toggle + * * @default - 'expandable */ groupsBehavior?: 'expandable' | 'selectable'; - virtualized?: boolean; + size: ListSizeTypes; /** - * If you need custom action button in group, - * use `getItemContent` and pass it as a `endIcon` prop. - * ```tsx - * getItemContent={({title}: T, {isGroup}) => ({ - * title, - * endIcon: isGroup ? buttonNodeWithLogic : undefined - * })} - * ``` + * Use slots if you don't need access to internal TreeListState. + * In other situations use `renderContainer` method */ - groupAction?: 'none' | 'items-count'; - size: ListSizeTypes; slotBeforeListBody?: React.ReactNode; + /** + * Use slots if you don't need access to internal TreeListState. + * In other situations use `renderContainer` method + */ slotAfterListBody?: React.ReactNode; - listContainerClassName?: string; /** * Define custom id depended on item data value to use in controlled state component variant */ @@ -64,23 +81,18 @@ export interface TreeSelectProps extends QAProps { */ renderControl?(props: RenderControlProps): React.JSX.Element; /** - * Required function to map you custom data to list item props. - * This function need to calculate item size by availability of `subtitle` prop + * Override list item content by you custom node. */ - getItemContent: GetItemContent; - /** - * For example wrap item with divider or some custom react node - */ - itemWrapper?( - getOriginalNode: () => React.JSX.Element, - context: RenderItemContext, - ): React.JSX.Element; - onClose?(): void; - containerWrapper?( - getOriginalNode: () => React.JSX.Element, - context: {items: ListItemType[]}, - ): React.JSX.Element; renderItem?: RenderItem; + renderControlContent(item: T): KnownItemStructure; + onClose?(): void; onUpdate?(value: string[]): void; onOpenChange?(open: boolean): void; + renderContainer?(props: RenderContainerProps): React.JSX.Element; + /** + * If you wont to disable default behavior pass `disabled` as a value; + */ + onItemClick?: + | 'disabled' + | ((defaultClickCallback: () => void, content: OverrideItemContext) => void); } diff --git a/src/components/borderRadius/BorderRadius.scss b/src/components/borderRadius/BorderRadius.scss new file mode 100644 index 0000000000..0fb11c4177 --- /dev/null +++ b/src/components/borderRadius/BorderRadius.scss @@ -0,0 +1,22 @@ +/* stylelint-disable declaration-no-important */ +@use '../variables'; + +$block: '.#{variables.$ns}border-radius'; + +#{$block} { + &_xs { + border-radius: var(--g-border-radius-xs, 3px) !important; + } + &_s { + border-radius: var(--g-border-radius-s, 5px) !important; + } + &_m { + border-radius: var(--g-border-radius-m, 6px) !important; + } + &_l { + border-radius: var(--g-border-radius-l, 8px) !important; + } + &_xl { + border-radius: var(--g-border-radius-xl, 10px) !important; + } +} diff --git a/src/components/borderRadius/borderRadius.ts b/src/components/borderRadius/borderRadius.ts new file mode 100644 index 0000000000..30ca24f63a --- /dev/null +++ b/src/components/borderRadius/borderRadius.ts @@ -0,0 +1,10 @@ +import {block} from '../utils/cn'; + +import './BorderRadius.scss'; + +export type BorderRadiusType = 'xs' | 's' | 'm' | 'l' | 'xl'; + +const bBorderRadius = block('border-radius'); + +export const borderRadius = ({size}: {size: BorderRadiusType}, className?: string) => + bBorderRadius({[size]: true}, className); diff --git a/src/components/borderRadius/index.ts b/src/components/borderRadius/index.ts new file mode 100644 index 0000000000..f3cd099516 --- /dev/null +++ b/src/components/borderRadius/index.ts @@ -0,0 +1 @@ +export {borderRadius, type BorderRadiusType} from './borderRadius'; diff --git a/src/components/index.ts b/src/components/index.ts index d9b69db807..b6a8ca1841 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -46,6 +46,7 @@ export * from './Tooltip'; export * from './User'; export * from './controls'; export * from './layout'; +export * from './borderRadius'; export * from './utils/class-transform'; export * from './utils/event-broker'; From 54e9c88dd43aa7755d4f3cfc953aafdba38a4030 Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Tue, 19 Dec 2023 18:33:16 +0300 Subject: [PATCH 05/14] fix: rename ListNext to useList --- src/components/TreeSelect/TreeSelect.tsx | 12 ++++++------ .../TreeSelect/__stories__/TreeSelect.stories.tsx | 2 +- .../__stories__/components/InfinityScrollExample.tsx | 6 +++--- .../components/RenderVirtualizedContainer.tsx | 4 ++-- .../__stories__/components/WithDndListExample.tsx | 6 +++--- .../components/WithFiltrationAndControlsExample.tsx | 4 ++-- ...ithGroupSelectionControlledStateAndCustomIcon.tsx | 4 ++-- .../components/WithItemLinksAndActionsExample.tsx | 4 ++-- .../TreeListContainer/TreeListContainer.tsx | 4 ++-- .../TreeSelect/hooks/useTreeSelectSelection.ts | 2 +- src/components/TreeSelect/types.ts | 4 ++-- .../__stories__/DndExample.stories.tsx | 0 .../__stories__/FlattenRenderer.stories.tsx | 0 .../__stories__/ListInfinityScroll.stories.tsx | 0 .../__stories__/PopupWithToggler.stories.tsx | 0 .../__stories__/RecursiveRenderer.stories.tsx | 0 .../__stories__/components/FlattenList.tsx | 0 .../__stories__/components/InfinityScrollList.tsx | 0 .../__stories__/components/ListWithDnd.tsx | 0 .../__stories__/components/PopupWithTogglerList.tsx | 10 ++++++++-- .../__stories__/components/RecursiveList.tsx | 0 .../VirtualizedListContainer.async.tsx | 0 .../VirtualizedListContainer.tsx | 0 .../components/VirtualizedListContainer/index.ts | 0 .../components/VirtualizedListContainer/types.ts | 0 .../__stories__/utils/makeData.ts | 0 .../__stories__/utils/reorderArray.ts | 0 .../__stories__/utils/useInfinityFetch.ts | 0 .../IntersectionContainer/IntersectionContainer.tsx | 0 .../ListContainerView/ListContainerView.scss | 0 .../ListContainerView/ListContainerView.tsx | 0 .../components/ListItemView/ListItemView.scss | 0 .../components/ListItemView/ListItemView.tsx | 7 ++++--- .../__stories__/ListItemView.stories.tsx | 0 .../ListRecursiveRenderer/ListRecursiveRenderer.scss | 0 .../ListRecursiveRenderer/ListRecursiveRenderer.tsx | 0 src/components/{ListNext => useList}/constants.ts | 0 .../hooks/useFlattenListItems.ts | 0 .../{ListNext => useList}/hooks/useList.ts | 0 .../{ListNext => useList}/hooks/useListFilter.ts | 0 .../{ListNext => useList}/hooks/useListKeydown.tsx | 0 .../hooks/useListParsedState.ts | 0 .../{ListNext => useList}/hooks/useListState.ts | 0 src/components/{ListNext => useList}/index.ts | 0 src/components/{ListNext => useList}/types.ts | 0 .../{ListNext => useList}/utils/computeItemSize.ts | 0 .../{ListNext => useList}/utils/createListItemId.ts | 0 .../utils/defaultFilterItems.ts | 0 .../{ListNext => useList}/utils/findNextIndex.ts | 0 .../{ListNext => useList}/utils/flattenItems.ts | 0 .../utils/getItemRenderState.tsx | 1 + .../{ListNext => useList}/utils/getListItemId.ts | 0 .../utils/getListParsedState.ts | 0 .../{ListNext => useList}/utils/groupItemId.ts | 0 .../{ListNext => useList}/utils/isTreeItemGuard.ts | 0 .../{ListNext => useList}/utils/scrollToListItem.ts | 0 src/unstable.ts | 2 +- 57 files changed, 40 insertions(+), 32 deletions(-) rename src/components/{ListNext => useList}/__stories__/DndExample.stories.tsx (100%) rename src/components/{ListNext => useList}/__stories__/FlattenRenderer.stories.tsx (100%) rename src/components/{ListNext => useList}/__stories__/ListInfinityScroll.stories.tsx (100%) rename src/components/{ListNext => useList}/__stories__/PopupWithToggler.stories.tsx (100%) rename src/components/{ListNext => useList}/__stories__/RecursiveRenderer.stories.tsx (100%) rename src/components/{ListNext => useList}/__stories__/components/FlattenList.tsx (100%) rename src/components/{ListNext => useList}/__stories__/components/InfinityScrollList.tsx (100%) rename src/components/{ListNext => useList}/__stories__/components/ListWithDnd.tsx (100%) rename src/components/{ListNext => useList}/__stories__/components/PopupWithTogglerList.tsx (91%) rename src/components/{ListNext => useList}/__stories__/components/RecursiveList.tsx (100%) rename src/components/{ListNext => useList}/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx (100%) rename src/components/{ListNext => useList}/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx (100%) rename src/components/{ListNext => useList}/__stories__/components/VirtualizedListContainer/index.ts (100%) rename src/components/{ListNext => useList}/__stories__/components/VirtualizedListContainer/types.ts (100%) rename src/components/{ListNext => useList}/__stories__/utils/makeData.ts (100%) rename src/components/{ListNext => useList}/__stories__/utils/reorderArray.ts (100%) rename src/components/{ListNext => useList}/__stories__/utils/useInfinityFetch.ts (100%) rename src/components/{ListNext => useList}/components/IntersectionContainer/IntersectionContainer.tsx (100%) rename src/components/{ListNext => useList}/components/ListContainerView/ListContainerView.scss (100%) rename src/components/{ListNext => useList}/components/ListContainerView/ListContainerView.tsx (100%) rename src/components/{ListNext => useList}/components/ListItemView/ListItemView.scss (100%) rename src/components/{ListNext => useList}/components/ListItemView/ListItemView.tsx (96%) rename src/components/{ListNext => useList}/components/ListItemView/__stories__/ListItemView.stories.tsx (100%) rename src/components/{ListNext => useList}/components/ListRecursiveRenderer/ListRecursiveRenderer.scss (100%) rename src/components/{ListNext => useList}/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx (100%) rename src/components/{ListNext => useList}/constants.ts (100%) rename src/components/{ListNext => useList}/hooks/useFlattenListItems.ts (100%) rename src/components/{ListNext => useList}/hooks/useList.ts (100%) rename src/components/{ListNext => useList}/hooks/useListFilter.ts (100%) rename src/components/{ListNext => useList}/hooks/useListKeydown.tsx (100%) rename src/components/{ListNext => useList}/hooks/useListParsedState.ts (100%) rename src/components/{ListNext => useList}/hooks/useListState.ts (100%) rename src/components/{ListNext => useList}/index.ts (100%) rename src/components/{ListNext => useList}/types.ts (100%) rename src/components/{ListNext => useList}/utils/computeItemSize.ts (100%) rename src/components/{ListNext => useList}/utils/createListItemId.ts (100%) rename src/components/{ListNext => useList}/utils/defaultFilterItems.ts (100%) rename src/components/{ListNext => useList}/utils/findNextIndex.ts (100%) rename src/components/{ListNext => useList}/utils/flattenItems.ts (100%) rename src/components/{ListNext => useList}/utils/getItemRenderState.tsx (97%) rename src/components/{ListNext => useList}/utils/getListItemId.ts (100%) rename src/components/{ListNext => useList}/utils/getListParsedState.ts (100%) rename src/components/{ListNext => useList}/utils/groupItemId.ts (100%) rename src/components/{ListNext => useList}/utils/isTreeItemGuard.ts (100%) rename src/components/{ListNext => useList}/utils/scrollToListItem.ts (100%) diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index df374aa8b5..70be51b529 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -1,6 +1,11 @@ import React from 'react'; import {useForkRef, useUniqId} from '../../hooks'; +import {SelectControl} from '../Select/components'; +import {SelectPopup} from '../Select/components/SelectPopup/SelectPopup'; +import {borderRadius} from '../borderRadius'; +import {Flex} from '../layout'; +import {useMobile} from '../mobile'; import { type ListItemId, ListItemView, @@ -9,12 +14,7 @@ import { useList, useListKeydown, useListState, -} from '../ListNext'; -import {SelectControl} from '../Select/components'; -import {SelectPopup} from '../Select/components/SelectPopup/SelectPopup'; -import {borderRadius} from '../borderRadius'; -import {Flex} from '../layout'; -import {useMobile} from '../mobile'; +} from '../useList'; import {block} from '../utils/cn'; import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index c1c68be115..d0e5f55bce 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -3,8 +3,8 @@ import React from 'react'; import type {Meta, StoryFn} from '@storybook/react'; import identity from 'lodash/identity'; -import {createRandomizedData} from '../../ListNext/__stories__/utils/makeData'; import {Flex} from '../../layout'; +import {createRandomizedData} from '../../useList/__stories__/utils/makeData'; import {TreeSelect} from '../TreeSelect'; import type {TreeSelectProps} from '../types'; diff --git a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx index 8dc445b84a..dfbd5d39e2 100644 --- a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -3,11 +3,11 @@ import React from 'react'; import identity from 'lodash/identity'; import {Label} from '../../../Label'; -import {ListItemView} from '../../../ListNext'; -import {useInfinityFetch} from '../../../ListNext/__stories__/utils/useInfinityFetch'; -import {IntersectionContainer} from '../../../ListNext/components/IntersectionContainer/IntersectionContainer'; import {Loader} from '../../../Loader'; import {Flex, spacing} from '../../../layout'; +import {ListItemView} from '../../../useList'; +import {useInfinityFetch} from '../../../useList/__stories__/utils/useInfinityFetch'; +import {IntersectionContainer} from '../../../useList/components/IntersectionContainer/IntersectionContainer'; import {TreeSelect} from '../../TreeSelect'; import type {TreeSelectProps} from '../../types'; diff --git a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx index 265148e6b9..e5494dd789 100644 --- a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx +++ b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import {ListContainerView, computeItemSize} from '../../../ListNext'; -import {VirtualizedListContainer} from '../../../ListNext/__stories__/components/VirtualizedListContainer'; +import {ListContainerView, computeItemSize} from '../../../useList'; +import {VirtualizedListContainer} from '../../../useList/__stories__/components/VirtualizedListContainer'; import type {RenderContainerProps} from '../../types'; // custom container renderer example diff --git a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx index 704a5eae6c..a831baf8af 100644 --- a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx @@ -14,10 +14,10 @@ import { } from 'react-beautiful-dnd'; import {Icon} from '../../../Icon'; -import {ListContainerView, ListItemView, ListItemViewProps} from '../../../ListNext'; -import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; -import {reorderArray} from '../../../ListNext/__stories__/utils/reorderArray'; import {Flex} from '../../../layout'; +import {ListContainerView, ListItemView, ListItemViewProps} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {reorderArray} from '../../../useList/__stories__/utils/reorderArray'; import {TreeSelect} from '../../TreeSelect'; import type {TreeSelectProps} from '../../types'; diff --git a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx index d01e479582..1624587ea1 100644 --- a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx @@ -3,11 +3,11 @@ import React from 'react'; import identity from 'lodash/identity'; import {Button} from '../../../Button'; -import {useListFilter} from '../../../ListNext'; -import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; import {Text} from '../../../Text'; 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} from '../../types'; diff --git a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx index a6bdf563eb..ba15b7fb8e 100644 --- a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx +++ b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx @@ -5,9 +5,9 @@ import identity from 'lodash/identity'; import {Button} from '../../../Button'; import {Icon} from '../../../Icon'; -import {ListItemId, ListItemType, ListItemView, getListParsedState} from '../../../ListNext'; -import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; import {Flex, spacing} from '../../../layout'; +import {ListItemId, ListItemType, ListItemView, getListParsedState} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeSelect} from '../../TreeSelect'; import type {TreeSelectProps} from '../../types'; diff --git a/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx index 41a769bc23..884b5e42fb 100644 --- a/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx @@ -6,9 +6,9 @@ import identity from 'lodash/identity'; import {Button} from '../../../Button'; import {DropdownMenu} from '../../../DropdownMenu'; import {Icon} from '../../../Icon'; -import {ListItemId, ListItemView} from '../../../ListNext'; -import {createRandomizedData} from '../../../ListNext/__stories__/utils/makeData'; import {Flex, spacing} from '../../../layout'; +import {ListItemId, ListItemView} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeSelect} from '../../TreeSelect'; import type {TreeSelectProps} from '../../types'; diff --git a/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx b/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx index 24bc21c8e5..b4d8a884d4 100644 --- a/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx +++ b/src/components/TreeSelect/components/TreeListContainer/TreeListContainer.tsx @@ -2,8 +2,8 @@ import React from 'react'; import type {RenderContainerProps} from 'src/components/TreeSelect/types'; -import {ListContainerView} from '../../../ListNext'; -import {ListItemRecursiveRenderer} from '../../../ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer'; +import {ListContainerView} from '../../../useList'; +import {ListItemRecursiveRenderer} from '../../../useList/components/ListRecursiveRenderer/ListRecursiveRenderer'; export const TreeListContainer = ({ items, diff --git a/src/components/TreeSelect/hooks/useTreeSelectSelection.ts b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts index 5070b677ac..ebb06a8383 100644 --- a/src/components/TreeSelect/hooks/useTreeSelectSelection.ts +++ b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts @@ -2,7 +2,7 @@ import React from 'react'; import type {UseOpenProps} from '../../../hooks/useSelect/types'; import {useOpenState} from '../../../hooks/useSelect/useOpenState'; -import type {ListItemId} from '../../ListNext/types'; +import type {ListItemId} from '../../useList/types'; type UseTreeSelectSelectionProps = { value?: ListItemId[]; diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index 49a6918768..dccaa3641d 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -1,5 +1,6 @@ import type React from 'react'; +import type {QAProps} from '../types'; import type { KnownItemStructure, ListItemId, @@ -10,8 +11,7 @@ import type { OverrideItemContext, RenderItemContext, RenderItemState, -} from '../ListNext'; -import type {QAProps} from '../types'; +} from '../useList'; export type RenderControlProps = { open: boolean; diff --git a/src/components/ListNext/__stories__/DndExample.stories.tsx b/src/components/useList/__stories__/DndExample.stories.tsx similarity index 100% rename from src/components/ListNext/__stories__/DndExample.stories.tsx rename to src/components/useList/__stories__/DndExample.stories.tsx diff --git a/src/components/ListNext/__stories__/FlattenRenderer.stories.tsx b/src/components/useList/__stories__/FlattenRenderer.stories.tsx similarity index 100% rename from src/components/ListNext/__stories__/FlattenRenderer.stories.tsx rename to src/components/useList/__stories__/FlattenRenderer.stories.tsx diff --git a/src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx b/src/components/useList/__stories__/ListInfinityScroll.stories.tsx similarity index 100% rename from src/components/ListNext/__stories__/ListInfinityScroll.stories.tsx rename to src/components/useList/__stories__/ListInfinityScroll.stories.tsx diff --git a/src/components/ListNext/__stories__/PopupWithToggler.stories.tsx b/src/components/useList/__stories__/PopupWithToggler.stories.tsx similarity index 100% rename from src/components/ListNext/__stories__/PopupWithToggler.stories.tsx rename to src/components/useList/__stories__/PopupWithToggler.stories.tsx diff --git a/src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx b/src/components/useList/__stories__/RecursiveRenderer.stories.tsx similarity index 100% rename from src/components/ListNext/__stories__/RecursiveRenderer.stories.tsx rename to src/components/useList/__stories__/RecursiveRenderer.stories.tsx diff --git a/src/components/ListNext/__stories__/components/FlattenList.tsx b/src/components/useList/__stories__/components/FlattenList.tsx similarity index 100% rename from src/components/ListNext/__stories__/components/FlattenList.tsx rename to src/components/useList/__stories__/components/FlattenList.tsx diff --git a/src/components/ListNext/__stories__/components/InfinityScrollList.tsx b/src/components/useList/__stories__/components/InfinityScrollList.tsx similarity index 100% rename from src/components/ListNext/__stories__/components/InfinityScrollList.tsx rename to src/components/useList/__stories__/components/InfinityScrollList.tsx diff --git a/src/components/ListNext/__stories__/components/ListWithDnd.tsx b/src/components/useList/__stories__/components/ListWithDnd.tsx similarity index 100% rename from src/components/ListNext/__stories__/components/ListWithDnd.tsx rename to src/components/useList/__stories__/components/ListWithDnd.tsx diff --git a/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx similarity index 91% rename from src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx rename to src/components/useList/__stories__/components/PopupWithTogglerList.tsx index 8a12f506b7..998bd58284 100644 --- a/src/components/ListNext/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -108,7 +108,7 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro expandedById={listState.expandedById} > {(id) => { - const [data, state, _context] = getItemRenderState({ + const [data, state, listContext] = getItemRenderState({ id, size, onItemClick, @@ -116,7 +116,13 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro ...listState, }); - return ; + return ( + + ); }} ))} diff --git a/src/components/ListNext/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx similarity index 100% rename from src/components/ListNext/__stories__/components/RecursiveList.tsx rename to src/components/useList/__stories__/components/RecursiveList.tsx diff --git a/src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx b/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx similarity index 100% rename from src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx rename to src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx diff --git a/src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx b/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx similarity index 100% rename from src/components/ListNext/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx rename to src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx diff --git a/src/components/ListNext/__stories__/components/VirtualizedListContainer/index.ts b/src/components/useList/__stories__/components/VirtualizedListContainer/index.ts similarity index 100% rename from src/components/ListNext/__stories__/components/VirtualizedListContainer/index.ts rename to src/components/useList/__stories__/components/VirtualizedListContainer/index.ts diff --git a/src/components/ListNext/__stories__/components/VirtualizedListContainer/types.ts b/src/components/useList/__stories__/components/VirtualizedListContainer/types.ts similarity index 100% rename from src/components/ListNext/__stories__/components/VirtualizedListContainer/types.ts rename to src/components/useList/__stories__/components/VirtualizedListContainer/types.ts diff --git a/src/components/ListNext/__stories__/utils/makeData.ts b/src/components/useList/__stories__/utils/makeData.ts similarity index 100% rename from src/components/ListNext/__stories__/utils/makeData.ts rename to src/components/useList/__stories__/utils/makeData.ts diff --git a/src/components/ListNext/__stories__/utils/reorderArray.ts b/src/components/useList/__stories__/utils/reorderArray.ts similarity index 100% rename from src/components/ListNext/__stories__/utils/reorderArray.ts rename to src/components/useList/__stories__/utils/reorderArray.ts diff --git a/src/components/ListNext/__stories__/utils/useInfinityFetch.ts b/src/components/useList/__stories__/utils/useInfinityFetch.ts similarity index 100% rename from src/components/ListNext/__stories__/utils/useInfinityFetch.ts rename to src/components/useList/__stories__/utils/useInfinityFetch.ts diff --git a/src/components/ListNext/components/IntersectionContainer/IntersectionContainer.tsx b/src/components/useList/components/IntersectionContainer/IntersectionContainer.tsx similarity index 100% rename from src/components/ListNext/components/IntersectionContainer/IntersectionContainer.tsx rename to src/components/useList/components/IntersectionContainer/IntersectionContainer.tsx diff --git a/src/components/ListNext/components/ListContainerView/ListContainerView.scss b/src/components/useList/components/ListContainerView/ListContainerView.scss similarity index 100% rename from src/components/ListNext/components/ListContainerView/ListContainerView.scss rename to src/components/useList/components/ListContainerView/ListContainerView.scss diff --git a/src/components/ListNext/components/ListContainerView/ListContainerView.tsx b/src/components/useList/components/ListContainerView/ListContainerView.tsx similarity index 100% rename from src/components/ListNext/components/ListContainerView/ListContainerView.tsx rename to src/components/useList/components/ListContainerView/ListContainerView.tsx diff --git a/src/components/ListNext/components/ListItemView/ListItemView.scss b/src/components/useList/components/ListItemView/ListItemView.scss similarity index 100% rename from src/components/ListNext/components/ListItemView/ListItemView.scss rename to src/components/useList/components/ListItemView/ListItemView.scss diff --git a/src/components/ListNext/components/ListItemView/ListItemView.tsx b/src/components/useList/components/ListItemView/ListItemView.tsx similarity index 96% rename from src/components/ListNext/components/ListItemView/ListItemView.tsx rename to src/components/useList/components/ListItemView/ListItemView.tsx index a7a325fa09..5e86e17446 100644 --- a/src/components/ListNext/components/ListItemView/ListItemView.tsx +++ b/src/components/useList/components/ListItemView/ListItemView.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import {Check} from '@gravity-ui/icons'; +import {Check, ChevronDown, ChevronUp} from '@gravity-ui/icons'; -import {ArrowToggle} from '../../../ArrowToggle'; import {Icon} from '../../../Icon'; import {Text, colorText} from '../../../Text'; import {borderRadius} from '../../../borderRadius'; @@ -159,7 +158,9 @@ export const ListItemView = React.forwardRef( {renderSafeIndentation(indentation)} {startSlot ?? - (isGroup ? : null)} + (isGroup ? ( + + ) : null)} {typeof title === 'string' ? ( diff --git a/src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx b/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx similarity index 100% rename from src/components/ListNext/components/ListItemView/__stories__/ListItemView.stories.tsx rename to src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx diff --git a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.scss b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.scss similarity index 100% rename from src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.scss rename to src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.scss diff --git a/src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx similarity index 100% rename from src/components/ListNext/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx rename to src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx diff --git a/src/components/ListNext/constants.ts b/src/components/useList/constants.ts similarity index 100% rename from src/components/ListNext/constants.ts rename to src/components/useList/constants.ts diff --git a/src/components/ListNext/hooks/useFlattenListItems.ts b/src/components/useList/hooks/useFlattenListItems.ts similarity index 100% rename from src/components/ListNext/hooks/useFlattenListItems.ts rename to src/components/useList/hooks/useFlattenListItems.ts diff --git a/src/components/ListNext/hooks/useList.ts b/src/components/useList/hooks/useList.ts similarity index 100% rename from src/components/ListNext/hooks/useList.ts rename to src/components/useList/hooks/useList.ts diff --git a/src/components/ListNext/hooks/useListFilter.ts b/src/components/useList/hooks/useListFilter.ts similarity index 100% rename from src/components/ListNext/hooks/useListFilter.ts rename to src/components/useList/hooks/useListFilter.ts diff --git a/src/components/ListNext/hooks/useListKeydown.tsx b/src/components/useList/hooks/useListKeydown.tsx similarity index 100% rename from src/components/ListNext/hooks/useListKeydown.tsx rename to src/components/useList/hooks/useListKeydown.tsx diff --git a/src/components/ListNext/hooks/useListParsedState.ts b/src/components/useList/hooks/useListParsedState.ts similarity index 100% rename from src/components/ListNext/hooks/useListParsedState.ts rename to src/components/useList/hooks/useListParsedState.ts diff --git a/src/components/ListNext/hooks/useListState.ts b/src/components/useList/hooks/useListState.ts similarity index 100% rename from src/components/ListNext/hooks/useListState.ts rename to src/components/useList/hooks/useListState.ts diff --git a/src/components/ListNext/index.ts b/src/components/useList/index.ts similarity index 100% rename from src/components/ListNext/index.ts rename to src/components/useList/index.ts diff --git a/src/components/ListNext/types.ts b/src/components/useList/types.ts similarity index 100% rename from src/components/ListNext/types.ts rename to src/components/useList/types.ts diff --git a/src/components/ListNext/utils/computeItemSize.ts b/src/components/useList/utils/computeItemSize.ts similarity index 100% rename from src/components/ListNext/utils/computeItemSize.ts rename to src/components/useList/utils/computeItemSize.ts diff --git a/src/components/ListNext/utils/createListItemId.ts b/src/components/useList/utils/createListItemId.ts similarity index 100% rename from src/components/ListNext/utils/createListItemId.ts rename to src/components/useList/utils/createListItemId.ts diff --git a/src/components/ListNext/utils/defaultFilterItems.ts b/src/components/useList/utils/defaultFilterItems.ts similarity index 100% rename from src/components/ListNext/utils/defaultFilterItems.ts rename to src/components/useList/utils/defaultFilterItems.ts diff --git a/src/components/ListNext/utils/findNextIndex.ts b/src/components/useList/utils/findNextIndex.ts similarity index 100% rename from src/components/ListNext/utils/findNextIndex.ts rename to src/components/useList/utils/findNextIndex.ts diff --git a/src/components/ListNext/utils/flattenItems.ts b/src/components/useList/utils/flattenItems.ts similarity index 100% rename from src/components/ListNext/utils/flattenItems.ts rename to src/components/useList/utils/flattenItems.ts diff --git a/src/components/ListNext/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx similarity index 97% rename from src/components/ListNext/utils/getItemRenderState.tsx rename to src/components/useList/utils/getItemRenderState.tsx index 7d213c9d33..f181bd6d42 100644 --- a/src/components/ListNext/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -1,3 +1,4 @@ +/* eslint-disable valid-jsdoc */ import type { ItemsParsedState, ListGroupState, diff --git a/src/components/ListNext/utils/getListItemId.ts b/src/components/useList/utils/getListItemId.ts similarity index 100% rename from src/components/ListNext/utils/getListItemId.ts rename to src/components/useList/utils/getListItemId.ts diff --git a/src/components/ListNext/utils/getListParsedState.ts b/src/components/useList/utils/getListParsedState.ts similarity index 100% rename from src/components/ListNext/utils/getListParsedState.ts rename to src/components/useList/utils/getListParsedState.ts diff --git a/src/components/ListNext/utils/groupItemId.ts b/src/components/useList/utils/groupItemId.ts similarity index 100% rename from src/components/ListNext/utils/groupItemId.ts rename to src/components/useList/utils/groupItemId.ts diff --git a/src/components/ListNext/utils/isTreeItemGuard.ts b/src/components/useList/utils/isTreeItemGuard.ts similarity index 100% rename from src/components/ListNext/utils/isTreeItemGuard.ts rename to src/components/useList/utils/isTreeItemGuard.ts diff --git a/src/components/ListNext/utils/scrollToListItem.ts b/src/components/useList/utils/scrollToListItem.ts similarity index 100% rename from src/components/ListNext/utils/scrollToListItem.ts rename to src/components/useList/utils/scrollToListItem.ts diff --git a/src/unstable.ts b/src/unstable.ts index 83d038af79..c96cb6e2e6 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -1,2 +1,2 @@ -export {useList, useListFilter, useListKeydown} from './components/ListNext'; +export {useList, useListFilter, useListKeydown} from './components/useList'; export * from './components/TreeSelect'; From a43d5ebdaad5f4ae00ff9bca8750be96e86f21e1 Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Tue, 19 Dec 2023 18:43:45 +0300 Subject: [PATCH 06/14] fix: fix useList example --- ...nderer.stories.tsx => VirtualizedList.stories.tsx} | 0 .../useList/__stories__/components/FlattenList.tsx | 11 +++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) rename src/components/useList/__stories__/{FlattenRenderer.stories.tsx => VirtualizedList.stories.tsx} (100%) diff --git a/src/components/useList/__stories__/FlattenRenderer.stories.tsx b/src/components/useList/__stories__/VirtualizedList.stories.tsx similarity index 100% rename from src/components/useList/__stories__/FlattenRenderer.stories.tsx rename to src/components/useList/__stories__/VirtualizedList.stories.tsx diff --git a/src/components/useList/__stories__/components/FlattenList.tsx b/src/components/useList/__stories__/components/FlattenList.tsx index 67d686563a..10cf4017e9 100644 --- a/src/components/useList/__stories__/components/FlattenList.tsx +++ b/src/components/useList/__stories__/components/FlattenList.tsx @@ -35,6 +35,7 @@ export const FlattenList = ({itemsCount, size}: FlattenListProps) => { const listParsedState = useList({ items: filterState.items, + expandedById: listState.expandedById, }); const onItemClick = React.useCallback( @@ -88,14 +89,20 @@ export const FlattenList = ({itemsCount, size}: FlattenListProps) => { } > {(id) => { - const [item, state, _context] = getItemRenderState({ + const [item, state, listContext] = getItemRenderState({ id, size, onItemClick, ...listParsedState, ...listState, }); - return ; + return ( + + ); }} From fb51fc3fa39e06001281200a38ab92d11a9be063 Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Fri, 22 Dec 2023 15:36:20 +0300 Subject: [PATCH 07/14] feat: add unit tests for core unils functions --- src/components/useList/types.ts | 4 +- .../useList/utils/defaultFilterItems.test.ts | 49 ++++++ .../useList/utils/defaultFilterItems.ts | 1 - .../useList/utils/flattenItems.test.ts | 63 +++++++ .../useList/utils/getListParsedState.test.ts | 158 ++++++++++++++++++ .../useList/utils/getListParsedState.ts | 10 +- .../useList/utils/isTreeItemGuard.ts | 2 +- 7 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 src/components/useList/utils/defaultFilterItems.test.ts create mode 100644 src/components/useList/utils/flattenItems.test.ts create mode 100644 src/components/useList/utils/getListParsedState.test.ts diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index 33d055d10b..8d9ae86dc3 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -42,8 +42,8 @@ export type ItemParsedState = { parentId?: ListItemId; indentation: number; // initial item state - selected: boolean; - disabled: boolean; + selected?: boolean; + disabled?: boolean; }; export type ItemsParsedState = Record; diff --git a/src/components/useList/utils/defaultFilterItems.test.ts b/src/components/useList/utils/defaultFilterItems.test.ts new file mode 100644 index 0000000000..1290a7dfab --- /dev/null +++ b/src/components/useList/utils/defaultFilterItems.test.ts @@ -0,0 +1,49 @@ +import {defaultFilterItems} from './defaultFilterItems'; + +const data = [ + { + data: {title: 'item-0'}, + disabled: true, + willNotBeIncluded: '123', + }, + { + data: {title: 'item-1'}, + children: [ + { + data: {title: 'child-1-1'}, + }, + { + data: {title: 'Child-1-2'}, + expanded: false, + children: [{data: {title: 'child-1-2-1'}, children: []}], + }, + { + data: {title: 'chilD-1-3'}, + }, + ], + }, + { + data: {title: 'item-2'}, + children: [], + selected: true, + }, +]; + +describe('defaultFilterItems', () => { + test('should return expected result', () => { + expect( + defaultFilterItems(data, ({title}) => title.toLowerCase().includes('child-1-2')), + ).toEqual([ + { + data: {title: 'item-1'}, + children: [ + { + data: {title: 'Child-1-2'}, + expanded: false, + children: [{data: {title: 'child-1-2-1'}, children: []}], + }, + ], + }, + ]); + }); +}); diff --git a/src/components/useList/utils/defaultFilterItems.ts b/src/components/useList/utils/defaultFilterItems.ts index 7ff304c13e..5e3adc0d9b 100644 --- a/src/components/useList/utils/defaultFilterItems.ts +++ b/src/components/useList/utils/defaultFilterItems.ts @@ -2,7 +2,6 @@ import type {ListItemType, ListTreeItemType} from '../types'; import {isTreeItemGuard} from './isTreeItemGuard'; -// TODO(aisaev188): unit tests export function defaultFilterItems( items: ListItemType[], filterFn: (data: T) => boolean, diff --git a/src/components/useList/utils/flattenItems.test.ts b/src/components/useList/utils/flattenItems.test.ts new file mode 100644 index 0000000000..c872fa6f12 --- /dev/null +++ b/src/components/useList/utils/flattenItems.test.ts @@ -0,0 +1,63 @@ +import {flattenItems} from './flattenItems'; + +const data = [ + { + data: {title: 'item-0'}, + disabled: true, + willNotBeIncluded: '123', + }, + { + data: {title: 'item-1'}, + children: [ + { + data: {title: 'child-1-1'}, + }, + { + data: {title: 'child-1-2'}, + expanded: false, + children: [{data: {title: 'child-1-2-1'}, children: []}], + }, + { + data: {title: 'child-1-3'}, + }, + ], + }, + { + data: {title: 'item-2'}, + children: [], + selected: true, + }, +]; + +describe('flattenItems', () => { + test('should return expected result', () => { + expect(flattenItems(data)).toEqual(['0', '1', '1-0', '1-1', '1-1-0', '1-2', '2']); + }); + + test('should return expected result with expanded state', () => { + expect( + flattenItems(data, { + '1': false, + }), + ).toEqual(['0', '1', '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']); + }); + + test('should return expected result with expanded state and id getter override', () => { + expect( + flattenItems( + data, + { + 'item-1': false, + }, + ({title}) => title, + ), + ).toEqual(['item-0', 'item-1', 'item-2']); + }); +}); diff --git a/src/components/useList/utils/getListParsedState.test.ts b/src/components/useList/utils/getListParsedState.test.ts new file mode 100644 index 0000000000..6fe1b943e7 --- /dev/null +++ b/src/components/useList/utils/getListParsedState.test.ts @@ -0,0 +1,158 @@ +import type {ListItemType} from '../types'; + +import {getListParsedState} from './getListParsedState'; + +describe('getListParsedState', () => { + test('get expected result with tree structure items', () => { + const data: ListItemType[] = [ + { + data: {title: 'item-0'}, + disabled: true, + willNotBeIncluded: '123', + }, + { + data: {title: 'item-1'}, + children: [ + { + data: {title: 'child-1-1'}, + }, + { + data: {title: 'child-1-2'}, + expanded: false, + children: [{data: {title: 'child-1-2-1'}, children: []}], + }, + { + data: {title: 'child-1-3'}, + }, + ], + }, + { + data: {title: 'item-2'}, + children: [], + selected: true, + }, + ]; + + expect(getListParsedState(data)).toEqual({ + byId: { + 0: {title: 'item-0'}, + 1: {title: 'item-1'}, + '1-0': {title: 'child-1-1'}, + '1-1': {title: 'child-1-2'}, + '1-1-0': {title: 'child-1-2-1'}, + '1-2': {title: 'child-1-3'}, + '2': {title: 'item-2'}, + }, + groupsState: { + 1: { + childrenIds: ['1-0', '1-1', '1-2'], + }, + '1-1': {childrenIds: ['1-1-0'], expanded: false}, + '1-1-0': {childrenIds: []}, + '2': {childrenIds: []}, + }, + itemsState: { + 0: {indentation: 0, disabled: true}, + 1: {indentation: 0}, + '1-0': {parentId: '1', indentation: 1}, + '1-1': {parentId: '1', indentation: 1}, + '1-1-0': {parentId: '1-1', indentation: 2}, + '1-2': {parentId: '1', indentation: 1}, + '2': {indentation: 0, selected: true}, + }, + lastItemId: '2', + }); + }); + + test('get expected result with flatten structure items', () => { + const data: ListItemType[] = [ + { + a: 'item-1', + children: [], + disabled: true, + }, + { + a: 'item-2', + selected: true, + }, + { + c: 'item-3', + }, + ]; + + expect(getListParsedState(data)).toEqual({ + byId: { + 0: { + a: 'item-1', + children: [], + disabled: true, + }, + 1: { + a: 'item-2', + selected: true, + }, + 2: { + c: 'item-3', + }, + }, + groupsState: {}, + itemsState: { + 0: {indentation: 0, disabled: true}, + 1: {indentation: 0, selected: true}, + 2: {indentation: 0}, + }, + lastItemId: '2', + }); + }); + + test('get expected result with getId function passed', () => { + const data: ListItemType<{title: string; id: string}>[] = [ + { + data: {title: 'item-0', id: 'id-1'}, + }, + { + data: {title: 'item-1', id: 'id-2'}, + children: [ + { + data: {title: 'child-1-1', id: 'id-3'}, + }, + { + data: {title: 'child-1-2', id: 'id-4'}, + expanded: false, + children: [{data: {title: 'child-1-2-1', id: 'id-5'}, children: []}], + }, + ], + }, + ]; + + expect(getListParsedState(data, ({id}) => id)).toEqual({ + byId: { + 'id-1': {title: 'item-0', id: 'id-1'}, + 'id-2': {title: 'item-1', id: 'id-2'}, + 'id-3': {title: 'child-1-1', id: 'id-3'}, + 'id-4': {title: 'child-1-2', id: 'id-4'}, + 'id-5': {title: 'child-1-2-1', id: 'id-5'}, + }, + groupsState: { + 'id-2': { + childrenIds: ['id-3', 'id-4'], + }, + 'id-4': { + childrenIds: ['id-5'], + expanded: false, + }, + 'id-5': { + childrenIds: [], + }, + }, + itemsState: { + 'id-1': {indentation: 0}, + 'id-2': {indentation: 0}, + 'id-3': {indentation: 1, parentId: 'id-2'}, + 'id-4': {indentation: 1, parentId: 'id-2'}, + 'id-5': {indentation: 2, parentId: 'id-4'}, + }, + lastItemId: 'id-5', + }); + }); +}); diff --git a/src/components/useList/utils/getListParsedState.ts b/src/components/useList/utils/getListParsedState.ts index bdfa616ccd..9c5e5caea4 100644 --- a/src/components/useList/utils/getListParsedState.ts +++ b/src/components/useList/utils/getListParsedState.ts @@ -26,7 +26,6 @@ interface TraverseTreeItemProps { parentGroupedId?: string; } -// TODO(aisaev188): unit tests export function getListParsedState( items: ListItemType[], /** @@ -54,8 +53,6 @@ export function getListParsedState( if (!result.itemsState[id]) { result.itemsState[id] = { indentation: 0, - selected: false, - disabled: false, }; } @@ -88,8 +85,6 @@ export function getListParsedState( if (!result.itemsState[id]) { result.itemsState[id] = { indentation: 0, - selected: false, - disabled: false, }; } @@ -113,10 +108,13 @@ export function getListParsedState( if (item.children) { result.groupsState[id] = { - expanded: item.expanded, childrenIds: [], }; + if (typeof item.expanded !== 'undefined') { + result.groupsState[id].expanded = item.expanded; + } + item.children.forEach((treeItem, index) => { traverseTreeItem({ item: treeItem, diff --git a/src/components/useList/utils/isTreeItemGuard.ts b/src/components/useList/utils/isTreeItemGuard.ts index 7fa3d80fbe..febb628a9b 100644 --- a/src/components/useList/utils/isTreeItemGuard.ts +++ b/src/components/useList/utils/isTreeItemGuard.ts @@ -1,5 +1,5 @@ import type {ListItemType, ListTreeItemType} from '../types'; export const isTreeItemGuard = (item?: ListItemType): item is ListTreeItemType => { - return item !== null && typeof item === 'object' && ('children' in item || 'data' in item); + return item !== null && typeof item === 'object' && 'data' in item; }; From 00745aa8011913febf3683e62d6a8961cd63cc5a Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Tue, 26 Dec 2023 19:03:10 +0300 Subject: [PATCH 08/14] feat: added useList docs --- src/components/TreeSelect/TreeSelect.tsx | 13 +- .../components/InfinityScrollExample.tsx | 2 +- .../components/RenderVirtualizedContainer.tsx | 2 +- .../components/WithDndListExample.tsx | 6 +- .../WithFiltrationAndControlsExample.tsx | 2 +- ...pSelectionControlledStateAndCustomIcon.tsx | 19 +- src/components/TreeSelect/index.ts | 2 +- .../components/InfinityScrollList.tsx | 3 +- .../IntersectionContainer.tsx | 2 +- .../__stories__/components/ListWithDnd.tsx | 2 +- .../useList/__stories__/useList.mdx | 427 ++++++++++++++++++ .../ListContainerView/ListContainerView.scss | 4 +- .../ListContainerView/ListContainerView.tsx | 8 +- .../components/ListItemView/ListItemView.scss | 10 +- .../components/ListItemView/ListItemView.tsx | 12 +- .../__stories__/ListItemView.stories.tsx | 8 +- src/components/useList/hooks/useList.ts | 4 +- src/components/useList/hooks/useListFilter.ts | 6 +- src/components/useList/types.ts | 13 +- .../useList/utils/createListItemId.ts | 2 - .../useList/utils/getItemRenderState.tsx | 30 +- .../useList/utils/getListParsedState.test.ts | 41 +- .../useList/utils/getListParsedState.ts | 29 +- .../useList/utils/scrollToListItem.ts | 4 +- 24 files changed, 534 insertions(+), 117 deletions(-) rename src/components/useList/{ => __stories__}/components/IntersectionContainer/IntersectionContainer.tsx (90%) create mode 100644 src/components/useList/__stories__/useList.mdx delete mode 100644 src/components/useList/utils/createListItemId.ts diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index 70be51b529..3f48177786 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -106,21 +106,21 @@ export const TreeSelect = React.forwardRef(function TreeSelect( const handleItemClick = React.useCallback( (id: ListItemId) => { - // onItemClick = null - switch off default click behavior + // onItemClick = disabled - switch off default click behavior if (onItemClick === 'disabled') return undefined; const defaultHandleClick = () => { if (listState.disabledById[id]) return; + // always activate selected item listState.setActiveItemId(id); const isGroup = id in listParsedState.groupsState; if (isGroup && groupsBehavior === 'expandable') { - // toggle group selection listState.setExpanded((state) => ({ ...state, - // by default all groups expanded + // toggle expanded state by id, by default all groups expanded [id]: typeof state[id] === 'boolean' ? !state[id] : false, })); } else if (multiple) { @@ -135,7 +135,10 @@ export const TreeSelect = React.forwardRef(function TreeSelect( return onItemClick(defaultHandleClick, { id, isGroup: id in listParsedState.groupsState, - isLastItem: listParsedState.lastItemId === id, + isLastItem: + listParsedState.flattenIdsOrder[ + listParsedState.flattenIdsOrder.length - 1 + ] === id, disabled: listState.disabledById[id], }); } @@ -146,7 +149,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( onItemClick, listState, listParsedState.groupsState, - listParsedState.lastItemId, + listParsedState.flattenIdsOrder, groupsBehavior, multiple, handleMultipleSelection, diff --git a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx index dfbd5d39e2..4d2b420c10 100644 --- a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -6,8 +6,8 @@ import {Label} from '../../../Label'; import {Loader} from '../../../Loader'; import {Flex, spacing} from '../../../layout'; import {ListItemView} from '../../../useList'; +import {IntersectionContainer} from '../../../useList/__stories__/components/IntersectionContainer/IntersectionContainer'; import {useInfinityFetch} from '../../../useList/__stories__/utils/useInfinityFetch'; -import {IntersectionContainer} from '../../../useList/components/IntersectionContainer/IntersectionContainer'; import {TreeSelect} from '../../TreeSelect'; import type {TreeSelectProps} from '../../types'; diff --git a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx index e5494dd789..e8b60c1134 100644 --- a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx +++ b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx @@ -13,7 +13,7 @@ export const RenderVirtualizedContainer = ({ size, }: RenderContainerProps) => { return ( - + computeItemSize(size)} diff --git a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx index a831baf8af..977c3911df 100644 --- a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx @@ -75,7 +75,7 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { ) => { return renderItem(flattenIdsOrder[rubric.source.index], { provided, - isDragging: snapshot.isDragging, + active: snapshot.isDragging, }); }} > @@ -106,8 +106,8 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { return ( ); } @@ -120,8 +120,8 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( )} diff --git a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx index 1624587ea1..93cc4e3613 100644 --- a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx @@ -53,7 +53,7 @@ export const WithFiltrationAndControlsExample = ({ renderContainer={(props) => { if (props.items.length === 0 && items.length > 0) { return ( - + Nothing found ); diff --git a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx index ba15b7fb8e..083d1c43ac 100644 --- a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx +++ b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx @@ -6,24 +6,11 @@ import identity from 'lodash/identity'; import {Button} from '../../../Button'; import {Icon} from '../../../Icon'; import {Flex, spacing} from '../../../layout'; -import {ListItemId, ListItemType, ListItemView, getListParsedState} from '../../../useList'; +import {ListItemId, ListItemView, getListParsedState} from '../../../useList'; import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeSelect} from '../../TreeSelect'; import type {TreeSelectProps} from '../../types'; -const getItemsExpandedState = (items: ListItemType[]) => { - return Object.entries(getListParsedState(items).groupsState).reduce< - Record - >((acc, [groupId, {expanded}]) => { - acc[groupId] = true; - - if (typeof expanded !== 'undefined') { - acc[groupId] = expanded; - } - return acc; - }, {}); -}; - export interface WithGroupSelectionControlledStateAndCustomIconExampleProps extends Omit< TreeSelectProps<{title: string}>, @@ -39,8 +26,8 @@ export const WithGroupSelectionControlledStateAndCustomIconExample = ({ const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); const [value, setValue] = React.useState([]); - const [expandedById, setExpanded] = React.useState>(() => - getItemsExpandedState(items), + const [expandedById, setExpanded] = React.useState>( + () => getListParsedState(items).initialState.expandedById, ); return ( diff --git a/src/components/TreeSelect/index.ts b/src/components/TreeSelect/index.ts index 97fbb66516..c066371c30 100644 --- a/src/components/TreeSelect/index.ts +++ b/src/components/TreeSelect/index.ts @@ -1,2 +1,2 @@ export {TreeSelect} from './TreeSelect'; -export type {TreeSelectProps} from './types'; +export type {TreeSelectProps, RenderItem} from './types'; diff --git a/src/components/useList/__stories__/components/InfinityScrollList.tsx b/src/components/useList/__stories__/components/InfinityScrollList.tsx index 458429f398..4da9fe11b0 100644 --- a/src/components/useList/__stories__/components/InfinityScrollList.tsx +++ b/src/components/useList/__stories__/components/InfinityScrollList.tsx @@ -4,7 +4,6 @@ import {Button} from '../../../Button'; import {Loader} from '../../../Loader'; import {TextInput} from '../../../controls'; import {Flex} from '../../../layout'; -import {IntersectionContainer} from '../../components/IntersectionContainer/IntersectionContainer'; import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; import {ListItemView} from '../../components/ListItemView/ListItemView'; import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; @@ -16,6 +15,8 @@ import type {ListItemId, ListSizeTypes} from '../../types'; import {getItemRenderState} from '../../utils/getItemRenderState'; import {useInfinityFetch} from '../utils/useInfinityFetch'; +import {IntersectionContainer} from './IntersectionContainer/IntersectionContainer'; + export interface InfinityScrollListProps { size: ListSizeTypes; } diff --git a/src/components/useList/components/IntersectionContainer/IntersectionContainer.tsx b/src/components/useList/__stories__/components/IntersectionContainer/IntersectionContainer.tsx similarity index 90% rename from src/components/useList/components/IntersectionContainer/IntersectionContainer.tsx rename to src/components/useList/__stories__/components/IntersectionContainer/IntersectionContainer.tsx index 49b5b597db..bc0aeaf07b 100644 --- a/src/components/useList/components/IntersectionContainer/IntersectionContainer.tsx +++ b/src/components/useList/__stories__/components/IntersectionContainer/IntersectionContainer.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {useIntersection} from '../../../../hooks'; +import {useIntersection} from '../../../../../hooks'; interface IntersectionContainerProps { children: React.JSX.Element; diff --git a/src/components/useList/__stories__/components/ListWithDnd.tsx b/src/components/useList/__stories__/components/ListWithDnd.tsx index a98aee6da5..e7d6a3991c 100644 --- a/src/components/useList/__stories__/components/ListWithDnd.tsx +++ b/src/components/useList/__stories__/components/ListWithDnd.tsx @@ -113,7 +113,7 @@ export const ListWithDnd = ({size, itemsCount}: ListWithDndProps) => { {...data} {...provided.draggableProps} {...provided.dragHandleProps} - isDragging={snapshot.isDragging} + active={snapshot.isDragging} ref={provided.innerRef} endSlot={} /> diff --git a/src/components/useList/__stories__/useList.mdx b/src/components/useList/__stories__/useList.mdx new file mode 100644 index 0000000000..a88f682f64 --- /dev/null +++ b/src/components/useList/__stories__/useList.mdx @@ -0,0 +1,427 @@ +import {Meta} from '@storybook/addon-docs'; + + + +# useList + +A set of hooks for creating stateless `List` components; + +The basic idea is that hooks take all the complex logic on themselves, and all you have to do is implement the "dumb" components of the view; + +`Storybook` provides complex examples how to use this components from this documentation. + +### Hooks + +- [useList](#uselist-1); +- [useListKeydown](#uselistkeydown) +- [useListFilter](#uselistfilter); +- [useListState](#useliststate); + +### Components (View only); + +- [ListItemView](#listitemview); +- [ListContainerView](#listcontainerview); +- [ListRecursiveRenderer](#listrecursiverenderer); + +### Utilitys + +- [computeItemSize](#computeitemsize); +- [scrollToListItem](#scrolltolistitem); +- [getItemRenderState](#getitemrenderstate); +- [getListParsedState](#getlistparsedstate); + +## Hooks + +### useList + +The main hook for creating a stateless version of the sheet. + +#### Props: + +- `items` - `ListItemType[]` - a flat or tree-like data structure, with `List` declaration: + +```tsx +interface ListItemInitialProps { + /** + * If you need to control the state from the outside, + * you can set a unique id for each element + */ + id?: string; + /** + * Initial disabled item state + */ + disabled?: boolean; + /** + * Initial selected item state + */ + selected?: boolean; + /** + * Default expanded state if group + */ + expanded?: boolean; +} + +type ListFlattenItemType = T & ListItemInitialProps; + +interface ListTreeItemType extends ListItemInitialProps { + data: T; + children?: ListTreeItemType[]; +} + +export type ListItemType = ListTreeItemType | ListFlattenItemType; +``` + +- `expandedById` - state for open/closed `List` elements. Affects the formation of the `flattenIdsOrder` - if the element id in this object is set to `false` - all elements of this group and all nested groups will not be present in the final ids order; +- `getId` - the property is optional. Allows you to generate an id for a list item depending on the list data: + +```tsx +const items = [ + {data: {id: 'id-1', title: 'some title 1'}, children: [...]}, + {data: {id: 'id-2', title: 'some title 2'}, children: [...]}, +]; + +/** + * byId: { + * 'id-1': {id: 'id-1', title: 'some title 1'}, + * 'id-2': {id: 'id-2', title: 'some title 2'}, + * } + */ +const {byid} = useList({ + items, + getId: ({id}) => id, +}) +``` + +#### Returned data: + +- `itemsState` - a normalized representation of meta information for each element of the list + features: + + - `parentId` - Id of the parent element, if there is a parent; + - `indentation` - Nesting level; + +- `byId` - normalized representation of list items: + + ```tsx + export type ParsedState = { + // ... + byId: Record; + // ... + }; + + const items = [ + {data: {title: 'title-1'}, children: [{data: {title: 'title-1-1'}, children: []}]}, + {data: {title: 'title-2'}, children: []}, + ]; + // -> + const byId: { + 0: {title: 'title-1'}; + '0-0': {title: 'title-1-1'}; + 1: {title: 'title-2'}; + }; + ``` + + The default IDs are formed according to the principle `-`. To make a custom `id`, you need to use it either when forming an array of `items` or through the`getId` function. + +- `groupsState` - a normalized representation of metadata about a group if the item is both a list item and a group: + - `childrenIds` - list of child element IDs; +- `flattenIdsOrder` - sequential representation of list items by id, taking into account invisible elements inside collapsed groups; + +### useListKeydown + +Keyboard support + +#### Props: + +- `disabledById` - key-value representation of disabled elements that do not need to be taken into account when navigating through the `List`; +- `activeItemId` - current active item `id`; +- `flattenIdsOrder` - a flat list of elements to be navigated through; Collapsed groups must be taken into account in this array; +- `onItemClick` - callback will be called when pressing the `Enter`, `Space` keys; +- `containerRef` - a reference to the DOM element of the List container inside which to search for its elements; +- `setActiveItemId` - Callback for setting the current active element; +- `enactive` - on/off keyboard support. Use it if you need to change the behavior in runtime; + +```tsx +const containerRef = React.useRef(null); +const parsedListState = useListState() +const parsedListState = useList(...) + +const handleItemClick = () => {...}; + +useListKeydown({ + onItemClick: handleItemClick, + containerRef, + ...listState, + ...parsedListState, +}) +``` + +### useListFilter + +#### Props: + +- `items` - original array of `listItemType[]`, same us used in the `useList` hook; +- `initialFilterValue` - the initial value of the filter; +- `filterItem` - the predicate function determines the principle of leaving elements in the original array. It works recursively, there is no need to implement custom logic to bypass the tree structure; +- `filterItems` - completely redefine the filtering logic; +- `debounceTimeout` - with what delay to apply the filtering result. By default, `300ms`; + +#### Returns: + +- `filterRef` - ref to the DOM element of the filtering component; +- `filter` - current filter value; +- `reset` - method for resetting the filter value; +- `items` - list of filtered sheet elements `listItemType[]`; +- `onChange` - callback for changing the filter value; + +```tsx +const List = () => { + const {items, reset: _reset, ...conponentProps} = useListFilter({ + items: [...] + }) + + const parsedListState = useList({ + items, + }) + + return ( + <> + + + ) +} +``` + +### useListState + +The basic hook for managing the state of the `List`. You can use your own implementation, the main thing is to understand about the concept of the `state` of the sheet. Which corresponds to the following interface: + +```tsx +type ListState = { + disabledById: Record; + selectedById: Record; + expandedById: Record; + activeItemId?: ListItemId; +}; + +const { + disabledById, + setDisabled, + selectedById, + setSelected, + expandedById, + setExpanded, + activeItemId, + setActiveItemId, +} = useListState(); +``` + +## Components + +### ListItemView + +The basic component responsible for the appearance of the list items. +Use it even if the functionality of the `useList` hook seems redundant to you + +#### Props: + +- `id` - required prop. Set `[data-list-item="${id}"]` data attribute. By this it core list engine finds elements to scroll to. +- `title` - base required prop to use. If passed string, applas default component styles according desig system. Pass you own componnet if you wont custom behaviour; +- `as` - if needed, override `html` tag. By default - `li`; +- `size` - the size of the element. By default, `m`. Available options are `s`, `m`, `L`, `xl`. It also affects the radii of the fillets; +- `height` - the height of the element in pixels. By default, it is calculated depending on the `size` parameter and the presence of the `subtitle` parameter; +- `selected` - the selected state of the component; +- `active` - the state when the element is in the user's focus, but not selected. It can also be used when you drag an element; +- `disabled` - The disabled state. It also prevents clicking on an element; +- `activeOnHover`- By default hovered elements has active styles. You can disable this behavior; +- `indentation` - Build in indentation component to render nested views structure; +- `selectable` - Show selected icon if selected and reserve space for this icon; +- `onClick` - on item click callback. !Note: if passed this and `disabled` option is `true` click will not be appear; +- `style` - optional react `React.CSSProperties` object; +- `subtitle` - Slot under `title`. If passed string apply prefefined styles. Or you can pass custom `React.ReactNode` to use you own behaviour; +- `startSlot` - custom slot before `title`; +- `endSlot` - custom slot after `title`; +- `corners` - Prop to remove default border radiuses from element; +- `className` - custom class name to mix with; +- `expanded` - Build in supoort expanded behaviour for list item groups; + +```tsx +const items = [ + {title: 'some title 1', subtitle: 'some subtitle 1', icon: }, + {title: 'some title 2', subtitle: 'some subtitle 2', icon: }, +]; + +const List = () => { + return ( + <> + {items.map(item, i) => { + return ( + + ) + }} + + ) +}; +``` + +### ListContainerView + +The default container for all custom lists. Contains all html attributes and styles for quick use in your projects. + +#### Props: + +- `id` - optional id attribute; +- `className` - custom class name to mix with; +- `fixedHeight` - removes default `overflow: auto` from container and set fixed container height (`--g-list-height` = `300px`); + +```tsx +const containerRef = React.useRef(null); + + + + +; +``` + +### ListRecursiveRenderer + +The basic "renderer" of the `List` elements. When rendering, it retains the nested html structure. +You can use it as an example if you need to implement hiding/closing groups of elements with animation. +For the virtualized version of the list, you need to implement a component with a similar interface, see the examples from storybook. + +#### Props: + +- `itemSchema` - base list item (`ListItemType`); +- `children` - render list item function; +- `index` - the ordinal index of the first level of the sheet elements; +- `expandedById` - state for hidden group elements, if the functionality of hiding/opening groups is supported +- `className` - custom class name to mix with; +- `getId` - the property is optional. Allows you to generate an id for a list item depending on the list data: +- `style` - optional react `React.CSSProperties` object; + +```tsx + + {items.map((item, index) => ( + + {(id) => } + + ))} + +``` + +## Utilitys + +### computeItemSize; + +Utility to compute list item height: + +```tsx + + computeItemSize( + // list size + size, + // has subrows + Boolean(get(byId[flattenIdsOrder[index]], 'subtitle')), + ) + } +/> +``` + +### scrollToListItem; + +Utility to sroll into list item view by id and ref on container DOM element: + +```tsx +const containerRef = React.useRef(null); +// restoring focus when popup opens +React.useLayoutEffect(() => { + if (open) { + containerRef.current?.focus(); + listState.setActiveItemId(selectedId ?? listParsedState.flattenIdsOrder[0]); + + if (selectedId) { + scrollToListItem(selectedId, containerRef.current); + } + } +}, [open]); +// ... +; +``` + +### getItemRenderState; + +Map list state to item render props; + +#### Returns: + +- item data (`T`); + +```tsx +item = { + data: T, + children: [...] +} +// or, if flatten list declaration variant +item = T +``` + +- item state props: + - `id` - item id; + - `size` - item size; + - `expanded` - expanded state if item group; + - `active` - is item active; + - `indentation` - item nest level; + - `disabled` - is item disabled; + - `selected` - is item selected; + - `onClick` - on item click handle if exists; +- item list context: + - `itemState`: + - `parentId?` - id of parant element; + - `indentation` - item nest level; + - `groupState` - exists only if item is group: + - `childrenIds` - List of child element IDs; + - `isLastItem` - if item is last in the list. Useful in cases than you need to do somthing on last item appears. For example, implement custom infinity lists variants + +```tsx +const listParsedState = useList(); +const listState = useListState(); +const handleItemClick = () => {}; + + + {(id) => { + const [data, stateProps, _listContext] = getItemRenderState({ + id, + size, // list size + onItemClick: handleItemClick, + ...listParsedState, + ...listState, + }); + + return ; + }} +; +``` + +### getListParsedState; + +same as `useList` hook functionality in stateless function. Use it if you need to extract initial list state form declaration: + +```tsx +// custom controlled state from computed initial state +const [expandedById, setExpanded] = React.useState( + () => getListParsedState(items).initialState.expandedById, +); +``` diff --git a/src/components/useList/components/ListContainerView/ListContainerView.scss b/src/components/useList/components/ListContainerView/ListContainerView.scss index 378dfc1b38..33defb91cb 100644 --- a/src/components/useList/components/ListContainerView/ListContainerView.scss +++ b/src/components/useList/components/ListContainerView/ListContainerView.scss @@ -7,11 +7,11 @@ $block: '.#{variables.$ns}list-container-view'; width: 100%; outline: none; - &_virtualized { + &_fixed-height { height: var(--g-list-height, 300px); } - &:not(#{$block}_virtualized) { + &:not(#{$block}_fixed-height) { overflow: auto; } } diff --git a/src/components/useList/components/ListContainerView/ListContainerView.tsx b/src/components/useList/components/ListContainerView/ListContainerView.tsx index cb2fa3161f..f03bc9079d 100644 --- a/src/components/useList/components/ListContainerView/ListContainerView.tsx +++ b/src/components/useList/components/ListContainerView/ListContainerView.tsx @@ -13,15 +13,15 @@ export interface ListContainerViewProps extends QAProps, React.HTMLAttributes<'d id?: string; className?: string; /** - * Removes `overflow: auto` from container + * Removes `overflow: auto` from container and set fixed container size (`--g-list-height` = `300px`) */ - virtualized?: boolean; + fixedHeight?: boolean; children: React.ReactNode; } export const ListContainerView = React.forwardRef( function ListContainerView( - {role = 'listbox', children, id, className, virtualized, ...props}, + {role = 'listbox', children, id, className, fixedHeight, ...props}, ref, ) { return ( @@ -33,7 +33,7 @@ export const ListContainerView = React.forwardRef {children} diff --git a/src/components/useList/components/ListItemView/ListItemView.scss b/src/components/useList/components/ListItemView/ListItemView.scss index 5835c9508c..ff377b774a 100644 --- a/src/components/useList/components/ListItemView/ListItemView.scss +++ b/src/components/useList/components/ListItemView/ListItemView.scss @@ -8,7 +8,7 @@ $block: '.#{variables.$ns}list-item-view'; &:hover#{$block}_activeOnHover, &_active#{$block}_activeOnHover, &_active { - background: var(--g-color-base-simple-hover); + background: var(--g-color-base-simple-hover-solid); } &_clickable { @@ -20,14 +20,6 @@ $block: '.#{variables.$ns}list-item-view'; background: var(--g-color-base-selection); } - &_dragging { - background: var(--g-color-base-simple-hover-solid); - } - - &_hidden { - display: none; - } - &__slot { &_indent_1 { width: 16px; diff --git a/src/components/useList/components/ListItemView/ListItemView.tsx b/src/components/useList/components/ListItemView/ListItemView.tsx index 5e86e17446..124abff6ee 100644 --- a/src/components/useList/components/ListItemView/ListItemView.tsx +++ b/src/components/useList/components/ListItemView/ListItemView.tsx @@ -10,7 +10,6 @@ import type {QAProps} from '../../../types'; import {block} from '../../../utils/cn'; import {LIST_ITEM_DATA_ATR, modToHeight} from '../../constants'; import type {ListItemId, ListSizeTypes} from '../../types'; -import {createListItemId} from '../../utils/createListItemId'; import './ListItemView.scss'; @@ -28,10 +27,6 @@ export interface ListItemViewProps extends QAProps, Omit @@ -57,14 +58,19 @@ const stories: ListItemViewProps[] = [ { id: '6', title, + size: 'l', subtitle, startSlot: ( ), indentation: 1, + selected: true, }, { id: '7', + expanded: true, + selectable: false, + size: 'xl', title: 'Group 1', }, ]; diff --git a/src/components/useList/hooks/useList.ts b/src/components/useList/hooks/useList.ts index 9a8dbf295c..e83187de7c 100644 --- a/src/components/useList/hooks/useList.ts +++ b/src/components/useList/hooks/useList.ts @@ -17,7 +17,7 @@ interface UseListProps { * Take array of items as a argument and returns parsed representation of this data structure to work with */ export const useList = ({items, expandedById, getId}: UseListProps): ListParsedState => { - const {byId, groupsState, itemsState, lastItemId} = useListParsedState({ + const {byId, groupsState, itemsState} = useListParsedState({ items, getId, }); @@ -28,5 +28,5 @@ export const useList = ({items, expandedById, getId}: UseListProps): ListP getId, }); - return {items, flattenIdsOrder, byId, groupsState, itemsState, lastItemId}; + return {items, flattenIdsOrder, byId, groupsState, itemsState}; }; diff --git a/src/components/useList/hooks/useListFilter.ts b/src/components/useList/hooks/useListFilter.ts index da227465b0..a5b55d07b1 100644 --- a/src/components/useList/hooks/useListFilter.ts +++ b/src/components/useList/hooks/useListFilter.ts @@ -5,9 +5,9 @@ import debounce from 'lodash/debounce'; import type {ListItemType} from '../types'; import {defaultFilterItems} from '../utils/defaultFilterItems'; -function defaultFilterFn(value: string, item: T): boolean { +function defaultFilterFn(value: string | undefined, item: T): boolean { return item && typeof item === 'object' && 'title' in item && typeof item.title === 'string' - ? item.title.includes(value) + ? item.title.toLowerCase().includes((value || '').toLowerCase()) : true; } @@ -29,7 +29,7 @@ interface UseListFilterProps { * Ready-to-use logic for filtering tree-like data structures * ```tsx * const {item: filteredItems,...listFiltration} = useListFIlter({items}); - * const [listParsedState, listState] = useList({items: filteredItems}); + * const listParsedState = useList({items: filteredItems}); * * * ``` diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index 8d9ae86dc3..28a954935c 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -32,20 +32,12 @@ export type ListItemType = ListTreeItemType | ListFlattenItemType; export type GroupParsedState = { childrenIds: ListItemId[]; - // initial group item state - expanded?: boolean; }; -export type ListGroupState = Record; - export type ItemParsedState = { parentId?: ListItemId; indentation: number; - // initial item state - selected?: boolean; - disabled?: boolean; }; -export type ItemsParsedState = Record; export type KnownItemStructure = { title: React.ReactNode; @@ -87,7 +79,7 @@ export type ParsedState = { * Stored internal meta info about item * Note: Groups are also items */ - itemsState: ItemsParsedState; + itemsState: Record; /** * Normalized original data */ @@ -95,8 +87,7 @@ export type ParsedState = { /** * Stored info about group items: */ - groupsState: ListGroupState; - lastItemId: ListItemId; + groupsState: Record; }; export type ListState = { diff --git a/src/components/useList/utils/createListItemId.ts b/src/components/useList/utils/createListItemId.ts deleted file mode 100644 index ac011883ef..0000000000 --- a/src/components/useList/utils/createListItemId.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const createListItemId = (itemId: string, listId?: string) => - listId ? `${listId}-${itemId}` : `${itemId}`; diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index f181bd6d42..73e12a6adf 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -1,23 +1,19 @@ /* eslint-disable valid-jsdoc */ import type { - ItemsParsedState, - ListGroupState, ListItemId, + ListParsedState, ListSizeTypes, ListState, RenderItemContext, RenderItemState, } from '../types'; -type ItemRendererProps = ListState & { - size?: ListSizeTypes; - byId: Record; - itemsState: ItemsParsedState; - groupsState: ListGroupState; - lastItemId: ListItemId; - onItemClick?(id: ListItemId): void; - id: ListItemId; -}; +type ItemRendererProps = ListState & + ListParsedState & { + size?: ListSizeTypes; + id: ListItemId; + onItemClick?(id: ListItemId): void; + }; /** * Map list state and parsed list state to item render props @@ -29,19 +25,19 @@ export const getItemRenderState = ( expandedById, groupsState, onItemClick, + flattenIdsOrder, size = 'm', itemsState, - lastItemId, selectedById, activeItemId, id, }: ItemRendererProps, {defaultExpanded = true}: {defaultExpanded?: boolean} = {}, ) => { - const context: RenderItemContext = { + const listContext: RenderItemContext = { itemState: itemsState[id], groupState: groupsState[id], - isLastItem: id === lastItemId, + isLastItem: id === flattenIdsOrder[flattenIdsOrder.length - 1], }; let expanded; @@ -51,16 +47,16 @@ export const getItemRenderState = ( expanded = expandedById[id] ?? defaultExpanded; } - const state: RenderItemState = { + const stateProps: RenderItemState = { id, size, expanded, active: id === activeItemId, - indentation: context.itemState.indentation, + indentation: listContext.itemState.indentation, disabled: disabledById[id], selected: selectedById[id], onClick: onItemClick ? () => onItemClick(id) : undefined, }; - return [byId[id], state, context] as const; + return [byId[id], stateProps, listContext] as const; }; diff --git a/src/components/useList/utils/getListParsedState.test.ts b/src/components/useList/utils/getListParsedState.test.ts index 6fe1b943e7..fa9bd7a6c4 100644 --- a/src/components/useList/utils/getListParsedState.test.ts +++ b/src/components/useList/utils/getListParsedState.test.ts @@ -34,6 +34,17 @@ describe('getListParsedState', () => { ]; expect(getListParsedState(data)).toEqual({ + initialState: { + selectedById: { + 2: true, + }, + disabledById: { + 0: true, + }, + expandedById: { + '1-1': false, + }, + }, byId: { 0: {title: 'item-0'}, 1: {title: 'item-1'}, @@ -47,20 +58,19 @@ describe('getListParsedState', () => { 1: { childrenIds: ['1-0', '1-1', '1-2'], }, - '1-1': {childrenIds: ['1-1-0'], expanded: false}, + '1-1': {childrenIds: ['1-1-0']}, '1-1-0': {childrenIds: []}, '2': {childrenIds: []}, }, itemsState: { - 0: {indentation: 0, disabled: true}, + 0: {indentation: 0}, 1: {indentation: 0}, '1-0': {parentId: '1', indentation: 1}, '1-1': {parentId: '1', indentation: 1}, '1-1-0': {parentId: '1-1', indentation: 2}, '1-2': {parentId: '1', indentation: 1}, - '2': {indentation: 0, selected: true}, + '2': {indentation: 0}, }, - lastItemId: '2', }); }); @@ -81,6 +91,15 @@ describe('getListParsedState', () => { ]; expect(getListParsedState(data)).toEqual({ + initialState: { + selectedById: { + 1: true, + }, + disabledById: { + 0: true, + }, + expandedById: {}, + }, byId: { 0: { a: 'item-1', @@ -97,11 +116,10 @@ describe('getListParsedState', () => { }, groupsState: {}, itemsState: { - 0: {indentation: 0, disabled: true}, - 1: {indentation: 0, selected: true}, + 0: {indentation: 0}, + 1: {indentation: 0}, 2: {indentation: 0}, }, - lastItemId: '2', }); }); @@ -126,6 +144,13 @@ describe('getListParsedState', () => { ]; expect(getListParsedState(data, ({id}) => id)).toEqual({ + initialState: { + selectedById: {}, + disabledById: {}, + expandedById: { + 'id-4': false, + }, + }, byId: { 'id-1': {title: 'item-0', id: 'id-1'}, 'id-2': {title: 'item-1', id: 'id-2'}, @@ -139,7 +164,6 @@ describe('getListParsedState', () => { }, 'id-4': { childrenIds: ['id-5'], - expanded: false, }, 'id-5': { childrenIds: [], @@ -152,7 +176,6 @@ describe('getListParsedState', () => { 'id-4': {indentation: 1, parentId: 'id-2'}, 'id-5': {indentation: 2, parentId: 'id-4'}, }, - lastItemId: 'id-5', }); }); }); diff --git a/src/components/useList/utils/getListParsedState.ts b/src/components/useList/utils/getListParsedState.ts index 9c5e5caea4..849966e137 100644 --- a/src/components/useList/utils/getListParsedState.ts +++ b/src/components/useList/utils/getListParsedState.ts @@ -2,6 +2,7 @@ import type { ListFlattenItemType, ListItemId, ListItemType, + ListState, ListTreeItemType, ParsedState, } from '../types'; @@ -26,6 +27,10 @@ interface TraverseTreeItemProps { parentGroupedId?: string; } +type ListParsedStateResult = ParsedState & { + initialState: Pick; +}; + export function getListParsedState( items: ListItemType[], /** @@ -33,16 +38,20 @@ export function getListParsedState( * So now you can use it id as a list item id in internal state */ getId?: (item: T) => ListItemId, -): ParsedState { +): ListParsedStateResult { if (process.env.NODE_ENV !== 'production') { console.time('getListParsedState'); } - const result: ParsedState = { + const result: ListParsedStateResult = { byId: {}, groupsState: {}, itemsState: {}, - lastItemId: '', + initialState: { + disabledById: {}, + selectedById: {}, + expandedById: {}, + }, }; const traverseItem = ({item, index}: TraverseItemProps) => { @@ -57,14 +66,12 @@ export function getListParsedState( } if (typeof item.selected !== 'undefined') { - result.itemsState[id].selected = item.selected; + result.initialState.selectedById[id] = item.selected; } if (typeof item.disabled !== 'undefined') { - result.itemsState[id].disabled = item.disabled; + result.initialState.disabledById[id] = item.disabled; } - - result.lastItemId = id; }; const traverseTreeItem = ({ @@ -93,26 +100,24 @@ export function getListParsedState( } if (typeof item.selected !== 'undefined') { - result.itemsState[id].selected = item.selected; + result.initialState.selectedById[id] = item.selected; } if (typeof item.disabled !== 'undefined') { - result.itemsState[id].disabled = item.disabled; + result.initialState.disabledById[id] = item.disabled; } if (groupedId) { result.itemsState[id].indentation = parseGroupItemId(groupedId).length - 1; } - result.lastItemId = id; - if (item.children) { result.groupsState[id] = { childrenIds: [], }; if (typeof item.expanded !== 'undefined') { - result.groupsState[id].expanded = item.expanded; + result.initialState.expandedById[id] = item.expanded; } item.children.forEach((treeItem, index) => { diff --git a/src/components/useList/utils/scrollToListItem.ts b/src/components/useList/utils/scrollToListItem.ts index 1d2086eff3..a86de0a84f 100644 --- a/src/components/useList/utils/scrollToListItem.ts +++ b/src/components/useList/utils/scrollToListItem.ts @@ -1,15 +1,13 @@ import {LIST_ITEM_DATA_ATR} from '../constants'; import type {ListItemId} from '../types'; -import {createListItemId} from './createListItemId'; - export const scrollToListItem = ( itemId: ListItemId, containerRef?: HTMLDivElement | HTMLUListElement | null, ) => { if (document) { const element = (containerRef || document).querySelector( - `[${LIST_ITEM_DATA_ATR}="${createListItemId(itemId)}"]`, + `[${LIST_ITEM_DATA_ATR}="${itemId}"]`, ); if (element) { From ac3d49b5dd0a3c2db6d15c89e374e96d6dc89f0b Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Fri, 29 Dec 2023 12:39:03 +0300 Subject: [PATCH 09/14] feat: add selected items array to onUpdate cb --- src/components/TreeSelect/TreeSelect.tsx | 45 +++++++------- .../__stories__/TreeSelect.stories.tsx | 6 +- .../hooks/useTreeSelectSelection.ts | 60 ++++++++++++------- src/components/TreeSelect/types.ts | 2 +- 4 files changed, 66 insertions(+), 47 deletions(-) diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index 3f48177786..96995a9170 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -18,7 +18,7 @@ import { import {block} from '../utils/cn'; import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; -import {useTreeSelectSelection} from './hooks/useTreeSelectSelection'; +import {useTreeSelectSelection, useValue} from './hooks/useTreeSelectSelection'; import type {RenderControlProps, TreeSelectProps} from './types'; import './TreeSelect.scss'; @@ -65,32 +65,11 @@ export const TreeSelect = React.forwardRef(function TreeSelect( const containerRef = React.useRef(null); const handleControlRef = useForkRef(ref, controlRef); - const { - value, - open, - toggleOpen, - handleClearValue, - handleMultipleSelection, - handleSingleSelection, - } = useTreeSelectSelection({ - onUpdate, + const {value, setInnerValue, selected} = useValue({ value: propsValue, defaultValue, - defaultOpen, - open: propsOpen, - onClose, - onOpenChange, }); - const selected = React.useMemo( - () => - value.reduce>((acc, value) => { - acc[value] = true; - return acc; - }, {}), - [value], - ); - const listState = useListState({ expandedById, disabledById, @@ -104,6 +83,26 @@ export const TreeSelect = React.forwardRef(function TreeSelect( getId, }); + const wrappedOnUpdate = React.useCallback( + (ids: ListItemId[]) => + onUpdate?.( + ids, + ids.map((id) => listParsedState.byId[id]), + ), + [listParsedState.byId, onUpdate], + ); + + const {open, toggleOpen, handleClearValue, handleMultipleSelection, handleSingleSelection} = + useTreeSelectSelection({ + setInnerValue, + value, + onUpdate: wrappedOnUpdate, + defaultOpen, + open: propsOpen, + onClose, + onOpenChange, + }); + const handleItemClick = React.useCallback( (id: ListItemId) => { // onItemClick = disabled - switch off default click behavior diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index d0e5f55bce..4675070d55 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -40,16 +40,16 @@ const DefaultTemplate: StoryFn< } > = ({itemsCount = 5, ...props}) => { const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); - const [value, setValue] = React.useState([]); return ( + console.log('Uncontrolled `TreeSelect onUpdate args: `', ...args) + } /> ); diff --git a/src/components/TreeSelect/hooks/useTreeSelectSelection.ts b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts index ebb06a8383..a6742927f7 100644 --- a/src/components/TreeSelect/hooks/useTreeSelectSelection.ts +++ b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts @@ -5,25 +5,50 @@ import {useOpenState} from '../../../hooks/useSelect/useOpenState'; import type {ListItemId} from '../../useList/types'; type UseTreeSelectSelectionProps = { - value?: ListItemId[]; - defaultValue?: ListItemId[]; + value: ListItemId[]; + setInnerValue?(ids: ListItemId[]): void; onUpdate?: (value: ListItemId[]) => void; } & UseOpenProps; +type UseValueProps = { + value?: ListItemId[]; + defaultValue?: ListItemId[]; +}; + +export const useValue = ({defaultValue, value: valueProps}: UseValueProps) => { + const [innerValue, setInnerValue] = React.useState(defaultValue || []); + + const value = valueProps || innerValue; + const uncontrolled = !valueProps; + + const selected = React.useMemo( + () => + value.reduce>((acc, value) => { + acc[value] = true; + return acc; + }, {}), + [value], + ); + + return { + selected, + value, + /** + * Available only if `uncontrolled` component valiant + */ + setInnerValue: uncontrolled ? setInnerValue : undefined, + }; +}; + export const useTreeSelectSelection = ({ + value, + setInnerValue, defaultOpen, onClose, onOpenChange, open: openProps, - value: valueProps, - defaultValue = [], onUpdate, }: UseTreeSelectSelectionProps) => { - const [innerValue, setInnerValue] = React.useState(defaultValue); - - const value = valueProps || innerValue; - const uncontrolled = !valueProps; - const {toggleOpen, open} = useOpenState({ defaultOpen, onClose, @@ -37,14 +62,12 @@ export const useTreeSelectSelection = ({ const nextValue = [id]; onUpdate?.(nextValue); - if (uncontrolled) { - setInnerValue(nextValue); - } + setInnerValue?.(nextValue); } toggleOpen(false); }, - [value, uncontrolled, onUpdate, toggleOpen], + [value, toggleOpen, onUpdate, setInnerValue], ); const handleMultipleSelection = React.useCallback( @@ -56,21 +79,18 @@ export const useTreeSelectSelection = ({ onUpdate?.(nextValue); - if (uncontrolled) { - setInnerValue(nextValue); - } + setInnerValue?.(nextValue); }, - [value, uncontrolled, onUpdate], + [value, onUpdate, setInnerValue], ); const handleClearValue = React.useCallback(() => { onUpdate?.([]); - setInnerValue([]); - }, [onUpdate]); + setInnerValue?.([]); + }, [onUpdate, setInnerValue]); return { open, - value, toggleOpen, handleSingleSelection, handleMultipleSelection, diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index dccaa3641d..09cb3dc185 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -86,7 +86,7 @@ export interface TreeSelectProps extends QAProps, Partial; renderControlContent(item: T): KnownItemStructure; onClose?(): void; - onUpdate?(value: string[]): void; + onUpdate?(value: ListItemId[], selectedItems: T[]): void; onOpenChange?(open: boolean): void; renderContainer?(props: RenderContainerProps): React.JSX.Element; /** From 33c5846039cb3e6ed8fd0f298eef66f86269715e Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Fri, 29 Dec 2023 15:21:16 +0300 Subject: [PATCH 10/14] feat: make renderContentProp optional --- src/components/TreeSelect/TreeSelect.tsx | 35 +++++++++++++++---- .../__stories__/TreeSelect.stories.tsx | 3 +- .../components/InfinityScrollExample.tsx | 7 ++-- .../components/WithDndListExample.tsx | 13 +++---- .../WithFiltrationAndControlsExample.tsx | 3 -- ...pSelectionControlledStateAndCustomIcon.tsx | 30 ++++++++++++---- .../WithItemLinksAndActionsExample.tsx | 2 -- src/components/TreeSelect/types.ts | 17 +++++++-- .../__stories__/ListItemView.stories.tsx | 10 +++--- src/components/useList/index.ts | 1 + src/components/useList/utils.ts | 5 +++ 11 files changed, 85 insertions(+), 41 deletions(-) create mode 100644 src/components/useList/utils.ts diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index 96995a9170..aa7c34bef8 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -10,6 +10,7 @@ import { type ListItemId, ListItemView, getItemRenderState, + isKnownStructureGuard, scrollToListItem, useList, useListKeydown, @@ -26,7 +27,10 @@ import './TreeSelect.scss'; const b = block('tree-select'); export const TreeSelect = React.forwardRef(function TreeSelect( - { + props: TreeSelectProps, + ref: React.Ref, +) { + const { id, slotBeforeListBody, slotAfterListBody, @@ -48,14 +52,12 @@ export const TreeSelect = React.forwardRef(function TreeSelect( onUpdate, getId, onOpenChange, - renderControlContent, renderControl, renderItem, renderContainer: RenderContainer = TreeListContainer, onItemClick, - }: TreeSelectProps, - ref: React.Ref, -) { + } = props; + const [mobile] = useMobile(); const uniqId = useUniqId(); const treeSelectId = id ?? uniqId; @@ -201,7 +203,19 @@ export const TreeSelect = React.forwardRef(function TreeSelect( renderControlContent(listParsedState.byId[id]).title), + value.map((id) => { + if ('renderControlContent' in props) { + return props.renderControlContent(listParsedState.byId[id]).title; + } + + const items = listParsedState.byId[id]; + + if (isKnownStructureGuard(items)) { + return items.title; + } + + return items as string; + }), ).join(', ')} view="normal" pin="round-round" @@ -249,10 +263,17 @@ export const TreeSelect = React.forwardRef(function TreeSelect( return renderItem(item, state, context, renderContextProps); } + const itemData = listParsedState.byId[id]; + return ( ); diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index 4675070d55..8a48680eb6 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -1,7 +1,6 @@ import React from 'react'; import type {Meta, StoryFn} from '@storybook/react'; -import identity from 'lodash/identity'; import {Flex} from '../../layout'; import {createRandomizedData} from '../../useList/__stories__/utils/makeData'; @@ -26,6 +25,7 @@ import { WithItemLinksAndActionsExampleProps, } from './components/WithItemLinksAndActionsExample'; +// TODO: пример с кастомной структурой данных export default { title: 'Unstable/TreeSelect', component: TreeSelect, @@ -45,7 +45,6 @@ const DefaultTemplate: StoryFn< 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 4d2b420c10..0e6a6b3a9e 100644 --- a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -1,7 +1,5 @@ import React from 'react'; -import identity from 'lodash/identity'; - import {Label} from '../../../Label'; import {Loader} from '../../../Loader'; import {Flex, spacing} from '../../../layout'; @@ -31,11 +29,11 @@ export const InfinityScrollExample = ({itemsCount = 5, ...props}: InfinityScroll return ( - {...props} + items={data} value={value} popupClassName={spacing({p: 2})} - renderControlContent={identity} renderItem={(item, state, {isLastItem, groupState}) => { const node = ( , - 'value' | 'onUpdate' | 'items' | 'getItemContent' - > {} + extends Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> {} export const WithDndListExample = (props: WithDndListExampleProps) => { - const [items, setItems] = React.useState(() => createRandomizedData({num: 10, depth: 0})); + const [items, setItems] = React.useState(() => + createRandomizedData({num: 10, depth: 0, getData: (title) => title}), + ); const [value, setValue] = React.useState([]); const handleDrugEnd: OnDragEndResponder = ({destination, source}) => { @@ -57,7 +55,6 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { {...props} value={value} items={items} - renderControlContent={identity} onItemClick={(_, {id, isGroup, disabled}) => { if (!isGroup && !disabled) { setValue([id]); @@ -97,7 +94,7 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { renderItem={(item, state, _listContext, renderContextProps) => { const commonProps = { ...state, - ...item, + title: item, endSlot: , }; diff --git a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx index 93cc4e3613..0cd53c850f 100644 --- a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx @@ -1,7 +1,5 @@ import React from 'react'; -import identity from 'lodash/identity'; - import {Button} from '../../../Button'; import {Text} from '../../../Text'; import {TextInput} from '../../../controls'; @@ -86,7 +84,6 @@ export const WithFiltrationAndControlsExample = ({ } value={value} - renderControlContent={identity} items={filterState.items} onUpdate={setValue} /> diff --git a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx index 083d1c43ac..b6c1ba14ce 100644 --- a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx +++ b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx @@ -1,29 +1,47 @@ import React from 'react'; import {ChevronDown, ChevronUp, Database, PlugConnection} from '@gravity-ui/icons'; -import identity from 'lodash/identity'; import {Button} from '../../../Button'; import {Icon} from '../../../Icon'; import {Flex, spacing} from '../../../layout'; -import {ListItemId, ListItemView, getListParsedState} from '../../../useList'; +import { + type KnownItemStructure, + ListItemId, + ListItemView, + getListParsedState, +} from '../../../useList'; import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeSelect} from '../../TreeSelect'; import type {TreeSelectProps} from '../../types'; +/** + * Just for example how to work with data + */ +interface CustomDataStructure { + a: string; +} + export interface WithGroupSelectionControlledStateAndCustomIconExampleProps extends Omit< - TreeSelectProps<{title: string}>, + TreeSelectProps, 'value' | 'onUpdate' | 'items' | 'getItemContent' | 'size' > { itemsCount?: number; } +const mapCustomDataStructureToKnownProps = (props: CustomDataStructure): KnownItemStructure => ({ + title: props.a, +}); + export const WithGroupSelectionControlledStateAndCustomIconExample = ({ itemsCount = 5, ...props }: WithGroupSelectionControlledStateAndCustomIconExampleProps) => { - const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); + const items = React.useMemo( + () => createRandomizedData({num: itemsCount, getData: (a) => ({a})}), + [itemsCount], + ); const [value, setValue] = React.useState([]); const [expandedById, setExpanded] = React.useState>( @@ -35,7 +53,7 @@ export const WithGroupSelectionControlledStateAndCustomIconExample = ({ } diff --git a/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx index 884b5e42fb..a96bf73628 100644 --- a/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx @@ -1,7 +1,6 @@ import React from 'react'; import {ChevronDown, ChevronUp, FolderOpen} from '@gravity-ui/icons'; -import identity from 'lodash/identity'; import {Button} from '../../../Button'; import {DropdownMenu} from '../../../DropdownMenu'; @@ -33,7 +32,6 @@ export const WithItemLinksAndActionsExample = (props: WithItemLinksAndActionsExa size="l" value={value} items={items} - renderControlContent={identity} onItemClick={(_, {id, isGroup, disabled}) => { if (!isGroup && !disabled) { setValue([id]); diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index 09cb3dc185..000a4bca69 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -41,11 +41,11 @@ export type RenderContainerProps = ListParsedState & containerRef: React.RefObject; }; -export interface TreeSelectProps extends QAProps, Partial> { +interface TreeSelectBaseProps extends QAProps, Partial> { value?: ListItemId[]; defaultOpen?: boolean; defaultValue?: ListItemId[]; - items: ListItemType[]; + // items: ListItemType[]; open?: boolean; id?: string | undefined; popupClassName?: string; @@ -84,7 +84,6 @@ export interface TreeSelectProps extends QAProps, Partial; - renderControlContent(item: T): KnownItemStructure; onClose?(): void; onUpdate?(value: ListItemId[], selectedItems: T[]): void; onOpenChange?(open: boolean): void; @@ -96,3 +95,15 @@ export interface TreeSelectProps extends QAProps, Partial void, content: OverrideItemContext) => 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/components/ListItemView/__stories__/ListItemView.stories.tsx b/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx index c7474a351b..888a7a0e7a 100644 --- a/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx +++ b/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type {Meta, StoryFn} from '@storybook/react'; -import {UserAvatar} from '../../../../UserAvatar'; +import {Avatar} from '../../../../Avatar'; import {Flex} from '../../../../layout'; import {ListItemView as ListItemViewComponent, ListItemViewProps} from '../ListItemView'; @@ -22,7 +22,7 @@ const stories: ListItemViewProps[] = [ subtitle, disabled: true, startSlot: ( - + ), }, { @@ -38,7 +38,7 @@ const stories: ListItemViewProps[] = [ subtitle, selected: true, startSlot: ( - + ), }, { @@ -48,7 +48,7 @@ const stories: ListItemViewProps[] = [ size: 'xl', height: 60, startSlot: ( - + ), }, { @@ -61,7 +61,7 @@ const stories: ListItemViewProps[] = [ size: 'l', subtitle, startSlot: ( - + ), indentation: 1, selected: true, diff --git a/src/components/useList/index.ts b/src/components/useList/index.ts index a2e315811d..0321954b30 100644 --- a/src/components/useList/index.ts +++ b/src/components/useList/index.ts @@ -11,3 +11,4 @@ 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/utils.ts b/src/components/useList/utils.ts new file mode 100644 index 0000000000..0bd5e91c28 --- /dev/null +++ b/src/components/useList/utils.ts @@ -0,0 +1,5 @@ +import type {KnownItemStructure} from './types'; + +export const isKnownStructureGuard = (item: unknown): item is KnownItemStructure => { + return item !== null && typeof item === 'object' && 'title' in item; +}; From 0b52b21e88d60ad0743f90a5adfe299c182bbc3c Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Wed, 10 Jan 2024 12:41:25 +0300 Subject: [PATCH 11/14] feat: add simple examples in doc, fix some review issues --- src/components/TreeSelect/TreeSelect.scss | 6 + src/components/TreeSelect/TreeSelect.tsx | 24 +++- .../__stories__/TreeSelect.stories.tsx | 1 - .../WithFiltrationAndControlsExample.tsx | 32 ++--- src/components/TreeSelect/types.ts | 2 + .../__stories__/components/FlattenList.tsx | 2 +- .../components/PopupWithTogglerList.tsx | 2 +- .../__stories__/components/RecursiveList.tsx | 2 +- .../useList/__stories__/useList.mdx | 109 +++++++++++++++++- .../components/ListItemView/ListItemView.tsx | 8 +- .../__stories__/ListItemView.stories.tsx | 50 ++++++-- src/components/useList/types.ts | 2 +- 12 files changed, 199 insertions(+), 41 deletions(-) diff --git a/src/components/TreeSelect/TreeSelect.scss b/src/components/TreeSelect/TreeSelect.scss index a12d97fa41..ad7cf33af3 100644 --- a/src/components/TreeSelect/TreeSelect.scss +++ b/src/components/TreeSelect/TreeSelect.scss @@ -3,6 +3,12 @@ $block: '.#{variables.$ns}tree-select'; #{$block} { + max-width: 100%; + + &_width_max { + width: 100%; + } + &__popup { overflow: hidden; min-width: 300px; diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index aa7c34bef8..b785583774 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -16,7 +16,7 @@ import { useListKeydown, useListState, } from '../useList'; -import {block} from '../utils/cn'; +import {type CnMods, block} from '../utils/cn'; import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; import {useTreeSelectSelection, useValue} from './hooks/useTreeSelectSelection'; @@ -37,6 +37,8 @@ export const TreeSelect = React.forwardRef(function TreeSelect( size = 'm', items, defaultOpen, + className, + width, popupClassName, open: propsOpen, multiple, @@ -224,8 +226,24 @@ export const TreeSelect = React.forwardRef(function TreeSelect( /> ); + const mods: CnMods = { + ...(width === 'max' && {width}), + }; + + const inlineStyles: React.CSSProperties = {}; + + if (typeof width === 'number') { + inlineStyles.width = width; + } + return ( - + {togglerNode} ( }); // assign components scope logic - state.selectable = context.groupState + state.hasSelectionIcon = context.groupState ? groupsBehavior === 'selectable' : undefined; diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx index 8a48680eb6..ee0de0674e 100644 --- a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -25,7 +25,6 @@ import { WithItemLinksAndActionsExampleProps, } from './components/WithItemLinksAndActionsExample'; -// TODO: пример с кастомной структурой данных export default { title: 'Unstable/TreeSelect', component: TreeSelect, diff --git a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx index 0cd53c850f..b4f6abda50 100644 --- a/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx @@ -7,7 +7,7 @@ import {Flex, spacing} from '../../../layout'; import {useListFilter} from '../../../useList'; import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeSelect} from '../../TreeSelect'; -import type {TreeSelectProps} from '../../types'; +import type {RenderContainerProps, TreeSelectProps} from '../../types'; import {RenderVirtualizedContainer} from './RenderVirtualizedContainer'; @@ -23,7 +23,23 @@ export const WithFiltrationAndControlsExample = ({ itemsCount = 5, ...props }: WithFiltrationAndControlsExampleProps) => { - const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); + const {items, renderContainer} = React.useMemo(() => { + const baseItems = createRandomizedData({num: itemsCount}); + const containerRenderer = (props: RenderContainerProps<{title: string}>) => { + if (props.items.length === 0 && baseItems.length > 0) { + return ( + + Nothing found + + ); + } + + return ; + }; + + return {items: baseItems, renderContainer: containerRenderer}; + }, [itemsCount]); + const [open, onOpenChange] = React.useState(true); const [value, setValue] = React.useState([]); const filterState = useListFilter({items}); @@ -48,17 +64,7 @@ export const WithFiltrationAndControlsExample = ({ ref={filterState.filterRef} /> } - renderContainer={(props) => { - if (props.items.length === 0 && items.length > 0) { - return ( - - Nothing found - - ); - } - - return ; - }} + renderContainer={renderContainer} slotAfterListBody={ } placement={['bottom-start', 'bottom-end', 'top-start', 'top-end']} offset={[0, 10]} @@ -108,19 +106,19 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro expandedById={listState.expandedById} > {(id) => { - const [data, state, listContext] = getItemRenderState({ + const {data, props, context} = getItemRenderState({ id, size, onItemClick, - ...listParsedState, + ...list, ...listState, }); return ( ); }} diff --git a/src/components/useList/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx index 5fa8773e77..2d76e88d22 100644 --- a/src/components/useList/__stories__/components/RecursiveList.tsx +++ b/src/components/useList/__stories__/components/RecursiveList.tsx @@ -9,13 +9,13 @@ import {useList} from '../../hooks/useList'; import {useListFilter} from '../../hooks/useListFilter'; import {useListKeydown} from '../../hooks/useListKeydown'; import {useListState} from '../../hooks/useListState'; -import type {ListItemId, ListSizeTypes} from '../../types'; +import type {ListItemId, ListItemSizeType} from '../../types'; import {getItemRenderState} from '../../utils/getItemRenderState'; import {createRandomizedData} from '../utils/makeData'; export interface RecursiveListProps { itemsCount: number; - size: ListSizeTypes; + size: ListItemSizeType; } export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { @@ -30,14 +30,14 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { const listState = useListState(); - const listParsedState = useList({ + const list = useList({ items: filterState.items, - expandedById: listState.expandedById, + ...listState, }); const onItemClick = React.useCallback( (id: ListItemId) => { - if (id in listParsedState.groupsState) { + if (id in list.groupsState) { listState.setExpanded((state) => ({ ...state, [id]: id in state ? !state[id] : false, @@ -52,13 +52,13 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { listState.setActiveItemId(id); }, - [listParsedState.groupsState, listState], + [list.groupsState, listState], ); useListKeydown({ containerRef, onItemClick, - ...listParsedState, + ...list, ...listState, }); @@ -67,7 +67,7 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { { expandedById={listState.expandedById} > {(id) => { - const [data, state, listContext] = getItemRenderState({ + const {data, props, context} = getItemRenderState({ id, size, onItemClick, - ...listParsedState, + ...list, ...listState, }); return ( ); }} diff --git a/src/components/useList/__stories__/useList.mdx b/src/components/useList/__stories__/useList.mdx index 2f3f031290..09a5b34520 100644 --- a/src/components/useList/__stories__/useList.mdx +++ b/src/components/useList/__stories__/useList.mdx @@ -52,29 +52,33 @@ function List() { const containerRef = React.useRef(null); const listState = useListState(); - const parsedListState = useList({ + const list = useList({ items, - expandedById: listState.expandedById, + ...listState, }); useListKeydown({ onItemClick, containerRef, - ...parsedListState, + ...list, ...listState, }); return ( - {parsedListState.items.map((_, i) => { - const [itemData, computedItemProps, _listContext] = getItemRenderState({ + {list.items.map((_, i) => { + const { + data, + props, + context: _context, + } = getItemRenderState({ id: String(i), onItemClick, - ...parsedListState, + ...list, ...listState, }); - return ; + return ; })} ); @@ -103,7 +107,7 @@ function List() { // same as prev example return ( - {parsedListState.items.map((item, index) => ( + {list.items.map((item, index) => ( {(id) => { - const [itemData, computedItemProps, _listContext] = getItemRenderState({ + const { + data, + props, + context: _context, + } = getItemRenderState({ id: String(i), onItemClick, - ...parsedListState, + ...list, ...listState, }); - return ; + return ; }} ))} @@ -168,7 +176,7 @@ interface ListTreeItemType extends ListItemInitialProps { export type ListItemType = ListTreeItemType | ListFlattenItemType; ``` -- `expandedById` - state for open/closed `List` elements. Affects the formation of the `flattenIdsOrder` - if the element id in this object is set to `false` - all elements of this group and all nested groups will not be present in the final ids order; +- `expandedById` - state for open/closed `List` elements. Affects the formation of the `existedFlattenIds` - if the element id in this object is set to `false` - all elements of this group and all nested groups will not be present in the final ids order; - `getId` - the property is optional. Allows you to generate an id for a list item depending on the list data: ```tsx @@ -178,7 +186,7 @@ const items = [ ]; /** - * byId: { + * itemsById: { * 'id-1': {id: 'id-1', title: 'some title 1'}, * 'id-2': {id: 'id-2', title: 'some title 2'}, * } @@ -197,12 +205,12 @@ const {byid} = useList({ - `parentId` - Id of the parent element, if there is a parent; - `indentation` - Nesting level; -- `byId` - normalized representation of list items: +- `itemsById` - normalized representation of list items: ```tsx export type ParsedState = { // ... - byId: Record; + itemsById: Record; // ... }; @@ -211,7 +219,7 @@ const {byid} = useList({ {data: {title: 'title-2'}, children: []}, ]; // -> - const byId: { + const itemsById: { 0: {title: 'title-1'}; '0-0': {title: 'title-1-1'}; 1: {title: 'title-2'}; @@ -222,7 +230,7 @@ const {byid} = useList({ - `groupsState` - a normalized representation of metadata about a group if the item is both a list item and a group: - `childrenIds` - list of child element IDs; -- `flattenIdsOrder` - sequential representation of list items by id, taking into account invisible elements inside collapsed groups; +- `existedFlattenIds` - sequential representation of list items by id, taking into account invisible elements inside collapsed groups; ### useListKeydown @@ -232,24 +240,24 @@ Keyboard support - `disabledById` - key-value representation of disabled elements that do not need to be taken into account when navigating through the `List`; - `activeItemId` - current active item `id`; -- `flattenIdsOrder` - a flat list of elements to be navigated through; Collapsed groups must be taken into account in this array; +- `existedFlattenIds` - a flat list of elements to be navigated through; Collapsed groups must be taken into account in this array; - `onItemClick` - callback will be called when pressing the `Enter`, `Space` keys; - `containerRef` - a reference to the DOM element of the List container inside which to search for its elements; - `setActiveItemId` - Callback for setting the current active element; -- `enactive` - on/off keyboard support. Use it if you need to change the behavior in runtime; +- `enabled` - on/off keyboard support. Use it if you need to change the behavior in runtime; ```tsx const containerRef = React.useRef(null); -const parsedListState = useListState() -const parsedListState = useList(...) +const listState = useListState() +const list = useList(...) const handleItemClick = () => {...}; useListKeydown({ onItemClick: handleItemClick, containerRef, + ...list, ...listState, - ...parsedListState, }) ``` @@ -269,21 +277,25 @@ useListKeydown({ - `filter` - current filter value; - `reset` - method for resetting the filter value; - `items` - list of filtered sheet elements `listItemType[]`; -- `onChange` - callback for changing the filter value; +- `onFilterUpdate` - callback for changing the filter value; ```tsx const List = () => { - const {items, reset: _reset, ...conponentProps} = useListFilter({ + const {items, filter, onFilterUpdate, filterRef} = useListFilter({ items: [...] }) - const parsedListState = useList({ + const list = useList({ items, }) return ( <> - + ) } @@ -411,7 +423,7 @@ For the virtualized version of the list, you need to implement a component with index={index} expandedById={expandedById} > - {(id) => } + {(id) => } ))} @@ -425,13 +437,13 @@ Utility to compute list item height: ```tsx computeItemSize( // list size size, // has subrows - Boolean(get(byId[flattenIdsOrder[index]], 'subtitle')), + Boolean(get(itemsById[existedFlattenIds[index]], 'subtitle')), ) } /> @@ -447,7 +459,7 @@ const containerRef = React.useRef(null); React.useLayoutEffect(() => { if (open) { containerRef.current?.focus(); - listState.setActiveItemId(selectedId ?? listParsedState.flattenIdsOrder[0]); + listState.setActiveItemId(selectedId ?? list.existedFlattenIds[0]); if (selectedId) { scrollToListItem(selectedId, containerRef.current); @@ -493,21 +505,24 @@ item = T - `isLastItem` - if item is last in the list. Useful in cases than you need to do somthing on last item appears. For example, implement custom infinity lists variants ```tsx -const listParsedState = useList(); const listState = useListState(); +const list = useList({ + items, + ...listState, +}); const handleItemClick = () => {}; {(id) => { - const [data, stateProps, _listContext] = getItemRenderState({ + const {data, props} = getItemRenderState({ id, size, // list size onItemClick: handleItemClick, - ...listParsedState, + ...list, ...listState, }); - return ; + return ; }} ; ``` diff --git a/src/components/useList/components/ListContainerView/ListContainerView.scss b/src/components/useList/components/ListContainerView/ListContainerView.scss index 33defb91cb..ba4001b476 100644 --- a/src/components/useList/components/ListContainerView/ListContainerView.scss +++ b/src/components/useList/components/ListContainerView/ListContainerView.scss @@ -8,7 +8,7 @@ $block: '.#{variables.$ns}list-container-view'; outline: none; &_fixed-height { - height: var(--g-list-height, 300px); + height: var(--g-list-container-height, 300px); } &:not(#{$block}_fixed-height) { diff --git a/src/components/useList/components/ListContainerView/ListContainerView.tsx b/src/components/useList/components/ListContainerView/ListContainerView.tsx index f03bc9079d..2e541b719d 100644 --- a/src/components/useList/components/ListContainerView/ListContainerView.tsx +++ b/src/components/useList/components/ListContainerView/ListContainerView.tsx @@ -9,24 +9,30 @@ import './ListContainerView.scss'; const b = block('list-container-view'); -export interface ListContainerViewProps extends QAProps, React.HTMLAttributes<'div'> { +export interface ListContainerViewProps extends QAProps { + /** + * Ability to override default html tag + */ + as?: keyof JSX.IntrinsicElements; id?: string; + role?: React.AriaRole; className?: string; /** * Removes `overflow: auto` from container and set fixed container size (`--g-list-height` = `300px`) */ fixedHeight?: boolean; children: React.ReactNode; + extraProps?: React.HTMLAttributes<'div'>; } export const ListContainerView = React.forwardRef( function ListContainerView( - {role = 'listbox', children, id, className, fixedHeight, ...props}, + {as = 'div', role = 'listbox', children, id, className, fixedHeight, extraProps}, ref, ) { return ( {children} diff --git a/src/components/useList/components/ListItemView/ListItemView.scss b/src/components/useList/components/ListItemView/ListItemView.scss index ff377b774a..5e4aa8b143 100644 --- a/src/components/useList/components/ListItemView/ListItemView.scss +++ b/src/components/useList/components/ListItemView/ListItemView.scss @@ -20,6 +20,19 @@ $block: '.#{variables.$ns}list-item-view'; background: var(--g-color-base-selection); } + &_radius_s { + border-radius: var(--g-list-item-border-radius-s, 3px); + } + &_radius_m { + border-radius: var(--g-list-item-border-radius-m, 6px); + } + &_radius_l { + border-radius: var(--g-list-item-border-radius-l, 8px); + } + &_radius_xl { + border-radius: var(--g-list-item-border-radius-xl, 8px); + } + &__slot { &_indent_1 { width: 16px; diff --git a/src/components/useList/components/ListItemView/ListItemView.tsx b/src/components/useList/components/ListItemView/ListItemView.tsx index c35f95c94a..53acbe1ab9 100644 --- a/src/components/useList/components/ListItemView/ListItemView.tsx +++ b/src/components/useList/components/ListItemView/ListItemView.tsx @@ -4,18 +4,17 @@ import {Check, ChevronDown, ChevronUp} from '@gravity-ui/icons'; import {Icon} from '../../../Icon'; import {Text, colorText} from '../../../Text'; -import {borderRadius} from '../../../borderRadius'; import {Flex, FlexProps, spacing} from '../../../layout'; import type {QAProps} from '../../../types'; import {block} from '../../../utils/cn'; import {LIST_ITEM_DATA_ATR, modToHeight} from '../../constants'; -import type {ListItemId, ListSizeTypes} from '../../types'; +import type {ListItemId, ListItemSizeType} from '../../types'; import './ListItemView.scss'; const b = block('list-item-view'); -export interface ListItemViewProps extends QAProps, Omit, 'title'> { +export interface ListItemViewProps extends QAProps { /** * Ability to override default html tag */ @@ -23,7 +22,7 @@ export interface ListItemViewProps extends QAProps, Omit { +export interface UseListProps extends Partial { items: ListItemType[]; /** * Control expanded items state from external source */ - expandedById?: Record; getId?(item: T): ListItemId; } +export type UseListResult = ListParsedState; + /** * Take array of items as a argument and returns parsed representation of this data structure to work with */ -export const useList = ({items, expandedById, getId}: UseListProps): ListParsedState => { - const {byId, groupsState, itemsState} = useListParsedState({ +export const useList = ({items, expandedById, getId}: UseListProps): UseListResult => { + const {itemsById, groupsState, itemsState, initialState} = useListParsedState({ items, getId, }); - const flattenIdsOrder = useFlattenListItems({ + const existedFlattenIds = useFlattenListItems({ items, - expandedById, + /** + * By default controlled from list items declaration state + */ + expandedById: expandedById || initialState.expandedById, getId, }); - return {items, flattenIdsOrder, byId, groupsState, itemsState}; + return {items, existedFlattenIds, itemsById, groupsState, itemsState}; }; diff --git a/src/components/useList/hooks/useListFilter.ts b/src/components/useList/hooks/useListFilter.ts index a5b55d07b1..4a6d06d8fa 100644 --- a/src/components/useList/hooks/useListFilter.ts +++ b/src/components/useList/hooks/useListFilter.ts @@ -29,7 +29,7 @@ interface UseListFilterProps { * Ready-to-use logic for filtering tree-like data structures * ```tsx * const {item: filteredItems,...listFiltration} = useListFIlter({items}); - * const listParsedState = useList({items: filteredItems}); + * const list = useList({items: filteredItems}); * * * ``` @@ -70,28 +70,29 @@ export function useListFilter({ setPrevItems(externalItems); } - const reset = React.useCallback(() => { - setFilter(initialFilterValue); - setItems(externalItems); - }, [externalItems, initialFilterValue]); - - const onChange = React.useMemo(() => { + const {onFilterUpdate, reset} = React.useMemo(() => { const debouncedFn = debounce( (value) => setItems(filterItemsFn(value, externalItems)), debounceTimeout, ); - return (nextFilterValue: string) => { - setFilter(nextFilterValue); - debouncedFn(nextFilterValue); + return { + reset: () => { + setFilter(initialFilterValue); + debouncedFn(initialFilterValue); + }, + onFilterUpdate: (nextFilterValue: string) => { + setFilter(nextFilterValue); + debouncedFn(nextFilterValue); + }, }; - }, [debounceTimeout, externalItems, filterItemsFn]); + }, [debounceTimeout, externalItems, filterItemsFn, initialFilterValue]); return { filterRef, filter, reset, items, - onChange, + onFilterUpdate, }; } diff --git a/src/components/useList/hooks/useListKeydown.tsx b/src/components/useList/hooks/useListKeydown.tsx index b230cb2809..fd64905401 100644 --- a/src/components/useList/hooks/useListKeydown.tsx +++ b/src/components/useList/hooks/useListKeydown.tsx @@ -1,48 +1,49 @@ import React from 'react'; +import {KeyCode} from '../../../constants'; import type {ListItemId, ListState} from '../types'; import {findNextIndex} from '../utils/findNextIndex'; import {scrollToListItem} from '../utils/scrollToListItem'; interface UseListKeydownProps extends Partial> { - flattenIdsOrder: ListItemId[]; + existedFlattenIds: ListItemId[]; onItemClick?(itemId: ListItemId): void; containerRef?: React.RefObject; setActiveItemId?(id: ListItemId): void; - enactive?: boolean; + enabled?: boolean; } // Use this hook if you need keyboard support for tree structure lists export const useListKeydown = ({ - flattenIdsOrder, + existedFlattenIds, onItemClick, containerRef, disabledById = {}, activeItemId, setActiveItemId, - enactive, + enabled, }: UseListKeydownProps) => { const activateItem = React.useCallback( (index?: number, scrollTo = true) => { - if (typeof index === 'number' && flattenIdsOrder[index]) { + if (typeof index === 'number' && existedFlattenIds[index]) { if (scrollTo) { - scrollToListItem(flattenIdsOrder[index], containerRef?.current); + scrollToListItem(existedFlattenIds[index], containerRef?.current); } - setActiveItemId?.(flattenIdsOrder[index]); + setActiveItemId?.(existedFlattenIds[index]); } }, - [containerRef, flattenIdsOrder, setActiveItemId], + [containerRef, existedFlattenIds, setActiveItemId], ); const handleKeyMove = React.useCallback( (event: KeyboardEvent, step: number, defaultItemIndex = 0) => { event.preventDefault(); - const maybeIndex = flattenIdsOrder.findIndex((i) => i === activeItemId); + const maybeIndex = existedFlattenIds.findIndex((i) => i === activeItemId); const nextIndex = findNextIndex({ - list: flattenIdsOrder, + list: existedFlattenIds, index: (maybeIndex > -1 ? maybeIndex : defaultItemIndex) + step, step: Math.sign(step), disabledItems: disabledById, @@ -50,28 +51,28 @@ export const useListKeydown = ({ activateItem(nextIndex); }, - [activateItem, activeItemId, disabledById, flattenIdsOrder], + [activateItem, activeItemId, disabledById, existedFlattenIds], ); React.useLayoutEffect(() => { const anchor = containerRef?.current; - if (enactive || !anchor) { + if (enabled || !anchor) { return undefined; } const handleKeyDown = (event: KeyboardEvent) => { switch (event.key) { - case 'ArrowDown': { + case KeyCode.ARROW_DOWN: { handleKeyMove(event, 1, -1); break; } - case 'ArrowUp': { + case KeyCode.ARROW_UP: { handleKeyMove(event, -1); break; } - case ' ': - case 'Enter': { + case KeyCode.SPACEBAR: + case KeyCode.ENTER: { if (activeItemId && !disabledById[activeItemId]) { event.preventDefault(); @@ -89,5 +90,5 @@ export const useListKeydown = ({ return () => { anchor.removeEventListener('keydown', handleKeyDown); }; - }, [activeItemId, containerRef, disabledById, enactive, handleKeyMove, onItemClick]); + }, [activeItemId, containerRef, disabledById, enabled, handleKeyMove, onItemClick]); }; diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index 77b225dae2..b9a113b989 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -1,6 +1,6 @@ export type ListItemId = string; -export type ListSizeTypes = 's' | 'm' | 'l' | 'xl'; +export type ListItemSizeType = 's' | 'm' | 'l' | 'xl'; interface ListItemInitialProps { /** * If you need to control the state from the outside, @@ -21,7 +21,7 @@ interface ListItemInitialProps { expanded?: boolean; } -export type ListFlattenItemType = T & ListItemInitialProps; +export type ListFlattenItemType = T extends {} ? T & ListItemInitialProps : T; export interface ListTreeItemType extends ListItemInitialProps { data: T; @@ -34,7 +34,7 @@ export type GroupParsedState = { childrenIds: ListItemId[]; }; -export type ItemParsedState = { +export type ItemState = { parentId?: ListItemId; indentation: number; }; @@ -54,7 +54,7 @@ export interface OverrideItemContext { } export type RenderItemContext = { - itemState: ItemParsedState; + itemState: ItemState; /** * Exists if item is group */ @@ -63,7 +63,7 @@ export type RenderItemContext = { }; export type RenderItemState = { - size: ListSizeTypes; + size: ListItemSizeType; id: ListItemId; onClick?(): void; selected: boolean; @@ -79,11 +79,11 @@ export type ParsedState = { * Stored internal meta info about item * Note: Groups are also items */ - itemsState: Record; + itemsState: Record; /** * Normalized original data */ - byId: Record; + itemsById: Record; /** * Stored info about group items: */ @@ -99,5 +99,5 @@ export type ListState = { export type ListParsedState = ParsedState & { items: ListItemType[]; - flattenIdsOrder: ListItemId[]; + existedFlattenIds: ListItemId[]; }; diff --git a/src/components/useList/utils/computeItemSize.ts b/src/components/useList/utils/computeItemSize.ts index 189180b480..23b1544088 100644 --- a/src/components/useList/utils/computeItemSize.ts +++ b/src/components/useList/utils/computeItemSize.ts @@ -1,6 +1,6 @@ import {modToHeight} from '../constants'; -import type {ListSizeTypes} from '../types'; +import type {ListItemSizeType} from '../types'; -export const computeItemSize = (size: ListSizeTypes, hasSubRows = false) => { +export const computeItemSize = (size: ListItemSizeType, hasSubRows = false) => { return modToHeight[size][Number(hasSubRows)]; }; diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index 73e12a6adf..1307031725 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -1,8 +1,8 @@ /* eslint-disable valid-jsdoc */ import type { ListItemId, + ListItemSizeType, ListParsedState, - ListSizeTypes, ListState, RenderItemContext, RenderItemState, @@ -10,7 +10,7 @@ import type { type ItemRendererProps = ListState & ListParsedState & { - size?: ListSizeTypes; + size?: ListItemSizeType; id: ListItemId; onItemClick?(id: ListItemId): void; }; @@ -20,12 +20,12 @@ type ItemRendererProps = ListState & */ export const getItemRenderState = ( { - byId, + itemsById, disabledById, expandedById, groupsState, onItemClick, - flattenIdsOrder, + existedFlattenIds, size = 'm', itemsState, selectedById, @@ -34,10 +34,10 @@ export const getItemRenderState = ( }: ItemRendererProps, {defaultExpanded = true}: {defaultExpanded?: boolean} = {}, ) => { - const listContext: RenderItemContext = { + const context: RenderItemContext = { itemState: itemsState[id], groupState: groupsState[id], - isLastItem: id === flattenIdsOrder[flattenIdsOrder.length - 1], + isLastItem: id === existedFlattenIds[existedFlattenIds.length - 1], }; let expanded; @@ -52,11 +52,11 @@ export const getItemRenderState = ( size, expanded, active: id === activeItemId, - indentation: listContext.itemState.indentation, + indentation: context.itemState.indentation, disabled: disabledById[id], selected: selectedById[id], onClick: onItemClick ? () => onItemClick(id) : undefined, }; - return [byId[id], stateProps, listContext] as const; + return {data: itemsById[id], props: stateProps, context}; }; diff --git a/src/components/useList/utils/getListItemId.ts b/src/components/useList/utils/getListItemId.ts index 666978d73c..78d101075e 100644 --- a/src/components/useList/utils/getListItemId.ts +++ b/src/components/useList/utils/getListItemId.ts @@ -13,7 +13,7 @@ export const getListItemId = ({item, groupedId, getId}: GetListItemIdProps if (typeof getId === 'function') { id = getId(isTreeItemGuard(item) ? item.data : item); - } else if (item.id) { + } else if (item && typeof item === 'object' && 'id' in item && item.id) { id = item.id; } diff --git a/src/components/useList/utils/getListParsedState.test.ts b/src/components/useList/utils/getListParsedState.test.ts index fa9bd7a6c4..9122c4cf29 100644 --- a/src/components/useList/utils/getListParsedState.test.ts +++ b/src/components/useList/utils/getListParsedState.test.ts @@ -45,7 +45,7 @@ describe('getListParsedState', () => { '1-1': false, }, }, - byId: { + itemsById: { 0: {title: 'item-0'}, 1: {title: 'item-1'}, '1-0': {title: 'child-1-1'}, @@ -100,7 +100,7 @@ describe('getListParsedState', () => { }, expandedById: {}, }, - byId: { + itemsById: { 0: { a: 'item-1', children: [], @@ -151,7 +151,7 @@ describe('getListParsedState', () => { 'id-4': false, }, }, - byId: { + itemsById: { 'id-1': {title: 'item-0', id: 'id-1'}, 'id-2': {title: 'item-1', id: 'id-2'}, 'id-3': {title: 'child-1-1', id: 'id-3'}, diff --git a/src/components/useList/utils/getListParsedState.ts b/src/components/useList/utils/getListParsedState.ts index 849966e137..86350b3281 100644 --- a/src/components/useList/utils/getListParsedState.ts +++ b/src/components/useList/utils/getListParsedState.ts @@ -44,7 +44,7 @@ export function getListParsedState( } const result: ListParsedStateResult = { - byId: {}, + itemsById: {}, groupsState: {}, itemsState: {}, initialState: { @@ -57,7 +57,7 @@ export function getListParsedState( const traverseItem = ({item, index}: TraverseItemProps) => { const id = getListItemId({groupedId: String(index), item, getId}); - result.byId[id] = item; + result.itemsById[id] = item; if (!result.itemsState[id]) { result.itemsState[id] = { @@ -65,12 +65,14 @@ export function getListParsedState( }; } - if (typeof item.selected !== 'undefined') { - result.initialState.selectedById[id] = item.selected; - } + if (item && typeof item === 'object') { + if ('selected' in item && typeof item.selected === 'boolean') { + result.initialState.selectedById[id] = item.selected; + } - if (typeof item.disabled !== 'undefined') { - result.initialState.disabledById[id] = item.disabled; + if ('disabled' in item && typeof item.disabled === 'boolean') { + result.initialState.disabledById[id] = item.disabled; + } } }; @@ -87,7 +89,7 @@ export function getListParsedState( result.groupsState[parentId].childrenIds.push(id); } - result.byId[id] = item.data; + result.itemsById[id] = item.data; if (!result.itemsState[id]) { result.itemsState[id] = { diff --git a/src/components/useList/utils/scrollToListItem.ts b/src/components/useList/utils/scrollToListItem.ts index a86de0a84f..b2ab2358d1 100644 --- a/src/components/useList/utils/scrollToListItem.ts +++ b/src/components/useList/utils/scrollToListItem.ts @@ -3,10 +3,10 @@ import type {ListItemId} from '../types'; export const scrollToListItem = ( itemId: ListItemId, - containerRef?: HTMLDivElement | HTMLUListElement | null, + containerElement?: HTMLDivElement | HTMLUListElement | null, ) => { if (document) { - const element = (containerRef || document).querySelector( + const element = (containerElement || document).querySelector( `[${LIST_ITEM_DATA_ATR}="${itemId}"]`, ); diff --git a/src/unstable.ts b/src/unstable.ts index c96cb6e2e6..7d0b8d5a49 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -1,2 +1,8 @@ -export {useList, useListFilter, useListKeydown} from './components/useList'; +/* eslint-disable camelcase */ +export { + useList as unstable_useList, + useListState as unstable_useListState, + useListFilter as unstable_useListFilter, + useListKeydown as unstable_useListKeydown, +} from './components/useList'; export * from './components/TreeSelect'; From 52b939240ca1d60cb1a3a5746abdaf97f96740f1 Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Thu, 18 Jan 2024 15:50:11 +0300 Subject: [PATCH 13/14] fix: more review fixes --- src/components/TreeSelect/TreeSelect.tsx | 8 ++++---- .../components/RenderVirtualizedContainer.tsx | 4 ++-- .../components/WithDndListExample.tsx | 6 +++--- .../__stories__/components/FlattenList.tsx | 4 ++-- .../__stories__/components/ListWithDnd.tsx | 2 +- .../components/PopupWithTogglerList.tsx | 2 +- src/components/useList/__stories__/useList.mdx | 12 ++++++------ src/components/useList/hooks/useList.ts | 4 ++-- src/components/useList/hooks/useListFilter.ts | 12 ++++++------ .../useList/hooks/useListKeydown.tsx | 18 +++++++++--------- src/components/useList/types.ts | 2 +- .../useList/utils/getItemRenderState.tsx | 4 ++-- 12 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index 802d96c441..db5817ae45 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -138,8 +138,8 @@ export const TreeSelect = React.forwardRef(function TreeSelect( id, isGroup: id in listParsedState.groupsState, isLastItem: - listParsedState.existedFlattenIds[ - listParsedState.existedFlattenIds.length - 1 + listParsedState.visibleFlattenIds[ + listParsedState.visibleFlattenIds.length - 1 ] === id, disabled: listState.disabledById[id], }); @@ -151,7 +151,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( onItemClick, listState, listParsedState.groupsState, - listParsedState.existedFlattenIds, + listParsedState.visibleFlattenIds, groupsBehavior, multiple, handleMultipleSelection, @@ -166,7 +166,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( const lastSelectedItemId = value[value.length - 1]; containerRef.current?.focus(); - const firstItemId = listParsedState.existedFlattenIds[0]; + const firstItemId = listParsedState.visibleFlattenIds[0]; listState.setActiveItemId(lastSelectedItemId ?? firstItemId); diff --git a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx index 78ab4028b6..c3b80e6f97 100644 --- a/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx +++ b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx @@ -8,14 +8,14 @@ import type {RenderContainerProps} from '../../types'; export const RenderVirtualizedContainer = ({ id, containerRef, - existedFlattenIds, + visibleFlattenIds, renderItem, size, }: RenderContainerProps) => { return ( computeItemSize(size)} > {renderItem} diff --git a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx index 71fef24abe..86b0aac1c9 100644 --- a/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx +++ b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx @@ -61,7 +61,7 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { setValue([id]); } }} - renderContainer={({renderItem, existedFlattenIds, containerRef, id}) => { + renderContainer={({renderItem, visibleFlattenIds, containerRef, id}) => { return ( { snapshot: DraggableStateSnapshot, rubric: DraggableRubric, ) => { - return renderItem(existedFlattenIds[rubric.source.index], { + return renderItem(visibleFlattenIds[rubric.source.index], { provided, active: snapshot.isDragging, }); @@ -83,7 +83,7 @@ export const WithDndListExample = (props: WithDndListExampleProps) => { {...droppableProvided.droppableProps} ref={droppableProvided.innerRef} > - {existedFlattenIds.map((id) => renderItem(id))} + {visibleFlattenIds.map((id) => renderItem(id))} {droppableProvided.placeholder} diff --git a/src/components/useList/__stories__/components/FlattenList.tsx b/src/components/useList/__stories__/components/FlattenList.tsx index 6f82f66564..daa5c3014e 100644 --- a/src/components/useList/__stories__/components/FlattenList.tsx +++ b/src/components/useList/__stories__/components/FlattenList.tsx @@ -75,11 +75,11 @@ export const FlattenList = ({itemsCount, size}: FlattenListProps) => { computeItemSize( size, - Boolean(get(list.itemsById[list.existedFlattenIds[index]], 'subtitle')), + Boolean(get(list.itemsById[list.visibleFlattenIds[index]], 'subtitle')), ) } > diff --git a/src/components/useList/__stories__/components/ListWithDnd.tsx b/src/components/useList/__stories__/components/ListWithDnd.tsx index 551e674fe0..e65dc44991 100644 --- a/src/components/useList/__stories__/components/ListWithDnd.tsx +++ b/src/components/useList/__stories__/components/ListWithDnd.tsx @@ -90,7 +90,7 @@ export const ListWithDnd = ({size, itemsCount}: ListWithDndProps) => { {(droppableProvided: DroppableProvided) => (
      - {list.existedFlattenIds.map((id, index) => { + {list.visibleFlattenIds.map((id, index) => { const {data, props} = getItemRenderState({ id, size, diff --git a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx index f526edccf6..d228ae4eee 100644 --- a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -47,7 +47,7 @@ export const PopupWithTogglerList = ({size, itemsCount}: PopupWithTogglerListPro React.useLayoutEffect(() => { if (open) { containerRef.current?.focus(); - listState.setActiveItemId(selectedId ?? list.existedFlattenIds[0]); + listState.setActiveItemId(selectedId ?? list.visibleFlattenIds[0]); if (selectedId) { scrollToListItem(selectedId, containerRef.current); diff --git a/src/components/useList/__stories__/useList.mdx b/src/components/useList/__stories__/useList.mdx index 09a5b34520..0e7afaf839 100644 --- a/src/components/useList/__stories__/useList.mdx +++ b/src/components/useList/__stories__/useList.mdx @@ -176,7 +176,7 @@ interface ListTreeItemType extends ListItemInitialProps { export type ListItemType = ListTreeItemType | ListFlattenItemType; ``` -- `expandedById` - state for open/closed `List` elements. Affects the formation of the `existedFlattenIds` - if the element id in this object is set to `false` - all elements of this group and all nested groups will not be present in the final ids order; +- `expandedById` - state for open/closed `List` elements. Affects the formation of the `visibleFlattenIds` - if the element id in this object is set to `false` - all elements of this group and all nested groups will not be present in the final ids order; - `getId` - the property is optional. Allows you to generate an id for a list item depending on the list data: ```tsx @@ -230,7 +230,7 @@ const {byid} = useList({ - `groupsState` - a normalized representation of metadata about a group if the item is both a list item and a group: - `childrenIds` - list of child element IDs; -- `existedFlattenIds` - sequential representation of list items by id, taking into account invisible elements inside collapsed groups; +- `visibleFlattenIds` - sequential representation of list items by id, taking into account invisible elements inside collapsed groups; ### useListKeydown @@ -240,7 +240,7 @@ Keyboard support - `disabledById` - key-value representation of disabled elements that do not need to be taken into account when navigating through the `List`; - `activeItemId` - current active item `id`; -- `existedFlattenIds` - a flat list of elements to be navigated through; Collapsed groups must be taken into account in this array; +- `visibleFlattenIds` - a flat list of elements to be navigated through; Collapsed groups must be taken into account in this array; - `onItemClick` - callback will be called when pressing the `Enter`, `Space` keys; - `containerRef` - a reference to the DOM element of the List container inside which to search for its elements; - `setActiveItemId` - Callback for setting the current active element; @@ -437,13 +437,13 @@ Utility to compute list item height: ```tsx computeItemSize( // list size size, // has subrows - Boolean(get(itemsById[existedFlattenIds[index]], 'subtitle')), + Boolean(get(itemsById[visibleFlattenIds[index]], 'subtitle')), ) } /> @@ -459,7 +459,7 @@ const containerRef = React.useRef(null); React.useLayoutEffect(() => { if (open) { containerRef.current?.focus(); - listState.setActiveItemId(selectedId ?? list.existedFlattenIds[0]); + listState.setActiveItemId(selectedId ?? list.visibleFlattenIds[0]); if (selectedId) { scrollToListItem(selectedId, containerRef.current); diff --git a/src/components/useList/hooks/useList.ts b/src/components/useList/hooks/useList.ts index 8900035edd..7e6f184a4f 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 existedFlattenIds = useFlattenListItems({ + const visibleFlattenIds = 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, existedFlattenIds, itemsById, groupsState, itemsState}; + return {items, visibleFlattenIds, itemsById, groupsState, itemsState}; }; diff --git a/src/components/useList/hooks/useListFilter.ts b/src/components/useList/hooks/useListFilter.ts index 4a6d06d8fa..3c9e853b6a 100644 --- a/src/components/useList/hooks/useListFilter.ts +++ b/src/components/useList/hooks/useListFilter.ts @@ -70,12 +70,12 @@ export function useListFilter({ setPrevItems(externalItems); } - const {onFilterUpdate, reset} = React.useMemo(() => { - const debouncedFn = debounce( - (value) => setItems(filterItemsFn(value, externalItems)), - debounceTimeout, - ); + const debouncedFn = React.useCallback( + debounce((value) => setItems(filterItemsFn(value, externalItems)), debounceTimeout), + [setItems, filterItemsFn, debounceTimeout], + ); + const {onFilterUpdate, reset} = React.useMemo(() => { return { reset: () => { setFilter(initialFilterValue); @@ -86,7 +86,7 @@ export function useListFilter({ debouncedFn(nextFilterValue); }, }; - }, [debounceTimeout, externalItems, filterItemsFn, initialFilterValue]); + }, [debouncedFn, initialFilterValue]); return { filterRef, diff --git a/src/components/useList/hooks/useListKeydown.tsx b/src/components/useList/hooks/useListKeydown.tsx index fd64905401..802b0f4b42 100644 --- a/src/components/useList/hooks/useListKeydown.tsx +++ b/src/components/useList/hooks/useListKeydown.tsx @@ -6,7 +6,7 @@ import {findNextIndex} from '../utils/findNextIndex'; import {scrollToListItem} from '../utils/scrollToListItem'; interface UseListKeydownProps extends Partial> { - existedFlattenIds: ListItemId[]; + visibleFlattenIds: ListItemId[]; onItemClick?(itemId: ListItemId): void; containerRef?: React.RefObject; setActiveItemId?(id: ListItemId): void; @@ -15,7 +15,7 @@ interface UseListKeydownProps extends Partial { const activateItem = React.useCallback( (index?: number, scrollTo = true) => { - if (typeof index === 'number' && existedFlattenIds[index]) { + if (typeof index === 'number' && visibleFlattenIds[index]) { if (scrollTo) { - scrollToListItem(existedFlattenIds[index], containerRef?.current); + scrollToListItem(visibleFlattenIds[index], containerRef?.current); } - setActiveItemId?.(existedFlattenIds[index]); + setActiveItemId?.(visibleFlattenIds[index]); } }, - [containerRef, existedFlattenIds, setActiveItemId], + [containerRef, visibleFlattenIds, setActiveItemId], ); const handleKeyMove = React.useCallback( (event: KeyboardEvent, step: number, defaultItemIndex = 0) => { event.preventDefault(); - const maybeIndex = existedFlattenIds.findIndex((i) => i === activeItemId); + const maybeIndex = visibleFlattenIds.findIndex((i) => i === activeItemId); const nextIndex = findNextIndex({ - list: existedFlattenIds, + list: visibleFlattenIds, index: (maybeIndex > -1 ? maybeIndex : defaultItemIndex) + step, step: Math.sign(step), disabledItems: disabledById, @@ -51,7 +51,7 @@ export const useListKeydown = ({ activateItem(nextIndex); }, - [activateItem, activeItemId, disabledById, existedFlattenIds], + [activateItem, activeItemId, disabledById, visibleFlattenIds], ); React.useLayoutEffect(() => { diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index b9a113b989..62297c736f 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -99,5 +99,5 @@ export type ListState = { export type ListParsedState = ParsedState & { items: ListItemType[]; - existedFlattenIds: ListItemId[]; + visibleFlattenIds: ListItemId[]; }; diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index 1307031725..ac31332aaa 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -25,7 +25,7 @@ export const getItemRenderState = ( expandedById, groupsState, onItemClick, - existedFlattenIds, + visibleFlattenIds, size = 'm', itemsState, selectedById, @@ -37,7 +37,7 @@ export const getItemRenderState = ( const context: RenderItemContext = { itemState: itemsState[id], groupState: groupsState[id], - isLastItem: id === existedFlattenIds[existedFlattenIds.length - 1], + isLastItem: id === visibleFlattenIds[visibleFlattenIds.length - 1], }; let expanded; From d1e1a250c5cba6544957b0f26b1e828bd5747091 Mon Sep 17 00:00:00 2001 From: Alexandr Isaev Date: Thu, 18 Jan 2024 16:49:01 +0300 Subject: [PATCH 14/14] fix: review fixes --- src/components/TreeSelect/types.ts | 9 ++++----- .../useList/__stories__/components/FlattenList.tsx | 4 ++-- .../__stories__/components/InfinityScrollList.tsx | 4 ++-- .../useList/__stories__/components/ListWithDnd.tsx | 4 ++-- .../__stories__/components/PopupWithTogglerList.tsx | 4 ++-- .../useList/__stories__/components/RecursiveList.tsx | 4 ++-- .../useList/components/ListItemView/ListItemView.scss | 8 ++++---- .../useList/components/ListItemView/ListItemView.tsx | 6 +++--- src/components/useList/types.ts | 4 ++-- src/components/useList/utils/computeItemSize.ts | 4 ++-- src/components/useList/utils/getItemRenderState.tsx | 4 ++-- src/unstable.ts | 7 ++++++- 12 files changed, 33 insertions(+), 29 deletions(-) diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index f3866a6aaf..de0d86cec1 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -4,7 +4,7 @@ import type {QAProps} from '../types'; import type { KnownItemStructure, ListItemId, - ListItemSizeType, + ListItemSize, ListItemType, ListParsedState, ListState, @@ -18,7 +18,7 @@ export type RenderControlProps = { toggleOpen(): void; clearValue(): void; ref: React.Ref; - size: ListItemSizeType; + size: ListItemSize; value: ListItemId[]; id: string; activeItemId?: ListItemId; @@ -36,7 +36,7 @@ export type RenderItem = ( export type RenderContainerProps = ListParsedState & ListState & { id: string; - size: ListItemSizeType; + size: ListItemSize; renderItem(id: ListItemId, renderContextProps?: Object): React.JSX.Element; containerRef: React.RefObject; className?: string; @@ -46,7 +46,6 @@ interface TreeSelectBaseProps extends QAProps, Partial[]; open?: boolean; id?: string | undefined; popupClassName?: string; @@ -68,7 +67,7 @@ interface TreeSelectBaseProps extends QAProps, Partial { diff --git a/src/components/useList/__stories__/components/InfinityScrollList.tsx b/src/components/useList/__stories__/components/InfinityScrollList.tsx index 604ffbef33..75a07b5c26 100644 --- a/src/components/useList/__stories__/components/InfinityScrollList.tsx +++ b/src/components/useList/__stories__/components/InfinityScrollList.tsx @@ -11,14 +11,14 @@ import {useList} from '../../hooks/useList'; import {useListFilter} from '../../hooks/useListFilter'; import {useListKeydown} from '../../hooks/useListKeydown'; import {useListState} from '../../hooks/useListState'; -import type {ListItemId, ListItemSizeType} from '../../types'; +import type {ListItemId, ListItemSize} from '../../types'; import {getItemRenderState} from '../../utils/getItemRenderState'; import {useInfinityFetch} from '../utils/useInfinityFetch'; import {IntersectionContainer} from './IntersectionContainer/IntersectionContainer'; export interface InfinityScrollListProps { - size: ListItemSizeType; + size: ListItemSize; } export const InfinityScrollList = ({size}: InfinityScrollListProps) => { diff --git a/src/components/useList/__stories__/components/ListWithDnd.tsx b/src/components/useList/__stories__/components/ListWithDnd.tsx index e65dc44991..2e82c5acd2 100644 --- a/src/components/useList/__stories__/components/ListWithDnd.tsx +++ b/src/components/useList/__stories__/components/ListWithDnd.tsx @@ -19,14 +19,14 @@ import {useList} from '../../hooks/useList'; import {useListFilter} from '../../hooks/useListFilter'; import {useListKeydown} from '../../hooks/useListKeydown'; import {useListState} from '../../hooks/useListState'; -import type {ListItemId, ListItemSizeType} from '../../types'; +import type {ListItemId, ListItemSize} from '../../types'; import {getItemRenderState} from '../../utils/getItemRenderState'; import {createRandomizedData} from '../utils/makeData'; import {reorderArray} from '../utils/reorderArray'; export interface ListWithDndProps { itemsCount: number; - size: ListItemSizeType; + size: ListItemSize; } 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 d228ae4eee..c64c9480e8 100644 --- a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -9,14 +9,14 @@ import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ import {useList} from '../../hooks/useList'; import {useListKeydown} from '../../hooks/useListKeydown'; import {useListState} from '../../hooks/useListState'; -import type {ListItemId, ListItemSizeType} from '../../types'; +import type {ListItemId, ListItemSize} from '../../types'; import {getItemRenderState} from '../../utils/getItemRenderState'; import {scrollToListItem} from '../../utils/scrollToListItem'; import {createRandomizedData} from '../utils/makeData'; export interface PopupWithTogglerListProps { itemsCount: number; - size: ListItemSizeType; + size: ListItemSize; } const COMPONENT_WIDTH = 300; diff --git a/src/components/useList/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx index 2d76e88d22..b63ae4f048 100644 --- a/src/components/useList/__stories__/components/RecursiveList.tsx +++ b/src/components/useList/__stories__/components/RecursiveList.tsx @@ -9,13 +9,13 @@ import {useList} from '../../hooks/useList'; import {useListFilter} from '../../hooks/useListFilter'; import {useListKeydown} from '../../hooks/useListKeydown'; import {useListState} from '../../hooks/useListState'; -import type {ListItemId, ListItemSizeType} from '../../types'; +import type {ListItemId, ListItemSize} from '../../types'; import {getItemRenderState} from '../../utils/getItemRenderState'; import {createRandomizedData} from '../utils/makeData'; export interface RecursiveListProps { itemsCount: number; - size: ListItemSizeType; + size: ListItemSize; } export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { diff --git a/src/components/useList/components/ListItemView/ListItemView.scss b/src/components/useList/components/ListItemView/ListItemView.scss index 5e4aa8b143..9eefa73fed 100644 --- a/src/components/useList/components/ListItemView/ListItemView.scss +++ b/src/components/useList/components/ListItemView/ListItemView.scss @@ -21,16 +21,16 @@ $block: '.#{variables.$ns}list-item-view'; } &_radius_s { - border-radius: var(--g-list-item-border-radius-s, 3px); + border-radius: var(--g-list-item-border-radius, 3px); } &_radius_m { - border-radius: var(--g-list-item-border-radius-m, 6px); + border-radius: var(--g-list-item-border-radius, 6px); } &_radius_l { - border-radius: var(--g-list-item-border-radius-l, 8px); + border-radius: var(--g-list-item-border-radius, 8px); } &_radius_xl { - border-radius: var(--g-list-item-border-radius-xl, 8px); + border-radius: var(--g-list-item-border-radius, 8px); } &__slot { diff --git a/src/components/useList/components/ListItemView/ListItemView.tsx b/src/components/useList/components/ListItemView/ListItemView.tsx index 53acbe1ab9..6056544ec8 100644 --- a/src/components/useList/components/ListItemView/ListItemView.tsx +++ b/src/components/useList/components/ListItemView/ListItemView.tsx @@ -8,7 +8,7 @@ import {Flex, FlexProps, spacing} from '../../../layout'; import type {QAProps} from '../../../types'; import {block} from '../../../utils/cn'; import {LIST_ITEM_DATA_ATR, modToHeight} from '../../constants'; -import type {ListItemId, ListItemSizeType} from '../../types'; +import type {ListItemId, ListItemSize} from '../../types'; import './ListItemView.scss'; @@ -22,7 +22,7 @@ export interface ListItemViewProps extends QAProps { /** * @default `m` */ - size?: ListItemSizeType; + size?: ListItemSize; height?: number; selected?: boolean; active?: boolean; @@ -97,7 +97,7 @@ export const ListItemView = React.forwardRef( height, expanded, style, - role = 'listitem', + role = 'option', onClick: _onClick, ...rest }: ListItemViewProps, diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index 62297c736f..34615229ea 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -1,6 +1,6 @@ export type ListItemId = string; -export type ListItemSizeType = 's' | 'm' | 'l' | 'xl'; +export type ListItemSize = 's' | 'm' | 'l' | 'xl'; interface ListItemInitialProps { /** * If you need to control the state from the outside, @@ -63,7 +63,7 @@ export type RenderItemContext = { }; export type RenderItemState = { - size: ListItemSizeType; + size: ListItemSize; id: ListItemId; onClick?(): void; selected: boolean; diff --git a/src/components/useList/utils/computeItemSize.ts b/src/components/useList/utils/computeItemSize.ts index 23b1544088..174494eaa3 100644 --- a/src/components/useList/utils/computeItemSize.ts +++ b/src/components/useList/utils/computeItemSize.ts @@ -1,6 +1,6 @@ import {modToHeight} from '../constants'; -import type {ListItemSizeType} from '../types'; +import type {ListItemSize} from '../types'; -export const computeItemSize = (size: ListItemSizeType, hasSubRows = false) => { +export const computeItemSize = (size: ListItemSize, hasSubRows = false) => { return modToHeight[size][Number(hasSubRows)]; }; diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index ac31332aaa..5bd07bdc4d 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -1,7 +1,7 @@ /* eslint-disable valid-jsdoc */ import type { ListItemId, - ListItemSizeType, + ListItemSize, ListParsedState, ListState, RenderItemContext, @@ -10,7 +10,7 @@ import type { type ItemRendererProps = ListState & ListParsedState & { - size?: ListItemSizeType; + size?: ListItemSize; id: ListItemId; onItemClick?(id: ListItemId): void; }; diff --git a/src/unstable.ts b/src/unstable.ts index 7d0b8d5a49..9b26891240 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -5,4 +5,9 @@ export { useListFilter as unstable_useListFilter, useListKeydown as unstable_useListKeydown, } from './components/useList'; -export * from './components/TreeSelect'; +export { + TreeSelect as unstable_TreeSelect, + TreeSelectProps as unstable_TreeSelectProps, + TreeSelectItem as unstable_TreeSelectItem, + TreeSelectItemProps as unstable_TreeSelectItemProps, +} from './components/TreeSelect';