diff --git a/package.json b/package.json index 47fd68e8fc..d2bc09f543 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/components/layout/Flex/Flex.tsx b/src/components/layout/Flex/Flex.tsx index 9bf7b021be..b5a67bf689 100644 --- a/src/components/layout/Flex/Flex.tsx +++ b/src/components/layout/Flex/Flex.tsx @@ -10,7 +10,9 @@ import './Flex.scss'; const b = block('flex'); -export interface FlexProps extends QAProps { +export interface FlexProps + extends QAProps, + React.HTMLAttributes { as?: T; /** * `flex-direction` property @@ -83,6 +85,7 @@ export interface FlexProps extends QAProps className?: string; title?: string; ref?: React.ComponentPropsWithRef['ref']; + onClick?(e: React.MouseEvent): void; } /** @@ -124,7 +127,7 @@ export const Flex = React.forwardRef(function Flex['ref'], ) { const { - as: Tag = 'div', + as, direction, width, grow, @@ -148,6 +151,7 @@ export const Flex = React.forwardRef(function Flex { 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 a8db79e2af..a8019f1633 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';