From 36893b3b7c643b6b023418c9d0466a4364ee82f2 Mon Sep 17 00:00:00 2001 From: Isaev Alexandr Date: Mon, 22 Jan 2024 17:38:31 +0300 Subject: [PATCH] feat(TreeSelect): added TreeSelect unstable component and new list hooks (#1090) Co-authored-by: Alexandr Isaev --- package.json | 8 + src/components/Select/types.ts | 2 +- src/components/TreeSelect/TreeSelect.scss | 18 + src/components/TreeSelect/TreeSelect.tsx | 306 ++++++++++ .../TreeSelectItem/TreeSelectItem.scss | 7 + .../TreeSelectItem/TreeSelectItem.tsx | 26 + .../TreeSelect/TreeSelectItem/index.ts | 1 + .../__stories__/TreeSelect.stories.tsx | 109 ++++ .../components/InfinityScrollExample.tsx | 73 +++ .../components/RenderVirtualizedContainer.tsx | 25 + .../components/WithDndListExample.tsx | 131 +++++ .../WithFiltrationAndControlsExample.tsx | 98 ++++ ...pSelectionControlledStateAndCustomIcon.tsx | 95 +++ .../WithItemLinksAndActionsExample.tsx | 110 ++++ .../TreeListContainer/TreeListContainer.tsx | 30 + .../hooks/useTreeSelectSelection.ts | 99 ++++ src/components/TreeSelect/index.ts | 3 + src/components/TreeSelect/types.ts | 115 ++++ .../__stories__/DndExample.stories.tsx | 32 ++ .../ListInfinityScroll.stories.tsx | 22 + .../__stories__/PopupWithToggler.stories.tsx | 25 + .../__stories__/RecursiveRenderer.stories.tsx | 27 + .../__stories__/VirtualizedList.stories.tsx | 27 + .../__stories__/components/FlattenList.tsx | 106 ++++ .../components/InfinityScrollList.tsx | 144 +++++ .../IntersectionContainer.tsx | 21 + .../__stories__/components/ListWithDnd.tsx | 133 +++++ .../components/PopupWithTogglerList.tsx | 131 +++++ .../__stories__/components/RecursiveList.tsx | 105 ++++ .../VirtualizedListContainer.async.tsx | 26 + .../VirtualizedListContainer.tsx | 37 ++ .../VirtualizedListContainer/index.ts | 1 + .../VirtualizedListContainer/types.ts | 10 + .../useList/__stories__/useList.mdx | 539 ++++++++++++++++++ .../useList/__stories__/utils/makeData.ts | 52 ++ .../useList/__stories__/utils/reorderArray.ts | 11 + .../__stories__/utils/useInfinityFetch.ts | 55 ++ .../ListContainerView/ListContainerView.scss | 17 + .../ListContainerView/ListContainerView.tsx | 49 ++ .../components/ListItemView/ListItemView.scss | 68 +++ .../components/ListItemView/ListItemView.tsx | 184 ++++++ .../__stories__/ListItemView.stories.tsx | 115 ++++ .../ListRecursiveRenderer.scss | 8 + .../ListRecursiveRenderer.tsx | 57 ++ src/components/useList/constants.ts | 10 + .../useList/hooks/useFlattenListItems.ts | 24 + src/components/useList/hooks/useList.ts | 36 ++ src/components/useList/hooks/useListFilter.ts | 98 ++++ .../useList/hooks/useListKeydown.tsx | 94 +++ .../useList/hooks/useListParsedState.ts | 25 + src/components/useList/hooks/useListState.ts | 30 + src/components/useList/index.ts | 14 + src/components/useList/types.ts | 103 ++++ src/components/useList/utils.ts | 5 + .../useList/utils/computeItemSize.ts | 6 + .../useList/utils/defaultFilterItems.test.ts | 49 ++ .../useList/utils/defaultFilterItems.ts | 38 ++ src/components/useList/utils/findNextIndex.ts | 20 + .../useList/utils/flattenItems.test.ts | 63 ++ src/components/useList/utils/flattenItems.ts | 48 ++ .../useList/utils/getItemRenderState.tsx | 62 ++ src/components/useList/utils/getListItemId.ts | 21 + .../useList/utils/getListParsedState.test.ts | 181 ++++++ .../useList/utils/getListParsedState.ts | 145 +++++ src/components/useList/utils/groupItemId.ts | 7 + .../useList/utils/isTreeItemGuard.ts | 5 + .../useList/utils/scrollToListItem.ts | 19 + src/hooks/useSelect/useOpenState.ts | 8 +- src/hooks/useSelect/useSelect.ts | 19 +- src/unstable.ts | 13 + 70 files changed, 4395 insertions(+), 6 deletions(-) create mode 100644 src/components/TreeSelect/TreeSelect.scss create mode 100644 src/components/TreeSelect/TreeSelect.tsx create mode 100644 src/components/TreeSelect/TreeSelectItem/TreeSelectItem.scss create mode 100644 src/components/TreeSelect/TreeSelectItem/TreeSelectItem.tsx create mode 100644 src/components/TreeSelect/TreeSelectItem/index.ts create mode 100644 src/components/TreeSelect/__stories__/TreeSelect.stories.tsx 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/TreeSelect/hooks/useTreeSelectSelection.ts create mode 100644 src/components/TreeSelect/index.ts create mode 100644 src/components/TreeSelect/types.ts create mode 100644 src/components/useList/__stories__/DndExample.stories.tsx create mode 100644 src/components/useList/__stories__/ListInfinityScroll.stories.tsx create mode 100644 src/components/useList/__stories__/PopupWithToggler.stories.tsx create mode 100644 src/components/useList/__stories__/RecursiveRenderer.stories.tsx create mode 100644 src/components/useList/__stories__/VirtualizedList.stories.tsx create mode 100644 src/components/useList/__stories__/components/FlattenList.tsx create mode 100644 src/components/useList/__stories__/components/InfinityScrollList.tsx create mode 100644 src/components/useList/__stories__/components/IntersectionContainer/IntersectionContainer.tsx create mode 100644 src/components/useList/__stories__/components/ListWithDnd.tsx create mode 100644 src/components/useList/__stories__/components/PopupWithTogglerList.tsx create mode 100644 src/components/useList/__stories__/components/RecursiveList.tsx create mode 100644 src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx create mode 100644 src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx create mode 100644 src/components/useList/__stories__/components/VirtualizedListContainer/index.ts create mode 100644 src/components/useList/__stories__/components/VirtualizedListContainer/types.ts create mode 100644 src/components/useList/__stories__/useList.mdx create mode 100644 src/components/useList/__stories__/utils/makeData.ts create mode 100644 src/components/useList/__stories__/utils/reorderArray.ts create mode 100644 src/components/useList/__stories__/utils/useInfinityFetch.ts create mode 100644 src/components/useList/components/ListContainerView/ListContainerView.scss create mode 100644 src/components/useList/components/ListContainerView/ListContainerView.tsx create mode 100644 src/components/useList/components/ListItemView/ListItemView.scss create mode 100644 src/components/useList/components/ListItemView/ListItemView.tsx create mode 100644 src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx create mode 100644 src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.scss create mode 100644 src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx create mode 100644 src/components/useList/constants.ts create mode 100644 src/components/useList/hooks/useFlattenListItems.ts create mode 100644 src/components/useList/hooks/useList.ts create mode 100644 src/components/useList/hooks/useListFilter.ts create mode 100644 src/components/useList/hooks/useListKeydown.tsx create mode 100644 src/components/useList/hooks/useListParsedState.ts create mode 100644 src/components/useList/hooks/useListState.ts create mode 100644 src/components/useList/index.ts create mode 100644 src/components/useList/types.ts create mode 100644 src/components/useList/utils.ts create mode 100644 src/components/useList/utils/computeItemSize.ts create mode 100644 src/components/useList/utils/defaultFilterItems.test.ts create mode 100644 src/components/useList/utils/defaultFilterItems.ts create mode 100644 src/components/useList/utils/findNextIndex.ts create mode 100644 src/components/useList/utils/flattenItems.test.ts create mode 100644 src/components/useList/utils/flattenItems.ts create mode 100644 src/components/useList/utils/getItemRenderState.tsx create mode 100644 src/components/useList/utils/getListItemId.ts create mode 100644 src/components/useList/utils/getListParsedState.test.ts create mode 100644 src/components/useList/utils/getListParsedState.ts create mode 100644 src/components/useList/utils/groupItemId.ts create mode 100644 src/components/useList/utils/isTreeItemGuard.ts create mode 100644 src/components/useList/utils/scrollToListItem.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/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..35e38f63fc --- /dev/null +++ b/src/components/TreeSelect/TreeSelect.scss @@ -0,0 +1,18 @@ +@use '../variables'; + +$block: '.#{variables.$ns}tree-select'; + +#{$block} { + max-width: 100%; + + &_width_max { + width: 100%; + } + + &__popup { + padding: 4px 0; + border-radius: 6px; + 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..db5817ae45 --- /dev/null +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -0,0 +1,306 @@ +import React from 'react'; + +import {useForkRef, useUniqId} from '../../hooks'; +import {SelectControl} from '../Select/components'; +import {SelectPopup} from '../Select/components/SelectPopup/SelectPopup'; +import {Flex} from '../layout'; +import {useMobile} from '../mobile'; +import { + type ListItemId, + getItemRenderState, + isKnownStructureGuard, + scrollToListItem, + useList, + useListKeydown, + useListState, +} from '../useList'; +import {type CnMods, block} from '../utils/cn'; + +import {TreeSelectItem} from './TreeSelectItem'; +import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; +import {useTreeSelectSelection, useValue} from './hooks/useTreeSelectSelection'; +import type {RenderControlProps, TreeSelectProps} from './types'; + +import './TreeSelect.scss'; + +const b = block('tree-select'); + +export const TreeSelect = React.forwardRef(function TreeSelect( + props: TreeSelectProps, + ref: React.Ref, +) { + const { + id, + slotBeforeListBody, + slotAfterListBody, + size = 'm', + items, + defaultOpen, + className, + width, + popupClassName, + open: propsOpen, + multiple, + popupWidth, + expandedById, + disabledById, + activeItemId, + defaultValue, + popupDisablePortal, + groupsBehavior = 'expandable', + value: propsValue, + onClose, + onUpdate, + getId, + onOpenChange, + renderControl, + renderItem, + renderContainer: RenderContainer = TreeListContainer, + onItemClick, + } = props; + + 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 {value, setInnerValue, selected} = useValue({ + value: propsValue, + defaultValue, + }); + + const listState = useListState({ + expandedById, + disabledById, + activeItemId, + selectedById: selected, + }); + + const listParsedState = useList({ + items, + getId, + ...listState, + }); + + const wrappedOnUpdate = React.useCallback( + (ids: ListItemId[]) => + onUpdate?.( + ids, + ids.map((id) => listParsedState.itemsById[id]), + ), + [listParsedState.itemsById, 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 + 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') { + listState.setExpanded((state) => ({ + ...state, + // toggle expanded state by id, 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.visibleFlattenIds[ + listParsedState.visibleFlattenIds.length - 1 + ] === id, + disabled: listState.disabledById[id], + }); + } + + return defaultHandleClick(); + }, + [ + onItemClick, + listState, + listParsedState.groupsState, + listParsedState.visibleFlattenIds, + groupsBehavior, + multiple, + handleMultipleSelection, + handleSingleSelection, + toggleOpen, + ], + ); + + // restoring focus when popup opens + React.useLayoutEffect(() => { + if (open) { + const lastSelectedItemId = value[value.length - 1]; + containerRef.current?.focus(); + + const firstItemId = listParsedState.visibleFlattenIds[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, + onItemClick: handleItemClick, + ...listParsedState, + ...listState, + }); + + const handleClose = React.useCallback(() => toggleOpen(false), [toggleOpen]); + + const controlProps: RenderControlProps = { + open, + toggleOpen, + clearValue: handleClearValue, + ref: handleControlRef, + size, + value, + id: treeSelectId, + activeItemId: listState.activeItemId, + }; + + const togglerNode = renderControl ? ( + renderControl(controlProps) + ) : ( + { + if ('renderControlContent' in props) { + return props.renderControlContent(listParsedState.itemsById[id]).title; + } + + const items = listParsedState.itemsById[id]; + + if (isKnownStructureGuard(items)) { + return items.title; + } + + return items as string; + }), + ).join(', ')} + view="normal" + pin="round-round" + popupId={`tree-select-popup-${treeSelectId}`} + selectId={`tree-select-${treeSelectId}`} + /> + ); + + const mods: CnMods = { + ...(width === 'max' && {width}), + }; + + const inlineStyles: React.CSSProperties = {}; + + if (typeof width === 'number') { + inlineStyles.width = width; + } + + return ( + + {togglerNode} + + {slotBeforeListBody} + { + const renderState = getItemRenderState({ + id, + size, + onItemClick: handleItemClick, + ...listParsedState, + ...listState, + }); + + // assign components scope logic + renderState.props.hasSelectionIcon = Boolean(multiple); + + if (renderItem) { + return renderItem( + renderState.data, + renderState.props, + renderState.context, + renderContextProps, + ); + } + + const itemData = listParsedState.itemsById[id]; + + return ( + + ); + }} + /> + {slotAfterListBody} + + + ); +}) as (props: TreeSelectProps & {ref?: React.Ref}) => React.ReactElement; diff --git a/src/components/TreeSelect/TreeSelectItem/TreeSelectItem.scss b/src/components/TreeSelect/TreeSelectItem/TreeSelectItem.scss new file mode 100644 index 0000000000..3050061dff --- /dev/null +++ b/src/components/TreeSelect/TreeSelectItem/TreeSelectItem.scss @@ -0,0 +1,7 @@ +@use '../../variables'; + +$block: '.#{variables.$ns}tree-select-item'; + +#{$block} { + padding: 0 4px; +} diff --git a/src/components/TreeSelect/TreeSelectItem/TreeSelectItem.tsx b/src/components/TreeSelect/TreeSelectItem/TreeSelectItem.tsx new file mode 100644 index 0000000000..2bdf467c4f --- /dev/null +++ b/src/components/TreeSelect/TreeSelectItem/TreeSelectItem.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import {ListItemView, ListItemViewProps} from '../../useList'; +import {block} from '../../utils/cn'; + +import './TreeSelectItem.scss'; + +const b = block('tree-select-item'); + +export interface TreeSelectItemProps extends Omit { + as?: 'div' | 'li'; + itemClassName?: string; +} + +export const TreeSelectItem = React.forwardRef(function TreeSelectItem( + {as = 'div', className, itemClassName, ...props}: TreeSelectItemProps, + ref?: any, +) { + const Tag: React.ElementType = as; + + return ( + + + + ); +}); diff --git a/src/components/TreeSelect/TreeSelectItem/index.ts b/src/components/TreeSelect/TreeSelectItem/index.ts new file mode 100644 index 0000000000..8bde947111 --- /dev/null +++ b/src/components/TreeSelect/TreeSelectItem/index.ts @@ -0,0 +1 @@ +export * from './TreeSelectItem'; diff --git a/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx new file mode 100644 index 0000000000..c705c8248f --- /dev/null +++ b/src/components/TreeSelect/__stories__/TreeSelect.stories.tsx @@ -0,0 +1,109 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {Flex} from '../../layout'; +import {createRandomizedData} from '../../useList/__stories__/utils/makeData'; +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 DefaultTemplate: StoryFn< + Omit< + TreeSelectProps<{title: string}>, + 'value' | 'onUpdate' | 'items' | 'renderControlContent' + > & { + itemsCount?: number; + } +> = ({itemsCount = 5, ...props}) => { + const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); + + return ( + + + console.log('Uncontrolled `TreeSelect onUpdate args: `', ...args) + } + /> + + ); +}; +export const Default = DefaultTemplate.bind({}); +Default.args = { + size: 'm', +}; + +const WithGroupSelectionControlledStateAndCustomIconTemplate: StoryFn< + WithGroupSelectionControlledStateAndCustomIconExampleProps +> = (props) => { + return ; +}; + +export const WithGroupSelectionControlledStateAndCustomIcon = + WithGroupSelectionControlledStateAndCustomIconTemplate.bind({}); +WithGroupSelectionControlledStateAndCustomIcon.args = { + groupsBehavior: 'selectable', +}; + +const InfinityScrollTemplate: StoryFn = (props) => { + return ; +}; +export const InfinityScroll = InfinityScrollTemplate.bind({}); +InfinityScroll.args = { + size: 'm', + multiple: true, +}; + +const WithFiltrationAndControlsTemplate: StoryFn = ( + props, +) => { + return ; +}; +export const WithFiltrationAndControls = WithFiltrationAndControlsTemplate.bind({}); +WithFiltrationAndControls.args = { + size: 'l', +}; + +const WithItemLinksAndActionsTemplate: StoryFn = (props) => { + return ; +}; +export const WithItemLinksAndActions = WithItemLinksAndActionsTemplate.bind({}); +WithItemLinksAndActions.args = {}; + +const WithDndListTemplate: StoryFn = (props) => { + return ; +}; +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..5cfee9dddd --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -0,0 +1,73 @@ +import React from 'react'; + +import {Label} from '../../../Label'; +import {Loader} from '../../../Loader'; +import {Flex, spacing} from '../../../layout'; +import {IntersectionContainer} from '../../../useList/__stories__/components/IntersectionContainer/IntersectionContainer'; +import {useInfinityFetch} from '../../../useList/__stories__/utils/useInfinityFetch'; +import {TreeSelect} from '../../TreeSelect'; +import {TreeSelectItem} from '../../TreeSelectItem'; +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 ( + + + {...props} + items={data} + value={value} + renderItem={(item, state, {isLastItem, groupState}) => { + const node = ( + {groupState.childrenIds.length} + ) : undefined + } + /> + ); + + if (isLastItem) { + return ( + + {node} + + ); + } + + return node; + }} + renderContainer={RenderVirtualizedContainer} + 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..c3b80e6f97 --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/RenderVirtualizedContainer.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import {ListContainerView, computeItemSize} from '../../../useList'; +import {VirtualizedListContainer} from '../../../useList/__stories__/components/VirtualizedListContainer'; +import type {RenderContainerProps} from '../../types'; + +// custom container renderer example +export const RenderVirtualizedContainer = ({ + id, + containerRef, + 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 new file mode 100644 index 0000000000..86b0aac1c9 --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/WithDndListExample.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +import {Grip} from '@gravity-ui/icons'; +import { + DragDropContext, + Draggable, + DraggableProvided, + DraggableRubric, + DraggableStateSnapshot, + Droppable, + DroppableProvided, + OnDragEndResponder, +} from 'react-beautiful-dnd'; + +import {Icon} from '../../../Icon'; +import {Flex} from '../../../layout'; +import {ListContainerView} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {reorderArray} from '../../../useList/__stories__/utils/reorderArray'; +import {TreeSelect} from '../../TreeSelect'; +import {TreeSelectItem, TreeSelectItemProps} from '../../TreeSelectItem'; +import type {TreeSelectProps} from '../../types'; + +const DraggableListItem = ({ + provided, + ...props +}: {provided?: DraggableProvided} & TreeSelectItemProps) => { + return ( + + ); +}; + +export interface WithDndListExampleProps + extends Omit, 'value' | 'onUpdate' | 'items' | 'getItemContent'> {} + +export const WithDndListExample = (props: WithDndListExampleProps) => { + const [items, setItems] = React.useState(() => + createRandomizedData({num: 10, depth: 0, getData: (title) => title}), + ); + 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, visibleFlattenIds, containerRef, id}) => { + return ( + + { + return renderItem(visibleFlattenIds[rubric.source.index], { + provided, + active: snapshot.isDragging, + }); + }} + > + {(droppableProvided: DroppableProvided) => ( + +
+ {visibleFlattenIds.map((id) => renderItem(id))} + {droppableProvided.placeholder} +
+
+ )} +
+
+ ); + }} + renderItem={(item, state, _listContext, renderContextProps) => { + const commonProps = { + ...state, + title: 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..0353d4f837 --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/WithFiltrationAndControlsExample.tsx @@ -0,0 +1,98 @@ +import React from 'react'; + +import {Button} from '../../../Button'; +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 {RenderContainerProps, 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, 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}); + + return ( + + + } + renderContainer={renderContainer} + slotAfterListBody={ + + + + + } + value={value} + 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..c601247f7d --- /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 {Button} from '../../../Button'; +import {Icon} from '../../../Icon'; +import {Flex, spacing} from '../../../layout'; +import {type KnownItemStructure, ListItemId, getListParsedState} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {TreeSelect} from '../../TreeSelect'; +import {TreeSelectItem} from '../../TreeSelectItem'; +import type {TreeSelectProps} from '../../types'; + +/** + * Just for example how to work with data + */ +interface CustomDataStructure { + a: string; +} + +export interface WithGroupSelectionControlledStateAndCustomIconExampleProps + extends Omit< + 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, getData: (a) => ({a})}), + [itemsCount], + ); + + const [value, setValue] = React.useState([]); + const [expandedById, setExpanded] = React.useState>( + () => getListParsedState(items).initialState.expandedById, + ); + + 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..d44b9e8ebd --- /dev/null +++ b/src/components/TreeSelect/__stories__/components/WithItemLinksAndActionsExample.tsx @@ -0,0 +1,110 @@ +import React from 'react'; + +import {ChevronDown, ChevronUp, FolderOpen} from '@gravity-ui/icons'; + +import {Button} from '../../../Button'; +import {DropdownMenu} from '../../../DropdownMenu'; +import {Icon} from '../../../Icon'; +import {Flex, spacing} from '../../../layout'; +import type {ListItemId} from '../../../useList'; +import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; +import {TreeSelect} from '../../TreeSelect'; +import {TreeSelectItem} from '../../TreeSelectItem'; +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..b4d8a884d4 --- /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 '../../../useList'; +import {ListItemRecursiveRenderer} from '../../../useList/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/hooks/useTreeSelectSelection.ts b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts new file mode 100644 index 0000000000..a6742927f7 --- /dev/null +++ b/src/components/TreeSelect/hooks/useTreeSelectSelection.ts @@ -0,0 +1,99 @@ +import React from 'react'; + +import type {UseOpenProps} from '../../../hooks/useSelect/types'; +import {useOpenState} from '../../../hooks/useSelect/useOpenState'; +import type {ListItemId} from '../../useList/types'; + +type UseTreeSelectSelectionProps = { + value: ListItemId[]; + setInnerValue?(ids: ListItemId[]): void; + onUpdate?: (value: ListItemId[]) => void; +} & UseOpenProps; + +type UseValueProps = { + value?: ListItemId[]; + defaultValue?: ListItemId[]; +}; + +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, + onUpdate, +}: UseTreeSelectSelectionProps) => { + const {toggleOpen, open} = useOpenState({ + defaultOpen, + onClose, + onOpenChange, + open: openProps, + }); + + const handleSingleSelection = React.useCallback( + (id: ListItemId) => { + if (!value.includes(id)) { + const nextValue = [id]; + onUpdate?.(nextValue); + + setInnerValue?.(nextValue); + } + + toggleOpen(false); + }, + [value, toggleOpen, onUpdate, setInnerValue], + ); + + const handleMultipleSelection = React.useCallback( + (id: ListItemId) => { + const alreadySelected = value.includes(id); + const nextValue = alreadySelected + ? value.filter((iteratedVal) => iteratedVal !== id) + : [...value, id]; + + onUpdate?.(nextValue); + + setInnerValue?.(nextValue); + }, + [value, onUpdate, setInnerValue], + ); + + const handleClearValue = React.useCallback(() => { + onUpdate?.([]); + setInnerValue?.([]); + }, [onUpdate, setInnerValue]); + + return { + open, + toggleOpen, + handleSingleSelection, + handleMultipleSelection, + handleClearValue, + }; +}; diff --git a/src/components/TreeSelect/index.ts b/src/components/TreeSelect/index.ts new file mode 100644 index 0000000000..25188255a9 --- /dev/null +++ b/src/components/TreeSelect/index.ts @@ -0,0 +1,3 @@ +export {TreeSelect} from './TreeSelect'; +export {TreeSelectItem, type TreeSelectItemProps} from './TreeSelectItem'; +export type {TreeSelectProps, RenderItem} from './types'; diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts new file mode 100644 index 0000000000..de0d86cec1 --- /dev/null +++ b/src/components/TreeSelect/types.ts @@ -0,0 +1,115 @@ +import type React from 'react'; + +import type {QAProps} from '../types'; +import type { + KnownItemStructure, + ListItemId, + ListItemSize, + ListItemType, + ListParsedState, + ListState, + OverrideItemContext, + RenderItemContext, + RenderItemState, +} from '../useList'; + +export type RenderControlProps = { + open: boolean; + toggleOpen(): void; + clearValue(): void; + ref: React.Ref; + size: ListItemSize; + value: ListItemId[]; + id: string; + activeItemId?: ListItemId; +}; + +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: ListItemSize; + renderItem(id: ListItemId, renderContextProps?: Object): React.JSX.Element; + containerRef: React.RefObject; + className?: string; + }; + +interface TreeSelectBaseProps extends QAProps, Partial> { + value?: ListItemId[]; + defaultOpen?: boolean; + defaultValue?: ListItemId[]; + open?: boolean; + id?: string | undefined; + popupClassName?: string; + popupWidth?: number; + width?: 'auto' | 'max' | number; + className?: string; + popupDisablePortal?: boolean; + multiple?: boolean; + /** + * 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'; + /** + * List popup has fixes size - 6px. This prop is used to control only list item size view. + * To override popup border radius use `popupClassName` class + */ + size: ListItemSize; + /** + * Use slots if you don't need access to internal TreeListState. + * In other situations use `renderContainer` method + */ + slotBeforeListBody?: React.ReactNode; + /** + * Use slots if you don't need access to internal TreeListState. + * In other situations use `renderContainer` method + */ + slotAfterListBody?: React.ReactNode; + /** + * 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; + /** + * Override list item content by you custom node. + */ + renderItem?: RenderItem; + onClose?(): void; + onUpdate?(value: ListItemId[], selectedItems: T[]): 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); +} + +type TreeSelectKnownProps = TreeSelectBaseProps & { + items: ListItemType[]; +}; +type TreeSelectUnknownProps = TreeSelectBaseProps & { + items: ListItemType[]; + renderControlContent(item: T): KnownItemStructure; +}; + +export type TreeSelectProps = T extends KnownItemStructure | string + ? TreeSelectKnownProps + : TreeSelectUnknownProps; diff --git a/src/components/useList/__stories__/DndExample.stories.tsx b/src/components/useList/__stories__/DndExample.stories.tsx new file mode 100644 index 0000000000..0e8a79c55a --- /dev/null +++ b/src/components/useList/__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/useList/__stories__/ListInfinityScroll.stories.tsx b/src/components/useList/__stories__/ListInfinityScroll.stories.tsx new file mode 100644 index 0000000000..f129b2be98 --- /dev/null +++ b/src/components/useList/__stories__/ListInfinityScroll.stories.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import { + InfinityScrollList as InfinityScrollListExample, + InfinityScrollListProps, +} from './components/InfinityScrollList'; + +export default { + title: 'Unstable/useList/InfinityScrollList', + component: InfinityScrollListExample, +} as Meta; + +const InfinityScrollListTemplate: StoryFn = (props) => { + return ; +}; + +export const InfinityScrollList = InfinityScrollListTemplate.bind({}); +InfinityScrollList.args = { + size: 'm', +}; diff --git a/src/components/useList/__stories__/PopupWithToggler.stories.tsx b/src/components/useList/__stories__/PopupWithToggler.stories.tsx new file mode 100644 index 0000000000..77b7157192 --- /dev/null +++ b/src/components/useList/__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 PopupWithTogglerTemplate: StoryFn = (props) => { + return ( + + + + ); +}; +export const PopupWithToggler = PopupWithTogglerTemplate.bind({}); +PopupWithToggler.args = { + itemsCount: 10, + size: 'm', +}; diff --git a/src/components/useList/__stories__/RecursiveRenderer.stories.tsx b/src/components/useList/__stories__/RecursiveRenderer.stories.tsx new file mode 100644 index 0000000000..67ca690442 --- /dev/null +++ b/src/components/useList/__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 RecursiveRendererTemplate: StoryFn = (props) => { + return ( + + + + ); +}; + +export const RecursiveRenderer = RecursiveRendererTemplate.bind({}); + +RecursiveRenderer.args = { + size: 's', + itemsCount: 10, +}; diff --git a/src/components/useList/__stories__/VirtualizedList.stories.tsx b/src/components/useList/__stories__/VirtualizedList.stories.tsx new file mode 100644 index 0000000000..2de4bb56f8 --- /dev/null +++ b/src/components/useList/__stories__/VirtualizedList.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/VirtualizedList', + component: FlattenList, +} as Meta; + +const VirtualizedListTemplate: StoryFn = (props) => { + return ( + + + + ); +}; + +export const VirtualizedList = VirtualizedListTemplate.bind({}); + +VirtualizedList.args = { + size: 's', + itemsCount: 1000, +}; diff --git a/src/components/useList/__stories__/components/FlattenList.tsx b/src/components/useList/__stories__/components/FlattenList.tsx new file mode 100644 index 0000000000..be60ffa0c2 --- /dev/null +++ b/src/components/useList/__stories__/components/FlattenList.tsx @@ -0,0 +1,106 @@ +import React from 'react'; + +import get from 'lodash/get'; + +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, ListItemSize} 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: ListItemSize; +} + +export const FlattenList = ({itemsCount, size}: FlattenListProps) => { + const containerRef = React.useRef(null); + const items = React.useMemo( + () => createRandomizedData<{title: string}>({num: itemsCount}), + [itemsCount], + ); + + const filterState = useListFilter({items}); + + const listState = useListState(); + + const list = useList({ + items: filterState.items, + ...listState, + }); + + const onItemClick = React.useCallback( + (id: ListItemId) => { + if (id in list.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); + }, + [list, listState], + ); + + useListKeydown({ + containerRef, + onItemClick, + ...list, + ...listState, + }); + + return ( + + + + + + computeItemSize( + size, + Boolean(get(list.itemsById[list.visibleFlattenIds[index]], 'subtitle')), + ) + } + > + {(id) => { + const {data, props, context} = getItemRenderState({ + id, + size, + onItemClick, + ...list, + ...listState, + }); + return ( + + ); + }} + + + + ); +}; diff --git a/src/components/useList/__stories__/components/InfinityScrollList.tsx b/src/components/useList/__stories__/components/InfinityScrollList.tsx new file mode 100644 index 0000000000..75a07b5c26 --- /dev/null +++ b/src/components/useList/__stories__/components/InfinityScrollList.tsx @@ -0,0 +1,144 @@ +import React from 'react'; + +import {Button} from '../../../Button'; +import {Loader} from '../../../Loader'; +import {TextInput} from '../../../controls'; +import {Flex} from '../../../layout'; +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, ListItemSize} from '../../types'; +import {getItemRenderState} from '../../utils/getItemRenderState'; +import {useInfinityFetch} from '../utils/useInfinityFetch'; + +import {IntersectionContainer} from './IntersectionContainer/IntersectionContainer'; + +export interface InfinityScrollListProps { + size: ListItemSize; +} + +export const InfinityScrollList = ({size}: InfinityScrollListProps) => { + const containerRef = React.useRef(null); + const {data, onFetchMore, canFetchMore, isLoading} = useInfinityFetch<{title: string}>(); + const filterState = useListFilter({items: data}); + + const listState = useListState(); + + const list = useList({ + items: filterState.items, + ...listState, + }); + + const onItemClick = (id: ListItemId) => { + if (id in list.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, + ...list, + ...listState, + }); + + const handleReset = () => { + filterState.reset(); + listState.setExpanded({}); + listState.setSelected({}); + listState.setActiveItemId(undefined); + }; + + const handleAccept = () => { + alert( + JSON.stringify( + Object.keys(listState.selectedById).map((id) => list.itemsById[id]), + null, + 2, + ), + ); + }; + + return ( + + + {data.length > 0 && ( + + + + + {list.items.map((item, index) => ( + + {(id) => { + const {data, props, context} = getItemRenderState({ + id, + size, + onItemClick, + ...list, + ...listState, + }); + const node = ; + + if (context.isLastItem) { + return ( + + {node} + + ); + } + + return node; + }} + + ))} + + + )} + + {isLoading && ( + + + + )} + + + + + + + ); +}; diff --git a/src/components/useList/__stories__/components/IntersectionContainer/IntersectionContainer.tsx b/src/components/useList/__stories__/components/IntersectionContainer/IntersectionContainer.tsx new file mode 100644 index 0000000000..bc0aeaf07b --- /dev/null +++ b/src/components/useList/__stories__/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/useList/__stories__/components/ListWithDnd.tsx b/src/components/useList/__stories__/components/ListWithDnd.tsx new file mode 100644 index 0000000000..2e82c5acd2 --- /dev/null +++ b/src/components/useList/__stories__/components/ListWithDnd.tsx @@ -0,0 +1,133 @@ +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, ListItemSize} from '../../types'; +import {getItemRenderState} from '../../utils/getItemRenderState'; +import {createRandomizedData} from '../utils/makeData'; +import {reorderArray} from '../utils/reorderArray'; + +export interface ListWithDndProps { + itemsCount: number; + size: ListItemSize; +} + +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 list = useList({ + items: filterState.items, + ...listState, + }); + + const onItemClick = React.useCallback( + (id: ListItemId) => { + if (id in list.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); + }, + [list.groupsState, listState], + ); + + useListKeydown({ + containerRef, + onItemClick, + ...list, + ...listState, + }); + + return ( + + + { + if (destination?.index && destination?.index !== source.index) { + setItems((items) => reorderArray(items, source.index, destination.index)); + } + }} + > + + {(droppableProvided: DroppableProvided) => ( +
+ + {list.visibleFlattenIds.map((id, index) => { + const {data, props} = getItemRenderState({ + id, + size, + onItemClick, + ...list, + ...listState, + }); + + return ( + + {( + provided: DraggableProvided, + snapshot: DraggableStateSnapshot, + ) => ( + } + /> + )} + + ); + })} + {droppableProvided.placeholder} + +
+ )} +
+
+
+ ); +}; diff --git a/src/components/useList/__stories__/components/PopupWithTogglerList.tsx b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx new file mode 100644 index 0000000000..c64c9480e8 --- /dev/null +++ b/src/components/useList/__stories__/components/PopupWithTogglerList.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +import {Button} from '../../../Button'; +import {Popup} from '../../../Popup'; +import {Flex} from '../../../layout'; +import {ListContainerView} from '../../components/ListContainerView/ListContainerView'; +import {ListItemView} from '../../components/ListItemView/ListItemView'; +import {ListItemRecursiveRenderer} from '../../components/ListRecursiveRenderer/ListRecursiveRenderer'; +import {useList} from '../../hooks/useList'; +import {useListKeydown} from '../../hooks/useListKeydown'; +import {useListState} from '../../hooks/useListState'; +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: ListItemSize; +} + +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}>({num: itemsCount}), + [itemsCount], + ); + + const listState = useListState(); + + const list = useList({ + items, + ...listState, + }); + + const [selectedId] = React.useMemo( + () => Object.keys(listState.selectedById), + [listState.selectedById], + ); + + // restoring focus when popup opens + React.useLayoutEffect(() => { + if (open) { + containerRef.current?.focus(); + listState.setActiveItemId(selectedId ?? list.visibleFlattenIds[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 list.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, + ...list, + ...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) => { + const {data, props, context} = getItemRenderState({ + id, + size, + onItemClick, + ...list, + ...listState, + }); + + return ( + + ); + }} + + ))} + + + + ); +}; diff --git a/src/components/useList/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx new file mode 100644 index 0000000000..b63ae4f048 --- /dev/null +++ b/src/components/useList/__stories__/components/RecursiveList.tsx @@ -0,0 +1,105 @@ +import React from 'react'; + +import {TextInput} from '../../../controls'; +import {Flex} from '../../../layout'; +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, ListItemSize} from '../../types'; +import {getItemRenderState} from '../../utils/getItemRenderState'; +import {createRandomizedData} from '../utils/makeData'; + +export interface RecursiveListProps { + itemsCount: number; + size: ListItemSize; +} + +export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { + const containerRef = React.useRef(null); + + const items = React.useMemo( + () => createRandomizedData<{title: string}>({num: itemsCount}), + [itemsCount], + ); + + const filterState = useListFilter({items}); + + const listState = useListState(); + + const list = useList({ + items: filterState.items, + ...listState, + }); + + const onItemClick = React.useCallback( + (id: ListItemId) => { + if (id in list.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); + }, + [list.groupsState, listState], + ); + + useListKeydown({ + containerRef, + onItemClick, + ...list, + ...listState, + }); + + return ( + + + + {filterState.items.map((item, index) => ( + + {(id) => { + const {data, props, context} = getItemRenderState({ + id, + size, + onItemClick, + ...list, + ...listState, + }); + + return ( + + ); + }} + + ))} + + + ); +}; diff --git a/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx b/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx new file mode 100644 index 0000000000..9d18048fdf --- /dev/null +++ b/src/components/useList/__stories__/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/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx b/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx new file mode 100644 index 0000000000..5491b6d088 --- /dev/null +++ b/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; +import {VariableSizeList} from 'react-window'; + +import type {ListContainerRenderProps} from './types'; + +const DEFAULT_OVERSCAN_COUNT = 10; + +export function VirtualizedListContainer({ + items, + className, + children, + ...props +}: ListContainerRenderProps) { + return ( + + {({width, height}: Size) => ( + + {({index, style, data}) => ( +
+ {children(data[index], index)} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/useList/__stories__/components/VirtualizedListContainer/index.ts b/src/components/useList/__stories__/components/VirtualizedListContainer/index.ts new file mode 100644 index 0000000000..506d9b433a --- /dev/null +++ b/src/components/useList/__stories__/components/VirtualizedListContainer/index.ts @@ -0,0 +1 @@ +export * from './VirtualizedListContainer.async'; diff --git a/src/components/useList/__stories__/components/VirtualizedListContainer/types.ts b/src/components/useList/__stories__/components/VirtualizedListContainer/types.ts new file mode 100644 index 0000000000..ee0222674f --- /dev/null +++ b/src/components/useList/__stories__/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/useList/__stories__/useList.mdx b/src/components/useList/__stories__/useList.mdx new file mode 100644 index 0000000000..0e7afaf839 --- /dev/null +++ b/src/components/useList/__stories__/useList.mdx @@ -0,0 +1,539 @@ +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); + +## Quick code snippets for beginners: + +### flatten items: + +```tsx +import { + ListContainerView, + type ListItemId, + type ListItemType, + ListItemView, + getItemRenderState, + useList, + useListKeydown, + useListState, +} from '@gravity-ui/uikit/unstable'; + +const items: ListItemType[] = ['one', 'two', 'free', 'four', 'five']; + +function List() { + const containerRef = React.useRef(null); + + const listState = useListState(); + const list = useList({ + items, + ...listState, + }); + + useListKeydown({ + onItemClick, + containerRef, + ...list, + ...listState, + }); + + return ( + + {list.items.map((_, i) => { + const { + data, + props, + context: _context, + } = getItemRenderState({ + id: String(i), + onItemClick, + ...list, + ...listState, + }); + + return ; + })} + + ); + + // multiple selection + function onItemClick(id: ListItemId) { + listState.setSelected((prevState) => ({ + ...prevState, + [id]: !prevState[id], + })); + + listState.setActiveItemId(id); + } +} +``` + +### tree item structure: + +```tsx +const items: ListItemType[] = [ + {data: 'one'}, + {data: 'two', children: [{data: 'tree', children: [{data: 'four'}, {data: 'five'}]}]}, +]; + +function List() { + // same as prev example + return ( + + {list.items.map((item, index) => ( + + {(id) => { + const { + data, + props, + context: _context, + } = getItemRenderState({ + id: String(i), + onItemClick, + ...list, + ...listState, + }); + + return ; + }} + + ))} + + ); +} +``` + +## 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 `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 +const items = [ + {data: {id: 'id-1', title: 'some title 1'}, children: [...]}, + {data: {id: 'id-2', title: 'some title 2'}, children: [...]}, +]; + +/** + * itemsById: { + * '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; + +- `itemsById` - normalized representation of list items: + + ```tsx + export type ParsedState = { + // ... + itemsById: Record; + // ... + }; + + const items = [ + {data: {title: 'title-1'}, children: [{data: {title: 'title-1-1'}, children: []}]}, + {data: {title: 'title-2'}, children: []}, + ]; + // -> + const itemsById: { + 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; +- `visibleFlattenIds` - 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`; +- `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; +- `enabled` - on/off keyboard support. Use it if you need to change the behavior in runtime; + +```tsx +const containerRef = React.useRef(null); +const listState = useListState() +const list = useList(...) + +const handleItemClick = () => {...}; + +useListKeydown({ + onItemClick: handleItemClick, + containerRef, + ...list, + ...listState, +}) +``` + +### 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[]`; +- `onFilterUpdate` - callback for changing the filter value; + +```tsx +const List = () => { + const {items, filter, onFilterUpdate, filterRef} = useListFilter({ + items: [...] + }) + + const list = 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; +- `hasSelectionIcon` - 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(itemsById[visibleFlattenIds[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 ?? list.visibleFlattenIds[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 listState = useListState(); +const list = useList({ + items, + ...listState, +}); +const handleItemClick = () => {}; + + + {(id) => { + const {data, props} = getItemRenderState({ + id, + size, // list size + onItemClick: handleItemClick, + ...list, + ...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/__stories__/utils/makeData.ts b/src/components/useList/__stories__/utils/makeData.ts new file mode 100644 index 0000000000..0e4a86f7c9 --- /dev/null +++ b/src/components/useList/__stories__/utils/makeData.ts @@ -0,0 +1,52 @@ +import {faker} from '@faker-js/faker/locale/en'; + +import type {ListItemType, ListTreeItemType} from '../../types'; + +const RANDOM_WORDS = Array(50) + .fill(null) + .map(() => faker.person.fullName()); + +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(depth, getData)); + } + + return data; +} + +function createRandomizedItem( + depth: number, + getData: (title: string) => T = base, +): ListTreeItemType { + const item: ListTreeItemType = { + data: getData(RANDOM_WORDS[Math.floor(Math.random() * RANDOM_WORDS.length)]), + }; + + const numChildren = depth > 0 ? 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/useList/__stories__/utils/reorderArray.ts b/src/components/useList/__stories__/utils/reorderArray.ts new file mode 100644 index 0000000000..31f55ad2a9 --- /dev/null +++ b/src/components/useList/__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/useList/__stories__/utils/useInfinityFetch.ts b/src/components/useList/__stories__/utils/useInfinityFetch.ts new file mode 100644 index 0000000000..31e082b8dd --- /dev/null +++ b/src/components/useList/__stories__/utils/useInfinityFetch.ts @@ -0,0 +1,55 @@ +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({num: itemsCount, depth: withChildren ? undefined : 0})), + 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/useList/components/ListContainerView/ListContainerView.scss b/src/components/useList/components/ListContainerView/ListContainerView.scss new file mode 100644 index 0000000000..ba4001b476 --- /dev/null +++ b/src/components/useList/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; + + &_fixed-height { + height: var(--g-list-container-height, 300px); + } + + &:not(#{$block}_fixed-height) { + overflow: auto; + } +} diff --git a/src/components/useList/components/ListContainerView/ListContainerView.tsx b/src/components/useList/components/ListContainerView/ListContainerView.tsx new file mode 100644 index 0000000000..2e541b719d --- /dev/null +++ b/src/components/useList/components/ListContainerView/ListContainerView.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import type {QAProps} from 'src/components/types'; + +import {Flex} from '../../../layout'; +import {block} from '../../../utils/cn'; + +import './ListContainerView.scss'; + +const b = block('list-container-view'); + +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( + {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 new file mode 100644 index 0000000000..9eefa73fed --- /dev/null +++ b/src/components/useList/components/ListItemView/ListItemView.scss @@ -0,0 +1,68 @@ +@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-solid); + } + + &_clickable { + cursor: pointer; + } + + &_selected, + &_selected:hover#{$block}_activeOnHover { + background: var(--g-color-base-selection); + } + + &_radius_s { + border-radius: var(--g-list-item-border-radius, 3px); + } + &_radius_m { + border-radius: var(--g-list-item-border-radius, 6px); + } + &_radius_l { + border-radius: var(--g-list-item-border-radius, 8px); + } + &_radius_xl { + border-radius: var(--g-list-item-border-radius, 8px); + } + + &__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/useList/components/ListItemView/ListItemView.tsx b/src/components/useList/components/ListItemView/ListItemView.tsx new file mode 100644 index 0000000000..6056544ec8 --- /dev/null +++ b/src/components/useList/components/ListItemView/ListItemView.tsx @@ -0,0 +1,184 @@ +import React from 'react'; + +import {Check, ChevronDown, ChevronUp} from '@gravity-ui/icons'; + +import {Icon} from '../../../Icon'; +import {Text, colorText} from '../../../Text'; +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, ListItemSize} from '../../types'; + +import './ListItemView.scss'; + +const b = block('list-item-view'); + +export interface ListItemViewProps extends QAProps { + /** + * Ability to override default html tag + */ + as?: keyof JSX.IntrinsicElements; + /** + * @default `m` + */ + size?: ListItemSize; + height?: number; + selected?: boolean; + active?: 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 + */ + hasSelectionIcon?: 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; + className?: string; + role?: React.AriaRole; + expanded?: boolean; + /** + * `[${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 = 'div', + size = 'm', + active, + selected, + disabled, + activeOnHover = true, + className, + hasSelectionIcon = true, + indentation, + startSlot, + subtitle, + endSlot, + title, + height, + expanded, + style, + role = 'option', + onClick: _onClick, + ...rest + }: ListItemViewProps, + ref?: any, + ) => { + const isGroup = typeof expanded === 'boolean'; + const onClick = disabled ? undefined : _onClick; + + return ( + + + {hasSelectionIcon && ( + + {selected ? ( + + ) : null} + + )} + + {renderSafeIndentation(indentation)} + + {startSlot ?? + (isGroup ? ( + + ) : null)} + + + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + {typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + )} + + + + {endSlot} + + ); + }, +); + +ListItemView.displayName = 'ListItemView'; diff --git a/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx b/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx new file mode 100644 index 0000000000..5af6542573 --- /dev/null +++ b/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx @@ -0,0 +1,115 @@ +import React from 'react'; + +import type {Meta, StoryFn} from '@storybook/react'; + +import {Avatar} from '../../../../Avatar'; +import {Flex} from '../../../../layout'; +import {useListState} from '../../../hooks/useListState'; +import type {ListItemId} from '../../../types'; +import {ListItemView as ListItemViewComponent, ListItemViewProps} from '../ListItemView'; + +export default { + title: 'Unstable/useList/ListItemView', + component: ListItemViewComponent, +} 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, + size: 'l', + subtitle, + hasSelectionIcon: false, + startSlot: ( + + ), + }, + { + id: '4', + title, + disabled: true, + size: 'xl', + height: 60, + startSlot: ( + + ), + }, + { + id: '5', + size: 'l', + startSlot: ( + + ), + title, + }, + { + id: '6', + title, + size: 'l', + subtitle: 'indentation 1', + startSlot: ( + + ), + indentation: 1, + selected: true, + }, + { + id: '7', + expanded: true, + size: 'xl', + title: 'Group 1', + }, + { + id: '8', + hasSelectionIcon: false, + expanded: true, + size: 'xl', + title: 'Group 1', + }, +]; + +const ListItemViewTemplate: StoryFn = () => { + const listState = useListState(); + + return ( + + {stories.map((props, i) => ( + + ))} + + ); + + function handleClick(id: ListItemId) { + return () => { + listState.setSelected((prevState) => ({ + ...prevState, + [id]: !prevState[id], + })); + }; + } +}; +export const ListItemView = ListItemViewTemplate.bind({}); diff --git a/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.scss b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.scss new file mode 100644 index 0000000000..519a20484f --- /dev/null +++ b/src/components/useList/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/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx new file mode 100644 index 0000000000..f73ab801ed --- /dev/null +++ b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import {block} from '../../../utils/cn'; +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 extends Partial> { + itemSchema: ListItemType; + children(id: ListItemId): React.JSX.Element; + index: number; + parentId?: string; + className?: string; + getId?(item: T): ListItemId; + style?: React.CSSProperties; +} + +// Saves the nested html structure for tree data structure +export function ListItemRecursiveRenderer({ + itemSchema, + index, + parentId, + ...props +}: ListRecursiveRendererProps) { + const groupedId = getGroupItemId(index, parentId); + const id = getListItemId({item: itemSchema, groupedId, getId: props.getId}); + + const node = props.children(id); + + 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) => ( + + ))} +
+ ); + } + + return node; +} diff --git a/src/components/useList/constants.ts b/src/components/useList/constants.ts new file mode 100644 index 0000000000..ead8ae18c1 --- /dev/null +++ b/src/components/useList/constants.ts @@ -0,0 +1,10 @@ +export const LIST_ITEM_DATA_ATR = 'data-list-item'; + +export const GROUPED_ID_SEPARATOR = '-'; + +export const modToHeight = { + s: [22, 44], + m: [26, 44], + l: [34, 52], + xl: [44, 62], +} as const; diff --git a/src/components/useList/hooks/useFlattenListItems.ts b/src/components/useList/hooks/useFlattenListItems.ts new file mode 100644 index 0000000000..b764306da6 --- /dev/null +++ b/src/components/useList/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[]; + 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 `expandedById` map + */ +export function useFlattenListItems({items, expandedById, getId}: UseFlattenListItemsProps) { + const order = React.useMemo(() => { + return flattenItems(items, expandedById, getId); + }, [items, expandedById, getId]); + + return order; +} diff --git a/src/components/useList/hooks/useList.ts b/src/components/useList/hooks/useList.ts new file mode 100644 index 0000000000..7e6f184a4f --- /dev/null +++ b/src/components/useList/hooks/useList.ts @@ -0,0 +1,36 @@ +/* eslint-disable valid-jsdoc */ +import type {ListItemId, ListItemType, ListParsedState, ListState} from '../types'; + +import {useFlattenListItems} from './useFlattenListItems'; +import {useListParsedState} from './useListParsedState'; + +export interface UseListProps extends Partial { + items: ListItemType[]; + /** + * Control expanded items state from external source + */ + 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): UseListResult => { + const {itemsById, groupsState, itemsState, initialState} = useListParsedState({ + items, + getId, + }); + + const visibleFlattenIds = useFlattenListItems({ + items, + /** + * By default controlled from list items declaration state + */ + expandedById: expandedById || initialState.expandedById, + getId, + }); + + return {items, visibleFlattenIds, itemsById, groupsState, itemsState}; +}; diff --git a/src/components/useList/hooks/useListFilter.ts b/src/components/useList/hooks/useListFilter.ts new file mode 100644 index 0000000000..3c9e853b6a --- /dev/null +++ b/src/components/useList/hooks/useListFilter.ts @@ -0,0 +1,98 @@ +import React from 'react'; + +import debounce from 'lodash/debounce'; + +import type {ListItemType} from '../types'; +import {defaultFilterItems} from '../utils/defaultFilterItems'; + +function defaultFilterFn(value: string | undefined, item: T): boolean { + return item && typeof item === 'object' && 'title' in item && typeof item.title === 'string' + ? item.title.toLowerCase().includes((value || '').toLowerCase()) + : 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 list = 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 debouncedFn = React.useCallback( + debounce((value) => setItems(filterItemsFn(value, externalItems)), debounceTimeout), + [setItems, filterItemsFn, debounceTimeout], + ); + + const {onFilterUpdate, reset} = React.useMemo(() => { + return { + reset: () => { + setFilter(initialFilterValue); + debouncedFn(initialFilterValue); + }, + onFilterUpdate: (nextFilterValue: string) => { + setFilter(nextFilterValue); + debouncedFn(nextFilterValue); + }, + }; + }, [debouncedFn, initialFilterValue]); + + return { + filterRef, + filter, + reset, + items, + onFilterUpdate, + }; +} diff --git a/src/components/useList/hooks/useListKeydown.tsx b/src/components/useList/hooks/useListKeydown.tsx new file mode 100644 index 0000000000..802b0f4b42 --- /dev/null +++ b/src/components/useList/hooks/useListKeydown.tsx @@ -0,0 +1,94 @@ +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> { + visibleFlattenIds: ListItemId[]; + onItemClick?(itemId: ListItemId): void; + containerRef?: React.RefObject; + setActiveItemId?(id: ListItemId): void; + enabled?: boolean; +} + +// Use this hook if you need keyboard support for tree structure lists +export const useListKeydown = ({ + visibleFlattenIds, + onItemClick, + containerRef, + disabledById = {}, + activeItemId, + setActiveItemId, + enabled, +}: UseListKeydownProps) => { + const activateItem = React.useCallback( + (index?: number, scrollTo = true) => { + if (typeof index === 'number' && visibleFlattenIds[index]) { + if (scrollTo) { + scrollToListItem(visibleFlattenIds[index], containerRef?.current); + } + + setActiveItemId?.(visibleFlattenIds[index]); + } + }, + [containerRef, visibleFlattenIds, setActiveItemId], + ); + + const handleKeyMove = React.useCallback( + (event: KeyboardEvent, step: number, defaultItemIndex = 0) => { + event.preventDefault(); + + const maybeIndex = visibleFlattenIds.findIndex((i) => i === activeItemId); + + const nextIndex = findNextIndex({ + list: visibleFlattenIds, + index: (maybeIndex > -1 ? maybeIndex : defaultItemIndex) + step, + step: Math.sign(step), + disabledItems: disabledById, + }); + + activateItem(nextIndex); + }, + [activateItem, activeItemId, disabledById, visibleFlattenIds], + ); + + React.useLayoutEffect(() => { + const anchor = containerRef?.current; + + if (enabled || !anchor) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + switch (event.key) { + case KeyCode.ARROW_DOWN: { + handleKeyMove(event, 1, -1); + break; + } + case KeyCode.ARROW_UP: { + handleKeyMove(event, -1); + break; + } + case KeyCode.SPACEBAR: + case KeyCode.ENTER: { + if (activeItemId && !disabledById[activeItemId]) { + event.preventDefault(); + + onItemClick?.(activeItemId); + } + break; + } + default: { + } + } + }; + + anchor.addEventListener('keydown', handleKeyDown); + + return () => { + anchor.removeEventListener('keydown', handleKeyDown); + }; + }, [activeItemId, containerRef, disabledById, enabled, handleKeyMove, onItemClick]); +}; diff --git a/src/components/useList/hooks/useListParsedState.ts b/src/components/useList/hooks/useListParsedState.ts new file mode 100644 index 0000000000..52d3403822 --- /dev/null +++ b/src/components/useList/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/useList/hooks/useListState.ts b/src/components/useList/hooks/useListState.ts new file mode 100644 index 0000000000..5b3165abe9 --- /dev/null +++ b/src/components/useList/hooks/useListState.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import React from 'react'; + +import type {ListState} from '../types'; + +interface UseListStateProps extends Partial {} + +function useControlledState(value: T, defaultValue: T) { + const [state, setState] = React.useState(value || defaultValue); + + return [value || state, setState] as const; +} + +export const useListState = (props: UseListStateProps = {}) => { + 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 { + disabledById, + setDisabled, + selectedById, + setSelected, + expandedById, + setExpanded, + activeItemId, + setActiveItemId, + }; +}; diff --git a/src/components/useList/index.ts b/src/components/useList/index.ts new file mode 100644 index 0000000000..0321954b30 --- /dev/null +++ b/src/components/useList/index.ts @@ -0,0 +1,14 @@ +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/ListRecursiveRenderer/ListRecursiveRenderer'; +export * from './components/ListContainerView/ListContainerView'; +export * from './utils/computeItemSize'; +export * from './utils/getItemRenderState'; +export * from './utils/scrollToListItem'; +export * from './utils/getListParsedState'; +export {modToHeight} from './constants'; +export {isKnownStructureGuard} from './utils'; diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts new file mode 100644 index 0000000000..34615229ea --- /dev/null +++ b/src/components/useList/types.ts @@ -0,0 +1,103 @@ +export type ListItemId = string; + +export type ListItemSize = 's' | 'm' | 'l' | 'xl'; +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; +} + +export type ListFlattenItemType = T extends {} ? T & ListItemInitialProps : T; + +export interface ListTreeItemType extends ListItemInitialProps { + data: T; + children?: ListTreeItemType[]; +} + +export type ListItemType = ListTreeItemType | ListFlattenItemType; + +export type GroupParsedState = { + childrenIds: ListItemId[]; +}; + +export type ItemState = { + parentId?: ListItemId; + indentation: number; +}; + +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: ItemState; + /** + * Exists if item is group + */ + groupState?: GroupParsedState; + isLastItem: boolean; +}; + +export type RenderItemState = { + size: ListItemSize; + id: ListItemId; + onClick?(): void; + selected: boolean; + disabled: boolean; + expanded?: boolean; + active: boolean; + indentation: number; + hasSelectionIcon?: boolean; +}; + +export type ParsedState = { + /** + * Stored internal meta info about item + * Note: Groups are also items + */ + itemsState: Record; + /** + * Normalized original data + */ + itemsById: Record; + /** + * Stored info about group items: + */ + groupsState: Record; +}; + +export type ListState = { + disabledById: Record; + selectedById: Record; + expandedById: Record; + activeItemId?: ListItemId; +}; + +export type ListParsedState = ParsedState & { + items: ListItemType[]; + visibleFlattenIds: ListItemId[]; +}; 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; +}; diff --git a/src/components/useList/utils/computeItemSize.ts b/src/components/useList/utils/computeItemSize.ts new file mode 100644 index 0000000000..174494eaa3 --- /dev/null +++ b/src/components/useList/utils/computeItemSize.ts @@ -0,0 +1,6 @@ +import {modToHeight} from '../constants'; +import type {ListItemSize} from '../types'; + +export const computeItemSize = (size: ListItemSize, hasSubRows = false) => { + return modToHeight[size][Number(hasSubRows)]; +}; 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 new file mode 100644 index 0000000000..5e3adc0d9b --- /dev/null +++ b/src/components/useList/utils/defaultFilterItems.ts @@ -0,0 +1,38 @@ +import type {ListItemType, ListTreeItemType} from '../types'; + +import {isTreeItemGuard} from './isTreeItemGuard'; + +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 (isTreeItemGuard(item) && item.children) { + const children = item.children.reduce(getChildren, []); + + if (children.length) { + result.push({...item, data: item.data, children} as ListTreeItemType); + } else if (filterFn(item.data)) { + result.push({...item, data: item.data, children: []}); + } + } 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; + }; + + const res = items.reduce[]>(getChildren, []); + + if (process.env.NODE_ENV !== 'production') { + console.timeEnd('defaultFilterItems'); + } + return res; +} diff --git a/src/components/useList/utils/findNextIndex.ts b/src/components/useList/utils/findNextIndex.ts new file mode 100644 index 0000000000..c00a429b50 --- /dev/null +++ b/src/components/useList/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/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/flattenItems.ts b/src/components/useList/utils/flattenItems.ts new file mode 100644 index 0000000000..e170da6b9f --- /dev/null +++ b/src/components/useList/utils/flattenItems.ts @@ -0,0 +1,48 @@ +import type {ListItemId, ListItemType} from '../types'; + +import {getListItemId} from './getListItemId'; +import {getGroupItemId} from './groupItemId'; +import {isTreeItemGuard} from './isTreeItemGuard'; + +export function flattenItems( + items: ListItemType[], + expandedById: 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 = getGroupItemId(index, parentId); + const id = getListItemId({groupedId, item, getId}); + + order.push(id); + + if (isTreeItemGuard(item) && item.children) { + // don't include collapsed groups + if (!(id in expandedById && !expandedById[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/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx new file mode 100644 index 0000000000..5bd07bdc4d --- /dev/null +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -0,0 +1,62 @@ +/* eslint-disable valid-jsdoc */ +import type { + ListItemId, + ListItemSize, + ListParsedState, + ListState, + RenderItemContext, + RenderItemState, +} from '../types'; + +type ItemRendererProps = ListState & + ListParsedState & { + size?: ListItemSize; + id: ListItemId; + onItemClick?(id: ListItemId): void; + }; + +/** + * Map list state and parsed list state to item render props + */ +export const getItemRenderState = ( + { + itemsById, + disabledById, + expandedById, + groupsState, + onItemClick, + visibleFlattenIds, + size = 'm', + itemsState, + selectedById, + activeItemId, + id, + }: ItemRendererProps, + {defaultExpanded = true}: {defaultExpanded?: boolean} = {}, +) => { + const context: RenderItemContext = { + itemState: itemsState[id], + groupState: groupsState[id], + isLastItem: id === visibleFlattenIds[visibleFlattenIds.length - 1], + }; + + let expanded; + + // isGroup + if (groupsState[id]) { + expanded = expandedById[id] ?? defaultExpanded; + } + + const stateProps: RenderItemState = { + id, + size, + expanded, + active: id === activeItemId, + indentation: context.itemState.indentation, + disabled: disabledById[id], + selected: selectedById[id], + onClick: onItemClick ? () => onItemClick(id) : undefined, + }; + + return {data: itemsById[id], props: stateProps, context}; +}; diff --git a/src/components/useList/utils/getListItemId.ts b/src/components/useList/utils/getListItemId.ts new file mode 100644 index 0000000000..78d101075e --- /dev/null +++ b/src/components/useList/utils/getListItemId.ts @@ -0,0 +1,21 @@ +import type {ListItemId, ListItemType} from '../types'; + +import {isTreeItemGuard} from './isTreeItemGuard'; + +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 && typeof item === 'object' && 'id' in item && item.id) { + id = item.id; + } + + return id; +}; diff --git a/src/components/useList/utils/getListParsedState.test.ts b/src/components/useList/utils/getListParsedState.test.ts new file mode 100644 index 0000000000..9122c4cf29 --- /dev/null +++ b/src/components/useList/utils/getListParsedState.test.ts @@ -0,0 +1,181 @@ +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({ + initialState: { + selectedById: { + 2: true, + }, + disabledById: { + 0: true, + }, + expandedById: { + '1-1': false, + }, + }, + itemsById: { + 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']}, + '1-1-0': {childrenIds: []}, + '2': {childrenIds: []}, + }, + itemsState: { + 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}, + }, + }); + }); + + 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({ + initialState: { + selectedById: { + 1: true, + }, + disabledById: { + 0: true, + }, + expandedById: {}, + }, + itemsById: { + 0: { + a: 'item-1', + children: [], + disabled: true, + }, + 1: { + a: 'item-2', + selected: true, + }, + 2: { + c: 'item-3', + }, + }, + groupsState: {}, + itemsState: { + 0: {indentation: 0}, + 1: {indentation: 0}, + 2: {indentation: 0}, + }, + }); + }); + + 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({ + initialState: { + selectedById: {}, + disabledById: {}, + expandedById: { + 'id-4': false, + }, + }, + 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'}, + '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'], + }, + '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'}, + }, + }); + }); +}); diff --git a/src/components/useList/utils/getListParsedState.ts b/src/components/useList/utils/getListParsedState.ts new file mode 100644 index 0000000000..86350b3281 --- /dev/null +++ b/src/components/useList/utils/getListParsedState.ts @@ -0,0 +1,145 @@ +import type { + ListFlattenItemType, + ListItemId, + ListItemType, + ListState, + 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: ListTreeItemType; + index: number; + parentId?: ListItemId; + parentGroupedId?: string; +} + +type ListParsedStateResult = ParsedState & { + initialState: Pick; +}; + +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, +): ListParsedStateResult { + if (process.env.NODE_ENV !== 'production') { + console.time('getListParsedState'); + } + + const result: ListParsedStateResult = { + itemsById: {}, + groupsState: {}, + itemsState: {}, + initialState: { + disabledById: {}, + selectedById: {}, + expandedById: {}, + }, + }; + + const traverseItem = ({item, index}: TraverseItemProps) => { + const id = getListItemId({groupedId: String(index), item, getId}); + + result.itemsById[id] = item; + + if (!result.itemsState[id]) { + result.itemsState[id] = { + indentation: 0, + }; + } + + if (item && typeof item === 'object') { + if ('selected' in item && typeof item.selected === 'boolean') { + result.initialState.selectedById[id] = item.selected; + } + + if ('disabled' in item && typeof item.disabled === 'boolean') { + result.initialState.disabledById[id] = item.disabled; + } + } + }; + + 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.itemsById[id] = item.data; + + if (!result.itemsState[id]) { + result.itemsState[id] = { + indentation: 0, + }; + } + + if (typeof parentId !== 'undefined') { + result.itemsState[id].parentId = parentId; + } + + if (typeof item.selected !== 'undefined') { + result.initialState.selectedById[id] = item.selected; + } + + if (typeof item.disabled !== 'undefined') { + result.initialState.disabledById[id] = item.disabled; + } + + if (groupedId) { + result.itemsState[id].indentation = parseGroupItemId(groupedId).length - 1; + } + + if (item.children) { + result.groupsState[id] = { + childrenIds: [], + }; + + if (typeof item.expanded !== 'undefined') { + result.initialState.expandedById[id] = item.expanded; + } + + item.children.forEach((treeItem, index) => { + traverseTreeItem({ + item: treeItem, + index, + parentGroupedId: groupedId, + parentId: id, + }); + }); + } + }; + + items.forEach((item, index) => + isTreeItemGuard(item) ? traverseTreeItem({item, index}) : traverseItem({item, index}), + ); + + if (process.env.NODE_ENV !== 'production') { + console.timeEnd('getListParsedState'); + } + + return result; +} diff --git a/src/components/useList/utils/groupItemId.ts b/src/components/useList/utils/groupItemId.ts new file mode 100644 index 0000000000..215f365b09 --- /dev/null +++ b/src/components/useList/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/useList/utils/isTreeItemGuard.ts b/src/components/useList/utils/isTreeItemGuard.ts new file mode 100644 index 0000000000..febb628a9b --- /dev/null +++ b/src/components/useList/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' && 'data' in item; +}; diff --git a/src/components/useList/utils/scrollToListItem.ts b/src/components/useList/utils/scrollToListItem.ts new file mode 100644 index 0000000000..b2ab2358d1 --- /dev/null +++ b/src/components/useList/utils/scrollToListItem.ts @@ -0,0 +1,19 @@ +import {LIST_ITEM_DATA_ATR} from '../constants'; +import type {ListItemId} from '../types'; + +export const scrollToListItem = ( + itemId: ListItemId, + containerElement?: HTMLDivElement | HTMLUListElement | null, +) => { + if (document) { + const element = (containerElement || document).querySelector( + `[${LIST_ITEM_DATA_ATR}="${itemId}"]`, + ); + + if (element) { + element.scrollIntoView({ + block: 'nearest', + }); + } + } +}; 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..9b26891240 --- /dev/null +++ b/src/unstable.ts @@ -0,0 +1,13 @@ +/* eslint-disable camelcase */ +export { + useList as unstable_useList, + useListState as unstable_useListState, + useListFilter as unstable_useListFilter, + useListKeydown as unstable_useListKeydown, +} from './components/useList'; +export { + TreeSelect as unstable_TreeSelect, + TreeSelectProps as unstable_TreeSelectProps, + TreeSelectItem as unstable_TreeSelectItem, + TreeSelectItemProps as unstable_TreeSelectItemProps, +} from './components/TreeSelect';