From ea320f7c17ca240cfb1f96297a634d465924b240 Mon Sep 17 00:00:00 2001 From: Isaev Alexandr Date: Fri, 21 Jun 2024 18:56:58 +0300 Subject: [PATCH] feat(useList): redesigned api (#1661) --- .../TableColumnSetup/TableColumnSetup.tsx | 9 +- src/components/TreeList/TreeList.tsx | 121 +-- src/components/TreeList/__stories__/Docs.mdx | 7 + .../TreeList/__stories__/TreeList.mdx | 425 ---------- .../TreeList/__stories__/TreeList.stories.tsx | 2 +- .../TreeList/__stories__/TreeListDocs.md | 100 +++ .../components/RenderVirtualizedContainer.tsx | 8 +- .../__stories__/stories/DefaultStory.tsx | 30 +- .../stories/InfinityScrollStory.tsx | 61 +- .../stories/WithDisabledElementsStory.tsx | 24 +- .../__stories__/stories/WithDndListStory.tsx | 33 +- .../WithFiltrationAndControlsStory.tsx | 53 +- .../WithGroupSelectionAndCustomIconStory.tsx | 46 +- .../stories/WithItemLinksAndActionsStory.tsx | 42 +- .../TreeListContainer/TreeListContainer.tsx | 34 - src/components/TreeList/types.ts | 90 +- src/components/TreeSelect/TreeSelect.scss | 2 + src/components/TreeSelect/TreeSelect.tsx | 181 ++-- .../__stories__/TreeSelect.stories.tsx | 18 +- .../components/InfinityScrollExample.tsx | 61 +- .../components/WithDndListExample.tsx | 19 +- .../WithFiltrationAndControlsExample.tsx | 4 +- ...pSelectionControlledStateAndCustomIcon.tsx | 53 +- .../WithItemLinksAndActionsExample.tsx | 44 +- .../TreeSelect/hooks/useControlledValue.ts | 60 ++ .../hooks/useTreeSelectSelection.ts | 99 --- src/components/TreeSelect/types.ts | 101 +-- .../__stories__/DndExample.stories.tsx | 2 +- src/components/useList/__stories__/Docs.mdx | 159 ++++ .../ListInfinityScroll.stories.tsx | 2 +- .../__stories__/PopupWithToggler.stories.tsx | 2 +- .../__stories__/RecursiveRenderer.stories.tsx | 2 +- .../__stories__/VirtualizedList.stories.tsx | 2 +- .../__stories__/components/FlattenList.tsx | 51 +- .../components/InfinityScrollList.tsx | 112 +-- .../__stories__/components/ListWithDnd.tsx | 38 +- .../components/PopupWithTogglerList.tsx | 86 +- .../__stories__/components/RecursiveList.tsx | 81 +- .../VirtualizedListContainer.async.tsx | 2 +- .../__stories__/docs/compute-item-size.md | 19 + .../__stories__/docs/get-item-render-state.md | 93 +++ .../docs/get-list-item-click-handler.md | 34 + .../__stories__/docs/get-list-item-qa.md | 12 + .../__stories__/docs/get-list-parsed-state.md | 14 + .../__stories__/docs/list-container-view.md | 23 + .../__stories__/docs/list-item-view.md | 66 ++ .../docs/list-recursive-renderer.md | 64 ++ .../__stories__/docs/scroll-to-list-item.md | 27 + .../__stories__/docs/use-list-filter.md | 54 ++ .../__stories__/docs/use-list-keydown.md | 32 + .../useList/__stories__/docs/use-list.md | 136 +++ .../useList/__stories__/useList.mdx | 780 ------------------ .../ListContainer/ListContainer.tsx | 41 + .../useList/components/ListContainer/index.ts | 2 + .../ListContainerView/ListContainerView.tsx | 4 +- .../components/ListContainerView/index.ts | 1 + .../components/ListItemView/ListItemView.scss | 3 + .../__stories__/ListItemView.stories.tsx | 15 +- .../ListRecursiveRenderer.tsx | 32 +- .../components/ListRecursiveRenderer/index.ts | 2 + .../useList/hooks/useFlattenListItems.ts | 2 +- src/components/useList/hooks/useList.ts | 87 +- src/components/useList/hooks/useListFilter.ts | 8 +- .../useList/hooks/useListKeydown.tsx | 61 +- .../useList/hooks/useListParsedState.ts | 22 +- src/components/useList/hooks/useListState.ts | 76 +- src/components/useList/index.ts | 7 +- src/components/useList/types.ts | 47 +- .../useList/utils/flattenItems.test.ts | 65 +- src/components/useList/utils/flattenItems.ts | 24 +- .../useList/utils/getItemRenderState.tsx | 79 +- .../useList/utils/getListItemClickHandler.ts | 32 + .../useList/utils/getListParsedState.test.ts | 22 +- .../useList/utils/getListParsedState.ts | 22 +- src/unstable.ts | 5 +- 75 files changed, 1846 insertions(+), 2433 deletions(-) create mode 100644 src/components/TreeList/__stories__/Docs.mdx delete mode 100644 src/components/TreeList/__stories__/TreeList.mdx create mode 100644 src/components/TreeList/__stories__/TreeListDocs.md delete mode 100644 src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx create mode 100644 src/components/TreeSelect/hooks/useControlledValue.ts delete mode 100644 src/components/TreeSelect/hooks/useTreeSelectSelection.ts create mode 100644 src/components/useList/__stories__/Docs.mdx create mode 100644 src/components/useList/__stories__/docs/compute-item-size.md create mode 100644 src/components/useList/__stories__/docs/get-item-render-state.md create mode 100644 src/components/useList/__stories__/docs/get-list-item-click-handler.md create mode 100644 src/components/useList/__stories__/docs/get-list-item-qa.md create mode 100644 src/components/useList/__stories__/docs/get-list-parsed-state.md create mode 100644 src/components/useList/__stories__/docs/list-container-view.md create mode 100644 src/components/useList/__stories__/docs/list-item-view.md create mode 100644 src/components/useList/__stories__/docs/list-recursive-renderer.md create mode 100644 src/components/useList/__stories__/docs/scroll-to-list-item.md create mode 100644 src/components/useList/__stories__/docs/use-list-filter.md create mode 100644 src/components/useList/__stories__/docs/use-list-keydown.md create mode 100644 src/components/useList/__stories__/docs/use-list.md delete mode 100644 src/components/useList/__stories__/useList.mdx create mode 100644 src/components/useList/components/ListContainer/ListContainer.tsx create mode 100644 src/components/useList/components/ListContainer/index.ts create mode 100644 src/components/useList/components/ListContainerView/index.ts create mode 100644 src/components/useList/components/ListRecursiveRenderer/index.ts create mode 100644 src/components/useList/utils/getListItemClickHandler.ts diff --git a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx index 9398ae549d..6cca789c33 100644 --- a/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx +++ b/src/components/Table/hoc/withTableSettings/TableColumnSetup/TableColumnSetup.tsx @@ -113,8 +113,7 @@ const useDndRenderContainer = ({onDragEnd, renderControls}: UseDndRenderContaine const dndRenderContainer: TreeSelectRenderContainer = ({ renderItem, - visibleFlattenIds, - itemsById, + list, containerRef, id, className, @@ -126,15 +125,15 @@ const useDndRenderContainer = ({onDragEnd, renderControls}: UseDndRenderContaine }; return renderItem( - visibleFlattenIds[rubric.source.index], + list.structure.visibleFlattenIds[rubric.source.index], rubric.source.index, renderContainerProps, ); }; const {stickyStartItemIdList, sortableItemIdList, stickyEndItemIdList} = prepareStickyState( - itemsById, - visibleFlattenIds, + list.structure.itemsById, + list.structure.visibleFlattenIds, ); const stickyStartItemList = stickyStartItemIdList.map((visibleFlattenId, idx) => { diff --git a/src/components/TreeList/TreeList.tsx b/src/components/TreeList/TreeList.tsx index f3a2248faa..a31acea0e9 100644 --- a/src/components/TreeList/TreeList.tsx +++ b/src/components/TreeList/TreeList.tsx @@ -3,12 +3,17 @@ import React from 'react'; import {useUniqId} from '../../hooks'; -import {ListItemView, getItemRenderState, useList, useListKeydown} from '../useList'; -import type {ListItemId} from '../useList'; +import { + ListContainer, + ListItemView, + getItemRenderState, + getListItemClickHandler, + useListKeydown, +} from '../useList'; +import type {ListOnItemClick} from '../useList'; import {block} from '../utils/cn'; -import {TreeListContainer} from './components/TreeListContainer/TreeListContainer'; -import type {TreeListProps, TreeListRenderContainerProps} from './types'; +import type {TreeListContainerProps, TreeListProps} from './types'; const b = block('tree-list'); @@ -16,20 +21,13 @@ export const TreeList = ({ qa, id, size = 'm', - items, className, - expandedById: propsExpandedById, - disabledById: propsDisabledById, - selectedById: propsSelectedById, - activeItemId, - defaultGroupsExpanded = true, - getItemId, - renderItem: propsRenderItem, - renderContainer = TreeListContainer, - onItemClick, + list, multiple, - setActiveItemId, containerRef: propsContainerRef, + renderItem: propsRenderItem, + renderContainer = ListContainer, + onItemClick: propsOnItemClick, mapItemDataToProps, }: TreeListProps) => { const uniqId = useUniqId(); @@ -37,73 +35,33 @@ export const TreeList = ({ const containerRefLocal = React.useRef(null); const containerRef = propsContainerRef ?? containerRefLocal; - const listParsedState = useList({ - items, - getItemId, - // used not all of all properties but it may be needed in future - expandedById: propsExpandedById, - disabledById: propsDisabledById, - selectedById: propsSelectedById, - activeItemId, - }); + const onItemClick = React.useMemo(() => { + if (propsOnItemClick === null) { + return undefined; + } - const expandedById = propsExpandedById || listParsedState.initialState.expandedById; - const disabledById = propsDisabledById || listParsedState.initialState.disabledById; - const selectedById = propsSelectedById || listParsedState.initialState.selectedById; + const handler: ListOnItemClick = (arg, e) => { + const payload = {id: arg.id, list}; - const handleItemClick = React.useMemo(() => { - if (onItemClick) { - return (listItemId: ListItemId) => { - onItemClick?.({ - id: listItemId, - index: listParsedState.idToFlattenIndex[listItemId], - data: listParsedState.itemsById[listItemId], - expanded: - // eslint-disable-next-line no-nested-ternary - expandedById && listItemId in expandedById - ? expandedById[listItemId] - : listItemId in listParsedState.initialState.expandedById - ? listParsedState.initialState.expandedById[listItemId] - : defaultGroupsExpanded, - disabled: disabledById - ? Boolean(disabledById[listItemId]) - : Boolean(listParsedState.initialState.disabledById[listItemId]), - selected: selectedById - ? Boolean(selectedById[listItemId]) - : Boolean(listParsedState.initialState.selectedById[listItemId]), + if (propsOnItemClick) { + propsOnItemClick?.(payload, e); + } else { + const baseOnClick = getListItemClickHandler({list, multiple}); - context: { - isLastItem: - listParsedState.visibleFlattenIds[ - listParsedState.visibleFlattenIds.length - 1 - ] === listItemId, - groupState: listParsedState.groupsState[listItemId], - itemState: listParsedState.itemsState[listItemId], - }, - }); - }; - } + baseOnClick(payload, e); + } + }; - return undefined; - }, [ - defaultGroupsExpanded, - disabledById, - expandedById, - selectedById, - listParsedState, - onItemClick, - ]); + return handler; + }, [propsOnItemClick, list, multiple]); useListKeydown({ containerRef, - onItemClick: handleItemClick, - ...listParsedState, - activeItemId, - disabledById, - setActiveItemId, + onItemClick, + list, }); - const renderItem: TreeListRenderContainerProps['renderItem'] = ( + const renderItem: TreeListContainerProps['renderItem'] = ( itemId, index, renderContainerProps, @@ -114,13 +72,8 @@ export const TreeList = ({ size, multiple, mapItemDataToProps, - onItemClick: handleItemClick, - ...listParsedState, - expandedById, - disabledById, - activeItemId, - selectedById, - defaultExpanded: defaultGroupsExpanded, + onItemClick, + list, }); if (propsRenderItem) { @@ -130,6 +83,7 @@ export const TreeList = ({ context: renderState.context, index, renderContainerProps, + list, }); } @@ -143,12 +97,7 @@ export const TreeList = ({ size, containerRef, className: b(null, className), - ...listParsedState, - expandedById, - disabledById, - activeItemId, - selectedById, + list, renderItem, - getItemId, }); }; diff --git a/src/components/TreeList/__stories__/Docs.mdx b/src/components/TreeList/__stories__/Docs.mdx new file mode 100644 index 0000000000..e3c76f1cd5 --- /dev/null +++ b/src/components/TreeList/__stories__/Docs.mdx @@ -0,0 +1,7 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; + +import TreeListDocs from './TreeListDocs.md?raw'; + + + +{TreeListDocs} diff --git a/src/components/TreeList/__stories__/TreeList.mdx b/src/components/TreeList/__stories__/TreeList.mdx deleted file mode 100644 index 7a16ad7570..0000000000 --- a/src/components/TreeList/__stories__/TreeList.mdx +++ /dev/null @@ -1,425 +0,0 @@ -import {Meta} from '@storybook/addon-docs'; - - - -# TreeList - -The basic component for working with lists, including tree-like ones. Under the hood, it uses the [useList](/docs/unstable-uselist--docs). To manage the state, it is recommended to use the [useListState](/docs/unstable-uselist--docs#useliststate) hook. - -`Storybook` provides complex examples how to use this components from this documentation. - -## Props: - -- [items](#items); -- [mapItemDataToProps](#mapitemdatatoprops); -- [qa](#qa); -- [id](#id); -- [containerRef](#containerref); -- [className](#classname); -- [multiple](#multiple); -- [size](#size-available-options); -- [defaultGroupsExpanded](#defaultgroupsexpanded); -- [getItemId](#getItemId); -- [renderItem](#renderitem); -- [renderContainer](#rendercontainer); -- [onItemClick](#onitemclick); -- [...useListState](/docs/unstable-uselist--docs#useliststate) - -## Quick start: - -### Basic example: - -```tsx -import { - type unstable_ListItemType as ListItemType, - unstable_TreeList as TreeList, -} from '@gravity-ui/uikit/unstable'; - -const items: ListItemType[] = ['one', 'two', 'free', 'four', 'five']; - - ({title: item})} />; -``` - -### Example with state: - -```tsx -import { - type unstable_ListItemType as ListItemType, - unstable_TreeList as TreeList, - unstable_useListState as useListState, -} from '@gravity-ui/uikit/unstable'; - -const items: ListItemType[] = [ - {title: 'one'}, - {title: 'two'}, - {title: 'free'}, - {title: 'four'}, - {title: 'five'}, -]; - -const Component = () => { - const listState = useListState(); - - const handleItemClick: TreeListOnItemClick = ({id, disabled, groupState}) => { - if (disabled) return; - - if (groupState) { - listState.setExpanded((prevState) => ({ - ...prevState, - [id]: id in prevState ? !prevState[id] : false, - })); - } else { - listState.setSelected((prevState) => ({ - [id]: !prevState[id], - })); - } - - listState.setActiveItemId(id); - }; - - return ( - ({title})} - /> - ); -}; -``` - -> If you want to display the nodes of the list as regular elements without the possibility of hiding the folded elements of the sheet, then just do not pass the `expandedById` object from the state to the component itself: - -```ts -const {expandedById, setExpandedById, ...listState} = useListState(); - - -``` - -## Component props: - -### items - -Array of list items. More details about data structure and properties you can find [here](/docs/unstable-uselist--docs#items-supported-data-structure); - -### mapItemDataToProps - -Map list item data structire to `ListItemView` [props](/docs/unstable-uselist--docs#listitemview); - -### containerRef - -Pass a ref to pass a link to the DOM element of the container. For example, in order to control the focus of the list to activate keyboard navigation support; - -```tsx -import React from 'react'; -import {Button, Alert} from '@gravity-ui/uikit'; -import { - type unstable_ListItemType as ListItemType, - unstable_TreeList sa TreeList, - unstable_useListState as useListState, -} from '@gravity-ui/uikit/unstable'; - -const items: ListItemType[] = [ - {data: {title: 'one'}}, - {data: {title: 'two'}}, - {data: {title: 'free'}}, - {data: {title: 'four'}}, - {data: {title: 'five'}}, -]; - -const Component = () => { - const containerRef = React.useRef(null); - const listState = useListState(); - - const handleItemClick: TreeListOnItemClick = ({ - id, - disabled, - selected, - expanded, - groupState, - }) => { - // ... - }; - - return ( - <> - - - ({title})} - /> - - ); -}; -``` - -### getItemId - -Generate an id for a list item depending on the list data. If it's necessary to have access to more custom management of the state of the list. The property is optional. - -```tsx -const items = [ - {data: {id: 'id-1', title: 'some title 1'}, children: [...]}, - {data: {id: 'id-2', title: 'some title 2'}, children: [...]}, -]; - - id} /> -``` - -### qa - -Set `qa` attribute for the container and sheet elements. `qa` attribute is also passed to the `ListItemView`. - -> Use the [getListItemQa](/docs/unstable-uselist--docs#getlistitemqa) is used to generate `qa` attributes in list items; also use this function in tests to compute a unique data attribute to access a specific list item - - ```ts - await locator.getByTestId(getListItemQa('some-list-qa', '0')); // select the first item in the list if auto-generated IDs are not used - ``` - -### className - -Pass custom CSS class for the list container. - -### id - -Set a custom id data attribute. By default, a unique identifier will be assigned. - -### multiple - -This prop is necessary for the correct view of the selected elements since the state of the selected elements is controlled from the parent component. - -### setActiveItemId - -Required for correct keyboard interactions. While navigating through the keyboard, you need to set the next active element. - -### defaultGroupsExpanded - -Control the default expanded state of items' groups. Default - `true`. - -### renderItem - -Redefine the rendering of a list item. For example, add dividers between list items or wrap an item in a link component. As a view component to display a list item, use [ListItemView](/docs/unstable-uselist--docs#listitemview); - -```tsx -import { - unstable_TreeList as TreeList, - unstable_ListItemView as ListItemView, -} from '@gravity-ui/uikit/unstable'; - - { - return ; - }} -/>; -``` - -#### renderItem function argument object: - -```tsx -type ListItemSize = 's' | 'm' | 'l' | 'xl'; - -interface RenderItemProps { - /** - * access to the original object with the data of the list element - */ - data: T; - /** - * ordinal index of the element, taking into account that with a tree-like data structure, the list elements have a flatten representation; - */ - index: number; - /** - * default props generated by the component taking into account the state (whether the element is selected or not, active, disclosed). The set of returned passes corresponds to the result of the function execution [getItemRenderState](/docs/unstable-uselist--docs#item-state-props) - */ - props: { - // item id; - id: string; - // qa attribute for tests - qa?: string; - // item size; - size: ListItemSize; - // expanded state if item group; - expanded?: boolean; - // is item active - active: boolean; - // item nest level; - indentation: number; - // is item disabled; - disabled: boolean; - // is item selected; - selected?: boolean; - // on item click handle if exists; - onClick?(): void; - // affects the view of the selected items; - hasSelectionIcon?: boolean; - // one required field of result `mapItemDataToProps` function work; - title: React.ReactNode; - }; - /** - * during `renderContainer` props you can pass render container context props to items; - */ - renderContainerProps?: Object; - /** - * useful information about the current list item: - */ - context: { - // meta info about item - itemState: { - // integer number, representing nested list level - indentation: number; - // `id` of parent list item if it exists - parentId?: string; - }; - // An optional parameter. If the list item is also the first item of the nested list - groupState: { - // array of `id` of nested list items; - childrenIds: string[]; - }; - // is the current item the last one in the list - isLastItem: boolean; - }; -} -``` - -> Important! Absolutely all the props for [ListItemView](/docs/unstable-uselist--docs#listitemview) can be redefined in the renderItem method. This is the preferred method for changing the view of the list elements. - -### renderContainer - -Render custom list container. - -```tsx -import { - unstable_TreeList as TreeList, - unstable_ListContainerView as ListContainerView, -} from '@gravity-ui/uikit/unstable'; - -) => { - return ( - - computeItemSize(size)} - > - {(id, index) => - renderItem( - id, - index, - _, // here you can optionally pass any props depending of render context */, - ) - } - - - ); - }} -/>; -``` - -### onItemClick - -Item's click callback. It also will be called on keyboard actions. - -```tsx -import {unstable_TreeList as TreeList} from '@gravity-ui/uikit/unstable'; - - { - // just do it! - }} -/>; -``` - -#### onItemClick function argument object: - -```tsx -type OnItemClick = (props: OnItemClickProps) => void; - -interface OnItemClickProps { - /** - * `id` of the current element; - */ - id: string; - /** - * access to the original payload (`T`) list item; - */ - data: T; - /** - * the ordinal index of the element, taking into account that with a tree-like data structure, the list elements have a flatten representation; - */ - index: number; - /** - * whether the item is selected or not; - */ - selected: boolean; - /** - * is the element disabled; - */ - disabled: boolean; - /** - * are nested child elements hidden; - */ - expanded: boolean; - /** - * useful information about the current list item: - */ - context: { - // meta info about item - itemState: { - // integer number, representing nested list level - indentation: number; - // `id` of parent list item if it exists - parentId?: string; - }; - // An optional parameter. If the list item is also the first item of the nested list - groupState: { - // array of `id` of nested list items; - childrenIds: string[]; - }; - // is the current item the last one in the list - isLastItem: boolean; - }; -} -``` diff --git a/src/components/TreeList/__stories__/TreeList.stories.tsx b/src/components/TreeList/__stories__/TreeList.stories.tsx index c3aa652e20..d4b1d213d2 100644 --- a/src/components/TreeList/__stories__/TreeList.stories.tsx +++ b/src/components/TreeList/__stories__/TreeList.stories.tsx @@ -11,7 +11,7 @@ import {WithGroupSelectionAndCustomIconStory} from './stories/WithGroupSelection import {WithItemLinksAndActionsStory} from './stories/WithItemLinksAndActionsStory'; export default { - title: 'Unstable/TreeList', + title: 'Lab/TreeList', component: TreeList, } as Meta; diff --git a/src/components/TreeList/__stories__/TreeListDocs.md b/src/components/TreeList/__stories__/TreeListDocs.md new file mode 100644 index 0000000000..1655bfbe67 --- /dev/null +++ b/src/components/TreeList/__stories__/TreeListDocs.md @@ -0,0 +1,100 @@ +# TreeList + +The basic component for working with lists, including tree-like. Under the hood, it uses the [useList](/docs/lab-uselist--docs). + +`Storybook` provides complex examples how to use this components from this documentation. + +## Quick start: + +### Import: + +```tsx +import {unstable_TreeList as TreeList} from '@gravity-ui/uikit/unstable'; +``` + +### Basic example: + +```tsx +import { + type unstable_ListItemType as ListItemType, + unstable_TreeList as TreeList, + unstable_useList as useList, +} from '@gravity-ui/uikit/unstable'; + +const items: ListItemType[] = ['one', 'two', 'free', 'four', 'five']; + +const list = useList({items}); + + ({title: item})} />; +``` + +### Example with state: + +```tsx +import { + type unstable_ListItemType as ListItemType, + unstable_TreeList as TreeList, + unstable_useList as useList, +} from '@gravity-ui/uikit/unstable'; + +const items: ListItemType[] = [ + {title: 'one'}, + {title: 'two'}, + {title: 'free'}, + {title: 'four'}, + {title: 'five'}, +]; + +const Component = () => { + const list = useList({items}); + + const handleItemClick = ({id}) => { + list.state.setSelected({[id]: true}); + }; + + return ( + ({title})} + /> + ); +}; +``` + +## Props: + +| Name | Description | Type | Default | +| :----------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------: | :-----: | +| list | result of [list](/docs/lab-uselist--docs#uselist) hook. | `UseListResult` | | +| containerRef | a reference to the DOM element of the List container inside which to search for its elements; | `React.RefObject` | | +| qa | Selector for tests | `string` | | +| size | The size of the element. This also affects the rounding radius of the list element | `s \| m \| l \| xl` | `m` | +| mapItemDataToProps | Map list item data (`T`) to `ListItemView` props | `(data: T) => ListItemCommonProps` | | +| multiple | One or multiple elements selected list | `boolean` | `false` | +| id | id attribute | `string` | | +| renderItem | Redefine the rendering of a list item. For example, add dividers between list items or wrap an item in a link component. As a view component to display a list item, use [ListItemView](/docs/lab-uselist--docs#listitemview); | `(props: TreeListRenderItem) => React.JSX.Element` | | +| renderContainer | Render custom list container. | `(props: TreeListRenderContainer) => React.JSX.Element` | | +| onItemClick | Override default on click behavior. Pass `null` to disable on click handler | `(props: {id: ListItemId; list: UseListResult}, e: React.SyntheticEvent) => void \| null` | | + +### TreeListRenderItem props: + +| Name | Description | Type | Default | +| :------------------- | :-------------------------------------------------------------------------- | :------------------------: | :---------: | +| data | List item data | `T` | | +| props | Prepared `ListItemView` [props](/docs/lab-uselist--docs#listitemview) | `ListItemViewProps` | +| context | List item context [props](/docs/lab-uselist--docs#listitemlistcontextprops) | `ListItemListContextProps` | | +| list | result of [list](/docs/lab-uselist--docs#uselist) hook. | `UseListResult` | | +| index | Index order in flatted visible id's | `number` | | +| renderContainerProps | Data from container rendered context if needed | `P` | `undefined` | + +### TreeListRenderContainer props: + +| Name | Description | Type | Default | +| :----------- | :-------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------: | :-----: | +| id | Id attribute | `string` | | +| list | result of `useList` hook | `UseListResult` | | +| size | The size of the element. This also affects the rounding radius of the list element | `s \| m \| l \| xl` | `m` | +| className | Class name to mix with | `string` | | +| containerRef | a reference to the DOM element of the List container inside which to search for its elements; | `React.RefObject` | | +| renderItem | Render item interface to implement | `(props: {id: ListItemId; index: number;renderContainerProps?: Object,}) => React.JSX.Element` | | diff --git a/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx index 553ca73b8b..71a3bff527 100644 --- a/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx +++ b/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx @@ -2,18 +2,18 @@ import React from 'react'; import {ListContainerView, computeItemSize} from '../../../useList'; import {VirtualizedListContainer} from '../../../useList/__stories__/components/VirtualizedListContainer'; -import type {TreeListRenderContainerProps} from '../../types'; +import type {TreeListContainerProps} from '../../types'; // custom container renderer example export const RenderVirtualizedContainer = ({ id, qa, containerRef, - visibleFlattenIds, + list, renderItem, size, className, -}: TreeListRenderContainerProps) => { +}: TreeListContainerProps) => { return ( ({ extraProps={{style: {padding: 0}}} > computeItemSize(size)} > {renderItem} diff --git a/src/components/TreeList/__stories__/stories/DefaultStory.tsx b/src/components/TreeList/__stories__/stories/DefaultStory.tsx index 69a610b0ed..e3a63f60e9 100644 --- a/src/components/TreeList/__stories__/stories/DefaultStory.tsx +++ b/src/components/TreeList/__stories__/stories/DefaultStory.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Text} from '../../../Text'; import {Flex} from '../../../layout'; -import {ListItemView} from '../../../useList'; +import {useList} from '../../../useList'; import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeList} from '../../TreeList'; import type {TreeListProps} from '../../types'; @@ -19,30 +19,34 @@ export interface DefaultStoryProps export const DefaultStory = ({itemsCount = 5, ...props}: DefaultStoryProps) => { const items = React.useMemo(() => createRandomizedData({num: itemsCount}), [itemsCount]); + const listWithGroups = useList({items}); + + const listWithNoGroups = useList({ + items, + withExpandedState: false, + }); + return ( Default TreeList - + - To remove default group view, override corresponding (expanded) prop in - renderItem method + List with `withExpandedState` false option in list state { - // if item group - if (groupState) { - props.expanded = undefined; - } - - return ; - }} /> diff --git a/src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx b/src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx index cca4c5d75d..b0dda4d8a7 100644 --- a/src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx +++ b/src/components/TreeList/__stories__/stories/InfinityScrollStory.tsx @@ -3,79 +3,54 @@ import React from 'react'; import {Label} from '../../../Label'; import {Loader} from '../../../Loader'; import {Flex, spacing} from '../../../layout'; -import {ListItemView, useListState} from '../../../useList'; +import {ListItemView, useList} from '../../../useList'; import {IntersectionContainer} from '../../../useList/__stories__/components/IntersectionContainer/IntersectionContainer'; import {useInfinityFetch} from '../../../useList/__stories__/utils/useInfinityFetch'; import {TreeList} from '../../TreeList'; -import type {TreeListOnItemClick, TreeListProps} from '../../types'; +import type {TreeListProps} from '../../types'; import {RenderVirtualizedContainer} from '../components/RenderVirtualizedContainer'; +interface Entity { + title: string; +} + function identity(value: T): T { return value; } export interface InfinityScrollStoryProps extends Omit< - TreeListProps<{title: string}>, + TreeListProps, 'value' | 'onUpdate' | 'items' | 'multiple' | 'size' | 'mapItemDataToProps' > { itemsCount?: number; } -export const InfinityScrollStory = ({itemsCount = 5, ...storyProps}: InfinityScrollStoryProps) => { - const listState = useListState(); - - const handleItemClick: TreeListOnItemClick<{title: string}> = ({ - id, - disabled, - expanded, - selected, - context: {groupState}, - }) => { - if (disabled) return; - - listState.setActiveItemId(id); - - if (groupState) { - listState.setExpanded((prevState) => ({ - ...prevState, - [id]: !expanded, - })); - } else { - listState.setSelected((prevState) => ({ - ...prevState, - [id]: !selected, - })); - } - }; +const multiple = true; +export const InfinityScrollStory = ({itemsCount = 3, ...storyProps}: InfinityScrollStoryProps) => { const { data: items = [], onFetchMore, canFetchMore, isLoading, - } = useInfinityFetch<{title: string}>(itemsCount, true); + } = useInfinityFetch(itemsCount, true); + + const list = useList({items}); return ( - {...storyProps} - {...listState} + size="l" + list={list} mapItemDataToProps={identity} - items={items} - multiple - onItemClick={handleItemClick} - renderItem={({data, props, context: {isLastItem, groupState}}) => { + multiple={multiple} + renderItem={({props, context: {isLastItem, childrenIds}}) => { const node = ( {groupState.childrenIds.length} - ) : undefined - } + endSlot={childrenIds ? : undefined} /> ); diff --git a/src/components/TreeList/__stories__/stories/WithDisabledElementsStory.tsx b/src/components/TreeList/__stories__/stories/WithDisabledElementsStory.tsx index fd68c78204..d79539aaa9 100644 --- a/src/components/TreeList/__stories__/stories/WithDisabledElementsStory.tsx +++ b/src/components/TreeList/__stories__/stories/WithDisabledElementsStory.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {Button} from '../../../Button'; import {Flex} from '../../../layout'; -import {useListState} from '../../../useList'; +import {getListItemClickHandler, useList} from '../../../useList'; import type {ListItemType} from '../../../useList'; import {TreeList} from '../../TreeList'; import type {TreeListProps} from '../../types'; @@ -12,14 +12,15 @@ export interface WithDisabledElementsStoryProps const items: ListItemType<{text: string}>[] = [ { - text: 'one', + text: 'default disabled', disabled: true, }, { text: 'two', }, { - text: 'free', + text: 'default selected', + selected: true, }, { text: 'four', @@ -29,8 +30,8 @@ const items: ListItemType<{text: string}>[] = [ }, ]; -export const WithDisabledElementsStory = ({...props}: WithDisabledElementsStoryProps) => { - const {disabledById: _disabledById, setDisabled: _setDisabled, ...listState} = useListState(); +export const WithDisabledElementsStory = ({...storyProps}: WithDisabledElementsStoryProps) => { + const list = useList({items}); const containerRef = React.useRef(null); return ( @@ -46,14 +47,15 @@ export const WithDisabledElementsStory = ({...props}: WithDisabledElementsStoryP to control from keyboard ({title: text})} - onItemClick={({data, id, selected}) => { - listState.setSelected({[id]: !selected}); - alert(`Clicked by item with id :"${id}" and data: ${JSON.stringify(data)}`); + onItemClick={({id}) => { + getListItemClickHandler({list})({id}); + alert( + `Clicked by item with id :"${id}" and data: ${JSON.stringify(list.structure.itemsById[id])}`, + ); }} /> diff --git a/src/components/TreeList/__stories__/stories/WithDndListStory.tsx b/src/components/TreeList/__stories__/stories/WithDndListStory.tsx index 1c4f454d0d..ee4d963a23 100644 --- a/src/components/TreeList/__stories__/stories/WithDndListStory.tsx +++ b/src/components/TreeList/__stories__/stories/WithDndListStory.tsx @@ -11,7 +11,7 @@ import type { } from 'react-beautiful-dnd'; import {Icon} from '../../../Icon'; -import {ListContainerView, ListItemView, useListState} from '../../../useList'; +import {ListContainerView, ListItemView, useList} from '../../../useList'; import type {ListItemViewProps} from '../../../useList'; import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {reorderArray} from '../../../useList/__stories__/utils/reorderArray'; @@ -46,7 +46,12 @@ export interface WithDndListStoryProps export const WithDndListStory = (storyProps: WithDndListStoryProps) => { const [items, setItems] = React.useState(randomItems); const containerRef = React.useRef(null); - const listState = useListState(); + + const list = useList({ + items, + // you can omit this prop here. If prop `id` passed, TreeSelect would take it by default + getItemId: ({id}) => id, + }); React.useLayoutEffect(() => { containerRef?.current?.focus(); @@ -54,7 +59,7 @@ export const WithDndListStory = (storyProps: WithDndListStoryProps) => { const renderContainer: TreeListRenderContainer = ({ renderItem, - visibleFlattenIds, + list, containerRef, id, }) => { @@ -64,7 +69,7 @@ export const WithDndListStory = (storyProps: WithDndListStoryProps) => { reorderArray(currentItems, source.index, destination.index), ); - listState.setActiveItemId(`${destination.index}`); + list.state.setActiveItemId(`${destination.index}`); } }; @@ -78,7 +83,7 @@ export const WithDndListStory = (storyProps: WithDndListStoryProps) => { rubric: DraggableRubric, ) => { return renderItem( - visibleFlattenIds[rubric.source.index], + list.structure.visibleFlattenIds[rubric.source.index], rubric.source.index, { provided, @@ -93,7 +98,7 @@ export const WithDndListStory = (storyProps: WithDndListStoryProps) => { {...droppableProvided.droppableProps} ref={droppableProvided.innerRef} > - {visibleFlattenIds.map((listItemId, index) => + {list.structure.visibleFlattenIds.map((listItemId, index) => renderItem(listItemId, index), )} {droppableProvided.placeholder} @@ -142,22 +147,10 @@ export const WithDndListStory = (storyProps: WithDndListStoryProps) => { return ( ({title: someRandomKey})} - // you can omit this prop here. If prop `id` passed, TreeSelect would take it by default - getItemId={({id}) => id} - onItemClick={({id, disabled, context: {groupState}}) => { - if (!groupState && !disabled) { - listState.setSelected((prevState) => ({ - [id]: !prevState[id], - })); - - listState.setActiveItemId(id); - } - }} renderContainer={renderContainer} renderItem={renderItem} /> diff --git a/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx b/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx index 53d1bf4525..a2463b8684 100644 --- a/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx +++ b/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx @@ -4,17 +4,18 @@ import {Button} from '../../../Button'; import {Text} from '../../../Text'; import {TextInput} from '../../../controls'; import {Flex, spacing} from '../../../layout'; -import {useListFilter, useListState} from '../../../useList'; +import {useList, useListFilter} from '../../../useList'; import {createRandomizedData} from '../../../useList/__stories__/utils/makeData'; import {TreeList} from '../../TreeList'; -import type {TreeListProps, TreeListRenderContainerProps} from '../../types'; +import type {TreeListContainerProps, TreeListProps} from '../../types'; import {RenderVirtualizedContainer} from '../components/RenderVirtualizedContainer'; +interface Entity { + title: string; +} + export interface WithFiltrationAndControlsStoryProps - extends Omit< - TreeListProps<{title: string}>, - 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps' - > { + extends Omit, 'value' | 'onUpdate' | 'items' | 'mapItemDataToProps'> { itemsCount?: number; } @@ -24,8 +25,8 @@ export const WithFiltrationAndControlsStory = ({ }: WithFiltrationAndControlsStoryProps) => { const {items, renderContainer} = React.useMemo(() => { const baseItems = createRandomizedData({num: itemsCount}); - const containerRenderer = (props: TreeListRenderContainerProps<{title: string}>) => { - if (props.items.length === 0 && baseItems.length > 0) { + const containerRenderer = (props: TreeListContainerProps) => { + if (props.list.structure.items.length === 0 && baseItems.length > 0) { return ( Nothing found @@ -39,10 +40,10 @@ export const WithFiltrationAndControlsStory = ({ return {items: baseItems, renderContainer: containerRenderer}; }, [itemsCount]); - const listState = useListState(); - const filterState = useListFilter({items}); + const list = useList({items: filterState.items}); + return ( { - if (disabled) return; - - if (groupState) { - listState.setExpanded((prevState) => ({ - ...prevState, - [id]: id in prevState ? !prevState[id] : false, - })); - } else { - listState.setSelected((prevState) => - treeSelectProps.multiple - ? { - ...prevState, - [id]: !prevState[id], - } - : { - [id]: !prevState[id], - }, - ); - } - - listState.setActiveItemId(id); - }} + list={list} mapItemDataToProps={(x) => x} renderContainer={renderContainer} - items={filterState.items} /> - - {items.map((item, index) => ( - - {(id) => { - const {props, context} = getItemRenderState({ - id, - size, - onItemClick, - mapItemDataToProps: (x) => x, - ...list, - ...listState, - }); + { + const {props, context} = getItemRenderState({ + id, + size, + onItemClick, + mapItemDataToProps: (x) => x, + list, + }); - return ( - - ); - }} - - ))} - + return ; + }} + /> ); diff --git a/src/components/useList/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx index e05305dcff..7a1e1ef61b 100644 --- a/src/components/useList/__stories__/components/RecursiveList.tsx +++ b/src/components/useList/__stories__/components/RecursiveList.tsx @@ -2,15 +2,14 @@ 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 {ListContainer} from '../../components/ListContainer'; +import {ListItemView} from '../../components/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 type {ListItemSize} from '../../types'; import {getItemRenderState} from '../../utils/getItemRenderState'; +import {getListItemClickHandler} from '../../utils/getListItemClickHandler'; import {createRandomizedData} from '../utils/makeData'; export interface RecursiveListProps { @@ -29,38 +28,14 @@ export const RecursiveList = ({size, itemsCount, 'aria-label': ariaLabel}: Recur const filterState = useListFilter({items}); - const listState = useListState(); + const list = useList({items: filterState.items}); - 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], - ); + const onItemClick = getListItemClickHandler({list}); useListKeydown({ containerRef, onItemClick, - ...list, - ...listState, + list, }); return ( @@ -73,33 +48,23 @@ export const RecursiveList = ({size, itemsCount, 'aria-label': ariaLabel}: Recur // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus /> - - {filterState.items.map((item, index) => ( - - {(id) => { - const {props, context} = getItemRenderState({ - id, - size, - onItemClick, - multiple: true, - mapItemDataToProps: (x) => x, - ...list, - ...listState, - }); + { + const {props, context} = getItemRenderState({ + id, + size, + onItemClick, + multiple: true, + mapItemDataToProps: (x) => x, + list, + }); - return ( - - ); - }} - - ))} - + return ; + }} + /> ); }; diff --git a/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx b/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx index 9d18048fdf..6a0b9060a3 100644 --- a/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx +++ b/src/components/useList/__stories__/components/VirtualizedListContainer/VirtualizedListContainer.async.tsx @@ -15,7 +15,7 @@ export const VirtualizedListContainer = (props: ListContainerRenderProps) return ( + } diff --git a/src/components/useList/__stories__/docs/compute-item-size.md b/src/components/useList/__stories__/docs/compute-item-size.md new file mode 100644 index 0000000000..77eb55c9b9 --- /dev/null +++ b/src/components/useList/__stories__/docs/compute-item-size.md @@ -0,0 +1,19 @@ +### computeItemSize; + +Utility to compute list item height: + +#### Usage example: + +```tsx + + computeItemSize( + // list size + size, + // has subrows + Boolean(get(itemsById[visibleFlattenIds[index]], 'subtitle')), + ) + } +/> +``` diff --git a/src/components/useList/__stories__/docs/get-item-render-state.md b/src/components/useList/__stories__/docs/get-item-render-state.md new file mode 100644 index 0000000000..da4a87325c --- /dev/null +++ b/src/components/useList/__stories__/docs/get-item-render-state.md @@ -0,0 +1,93 @@ +### getItemRenderState; + +Map list state to `ListItemView` render props; + +```tsx +import { + unstable_ListItemView as ListItemView, + unstable_getItemRenderState as getItemRenderState, + unstable_useListState as useListState, + unstable_useList as useList, +} from '@gravity-ui/uikit/unstable'; + +const list = useList({items: [...]}); +const onItemClick = getListItemClickHandler({list}); + +const {data, props, context} = getItemRenderState({ + qa: 'some-qa-id', + id, + multiple: true, + size, // list size + onItemClick, + mapItemDataToProps: (item) => ({title: item.title}), + list, +}); + +return ; +``` + +#### Props: + +| Name | Description | Type | Default | +| :----------------- | :--------------------------------------------------------------------------------- | :------------------------------------------------------------: | :-----: | +| id | `id` of list item | `ListItemId` | | +| list | result of `useList` hook | `UseListResult` | | +| multiple | One or multiple elements selected list | `boolean` | | +| onItemClick | Optional on click handler | `(payload :{id: ListItemId}, e: React.SyntheticEvent) => void` | | +| size | The size of the element. This also affects the rounding radius of the list element | `s \| m \| l \| xl` | `m` | +| mapItemDataToProps | Map list item data (`T`) to `ListItemView` props | `(data: T) => ListItemCommonProps` | | + +##### ListItemCommonProps + +| Name | Type | Note | +| :-------- | :---------------: | :------: | +| title | `React.ReactNode` | required | +| subtitle | `React.ReactNode` | optional | +| startSlot | `React.ReactNode` | optional | +| endSlot | `React.ReactNode` | optional | + +#### Returns: + +| Name | Description | Type | +| :------ | :----------------------------------------------: | :------------------------: | +| data | Row list item data | `T` | +| props | Prepared props for `ListItemView` | `ListItemViewProps` | +| context | List item state form `list` picked by current id | `ListItemListContextProps` | + +##### ListItemListContextProps + +| Name | Description | Type | +| :---------- | :----------------------------------------------------------: | :------------: | +| indentation | Item nested level | `number` | +| parentId | Optional. Link to parent group node if current node is child | `ListItemId` | +| childrenIds | Optional. Exists is list item a group node | `ListItemId[]` | + +#### Usage example: + +```tsx +import { + unstable_ListContainerView as ListItemRecursiveRenderer, + unstable_ListItemView as ListItemView, + unstable_getItemRenderState as getItemRenderState, + unstable_useList as useList, +} from '@gravity-ui/uikit/unstable'; + +const list = useList({items}); +const onItemClick = () => {}; + + + {(id) => { + const {props} = getItemRenderState({ + qa: 'some-qa-id', + id, + multiple: false, + size, // list size + onItemClick, + mapItemDataToProps, + list, + }); + + return ; + }} +; +``` diff --git a/src/components/useList/__stories__/docs/get-list-item-click-handler.md b/src/components/useList/__stories__/docs/get-list-item-click-handler.md new file mode 100644 index 0000000000..5283516a4a --- /dev/null +++ b/src/components/useList/__stories__/docs/get-list-item-click-handler.md @@ -0,0 +1,34 @@ +### getListItemClickHandler + +Basic click logic implemented for you + +```tsx +import {unstable_getListItemClickHandler as getListItemClickHandler} from '@gravity-ui/uikit/unstable'; +``` + +#### props: + +| Name | Description | Type | Default | +| :------- | :------------------------------------- | :-------------: | :-----: | +| list | result of `useList` hook | `UseListResult` | | +| multiple | One or multiple elements selected list | `boolean` | | + +#### Result: + +onClick callback `(payload: {id: listItemId}) => void`; + +#### Usage example: + +```tsx +const filterState = useListFilter({items: [...]}); + +const list = useList({items: filterState.items}); + +const onItemClick = getListItemClickHandler({list}); + +useListKeydown({ + containerRef, + onItemClick, + list, +}); +``` diff --git a/src/components/useList/__stories__/docs/get-list-item-qa.md b/src/components/useList/__stories__/docs/get-list-item-qa.md new file mode 100644 index 0000000000..e020b65bb0 --- /dev/null +++ b/src/components/useList/__stories__/docs/get-list-item-qa.md @@ -0,0 +1,12 @@ +### getListItemQa + +Function is used to generate `qa` attributes in list items; +Also use this function in tests to create a unique data attribute for accessing a specific list item. + +#### Usage example: + +```ts +import {unstable_getListItemQa as getListItemQa} from '@gravity-ui/uikit/unstable'; + +await locator.getByTestId(getListItemQa('some-list-qa', '0')); // select the first item in the list if auto-generated `id` are used +``` diff --git a/src/components/useList/__stories__/docs/get-list-parsed-state.md b/src/components/useList/__stories__/docs/get-list-parsed-state.md new file mode 100644 index 0000000000..0e4c79c2d7 --- /dev/null +++ b/src/components/useList/__stories__/docs/get-list-parsed-state.md @@ -0,0 +1,14 @@ +### getListParsedState; + +Used under the hood of `useList().structure` property. Use it if you need to extract initial list state form declaration: + +#### Usage example: + +```tsx +import {unstable_getListParsedState as getListParsedState} from '@gravity-ui/uikit/unstable'; + +// custom controlled state from computed initial state +const [expandedById, setExpanded] = React.useState( + () => getListParsedState(items).initialState.expandedById, +); +``` diff --git a/src/components/useList/__stories__/docs/list-container-view.md b/src/components/useList/__stories__/docs/list-container-view.md new file mode 100644 index 0000000000..46285a926b --- /dev/null +++ b/src/components/useList/__stories__/docs/list-container-view.md @@ -0,0 +1,23 @@ +### ListContainerView + +The default container for all custom lists. Contains all html attributes and styles for quick use in your projects. + +#### Props: + +| Name | Description | Type | Default | +| :---------- | :----------------------------------------------------------------------------------------------------------- | :-------------------: | :-----: | +| id | Optional id attribute | `string` | | +| style | Inline styles if needed | `React.CSSProperties` | | +| className | Custom class name to mix with | `string` | | +| fixedHeight | Removes default `overflow: auto` from container and set fixed container height (`--g-list-height` = `300px`) | `boolean` | | + +#### Usage example: + +```tsx +const containerRef = React.useRef(null); + + + + +; +``` diff --git a/src/components/useList/__stories__/docs/list-item-view.md b/src/components/useList/__stories__/docs/list-item-view.md new file mode 100644 index 0000000000..d0b6bbbe70 --- /dev/null +++ b/src/components/useList/__stories__/docs/list-item-view.md @@ -0,0 +1,66 @@ +### ListItemView + +```tsx +import {unstable_ListItemView as ListItemView} from '@gravity-ui/uikit/unstable'; +``` + +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 + +#### Usage example: + +```tsx +import { + type unstable_ListItemType as ListItemType, + unstable_ListItemView as ListItemView, +} from '@gravity-ui/uikit/unstable'; + +type Entity = {title: string, subtitle: string, icon: React.ReactNode}; + +const items: ListItemType[] = [ + {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 ( + + ) + }} + + ) +}; +``` + +#### Props: + +| Name | Description | Type | Default | +| :--------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------: | :-----: | +| id | Required prop. Set `[data-list-item="${id}"]` data attribute. By this it core list engine finds elements to scroll to. | `string` | | +| title | Base required prop to use. If passed string, applies default component styles according design system. Pass you own component if you wont custom behavior; | `React.ReactNode` | | +| subtitle | Slot under `title`. If passed string apply predefined styles. Or you can pass custom `React.ReactNode` to use you own behavior | `React.ReactNode` | | +| as | If needed, override `html` tag. By default - `li` | `HTMLElement` | `li` | +| size | The size of the element. This also affects the rounding radius of the list element | `s \| m \| l \| xl` | `m` | +| 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 | `number ` | | +| selected | The selected state of the component | `boolean ` | | +| 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 | `boolean ` | | +| disabled | The disabled state. It also prevents clicking on an element | `boolean ` | | +| activeOnHover | directly control hover behavior | `boolean ` | | +| indentation | Affects the visual indentation of the element content | `number ` | | +| hasSelectionIcon | Show selected icon if selected and reserve space for this icon | `boolean ` | | +| onClick | On item click callback. If `disabled` option is `true` click don't appears | `() => void` | | +| startSlot | Custom slot before `title` | `React.ReactNode` | | +| endSlot | Custom slot before `title` | `React.ReactNode` | | +| style | Inline styles if needed | `React.CSSProperties` | | +| className | Custom class name to mix with | `string` | | +| expanded | Adds a visual representation of a group element if the value is different from `undefined` | `string \| undefined` | | +| dragging | manage view of dragging element. Required for draggable list implementation | `boolean` | | diff --git a/src/components/useList/__stories__/docs/list-recursive-renderer.md b/src/components/useList/__stories__/docs/list-recursive-renderer.md new file mode 100644 index 0000000000..71558379e0 --- /dev/null +++ b/src/components/useList/__stories__/docs/list-recursive-renderer.md @@ -0,0 +1,64 @@ +### ListRecursiveRenderer + +The basic "renderer" of the `List` elements. When rendering, it retains the nested html structure. + +#### Props: + +| Name | Description | Type | Default | +| :--------- | :------------------------------------ | :-------------------: | :-----: | +| itemSchema | Simplified list representation schema | `ItemSchema` | | +| children | Children React element | `React.ReactNode` | | +| style | Inline styles if needed | `React.CSSProperties` | | +| className | Custom class name to mix with | `string` | | + +##### ItemSchema + +```ts +export type ItemSchema = { + id: ListItemId; + index: number; + children?: ItemSchema[]; +}; +``` + +#### Usage example: + +```tsx +import { + unstable_ListItemRecursiveRenderer as ListItemRecursiveRenderer, + unstable_ListContainerView as ListContainerView, + unstable_ListItemView as ListItemView, + unstable_useList as useList, + unstable_useListItemsClick as useListItemsClick, + unstable_getItemRenderState as getItemRenderState, +} from '@gravity-ui/uikit/unstable'; + +const items: ListItemType[] = [ + {data: 'one'}, + {data: 'two', children: [{data: 'tree', children: [{data: 'four'}, {data: 'five'}]}]}, +]; + +function List() { + const list = useList({items}); + const onItemClick = useListItemsClick({items}); + + return ( + + {list.structure.itemsSchema.map((itemSchema, index) => ( + + {(id) => { + const {props} = getItemRenderState({ + id: String(i), + mapItemDataToProps: (title) => ({title}), + onItemClick, + list, + }); + + return ; + }} + + ))} + + ); +} +``` diff --git a/src/components/useList/__stories__/docs/scroll-to-list-item.md b/src/components/useList/__stories__/docs/scroll-to-list-item.md new file mode 100644 index 0000000000..692ebfddd9 --- /dev/null +++ b/src/components/useList/__stories__/docs/scroll-to-list-item.md @@ -0,0 +1,27 @@ +### scrollToListItem; + +Utility to scroll into list item view by id and ref on container DOM element: + +#### Usage example: + +```tsx +import { + unstable_ListContainerView as ListContainerView, + unstable_scrollToListItem as scrollToListItem, +} from '@gravity-ui/uikit/unstable'; + +const containerRef = React.useRef(null); +// restoring focus when popup opens +React.useLayoutEffect(() => { + if (open) { + containerRef.current?.focus(); + list.state.setActiveItemId(selectedId ?? list.structure.visibleFlattenIds[0]); + + if (selectedId) { + scrollToListItem(selectedId, containerRef.current); + } + } +}, [open]); +// ... +; +``` diff --git a/src/components/useList/__stories__/docs/use-list-filter.md b/src/components/useList/__stories__/docs/use-list-filter.md new file mode 100644 index 0000000000..277837e07f --- /dev/null +++ b/src/components/useList/__stories__/docs/use-list-filter.md @@ -0,0 +1,54 @@ +### useListFilter + +Basic tree like structure list filtration logic and utilities. To avoid implementing custom filtering logic from scratch, first use this hook + +```tsx +import {unstable_useListKeydown as useListFilter} from '@gravity-ui/uikit/unstable'; +``` + +#### Props: + +| Name | Description | Type | Default | +| :----------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------: | :-----: | +| items | Original array of list items, same us used in the `useList` hook | `listItemType[]` | | +| initialFilterValue | The initial value of the filter; | `string` | | +| 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; | `(value: string, item: T): boolean;` | | +| filterItems | Completely redefine the filtering logic; | `(value: string, items: ListItemType[]) => ListItemType[]` | | +| debounceTimeout | With what delay to apply the filtering result | `number` | `300` | + +#### Returns: + +| Name | Description | Type | +| :------------- | :------------------------------------------------ | :---------------------------------: | +| filterRef | Ref to the DOM element of the filtering component | `React.RefObject` | +| filter | Current filter value | `string` | +| reset | Method for resetting filter value | `() => void` | +| items | List of filtered list elements | `listItemType[]` | +| onFilterUpdate | Callback on changing filter value | `(filter: string) => void` | + +#### Usage example: + +```tsx +import { + unstable_useList as useList, + unstable_useListKeydown as useListFilter, +} from '@gravity-ui/uikit/unstable'; + +const List = () => { + const {items, filter, onFilterUpdate, filterRef} = useListFilter({ + items: [...] + }) + + const list = useList({items}) + + return ( + <> + + + ) +} +``` diff --git a/src/components/useList/__stories__/docs/use-list-keydown.md b/src/components/useList/__stories__/docs/use-list-keydown.md new file mode 100644 index 0000000000..6dfe88f7ef --- /dev/null +++ b/src/components/useList/__stories__/docs/use-list-keydown.md @@ -0,0 +1,32 @@ +### useListKeydown + +Keyboard support + +#### Props: + +| Name | Description | Type | Default | +| :----------- | :-------------------------------------------------------------------------------------------- | :------------------------------------------------------------: | :-----: | +| list | result of `useList` hook | `UseListResult` | | +| onItemClick | callback will be called when pressing the `Enter`, `Space` keys; | `(payload: {id: ListItemId}, e: React.SyntheticEvent) => void` | | +| containerRef | a reference to the DOM element of the List container inside which to search for its elements; | `React.RefObject` | | +| enabled | on/off keyboard support. Use it if you need to change the behavior in runtime; | `boolean` | | + +#### Usage example: + +```tsx +import { + unstable_useList as useList, + unstable_useListKeydown as useListKeydown, + unstable_getListItemClickHandler as getListItemClickHandler, +} from '@gravity-ui/uikit/unstable'; + +const containerRef = React.useRef(null); +const list = useList(...) +const handleItemClick = getListItemClickHandler({list}); + +useListKeydown({ + onItemClick, + containerRef, + list, +}) +``` diff --git a/src/components/useList/__stories__/docs/use-list.md b/src/components/useList/__stories__/docs/use-list.md new file mode 100644 index 0000000000..3b2bcdf5eb --- /dev/null +++ b/src/components/useList/__stories__/docs/use-list.md @@ -0,0 +1,136 @@ +### useList + +The main hook to use what provide you normalized representation of list items (`structure`) and list state (`state`). + +#### Props: + +| Name | Description | Type | Default | +| :------------------- | :------------------------------------------------------------------------- | :-----------------------: | :--------: | +| items | a flat or tree-like data structure, with`List` declaration | `ListItemType[]` | | +| getItemId | Allows you to generate an id for a list item depending on the list data | `(itemData: T) => string` | | +| defaultExpandedState | Default state for nodes with children items if `withExpandedState` is true | `expanded`, `closed` | `expanded` | +| withExpandedState | Is nodes with children's needed to be controlled | `boolean` | `true` | +| initialState | Initial state values | `Partial` | | +| controlledState | Way to override state by some controlled values. | `Partial` | | + +#### Result (UseListResult): + +| Name | Description | Type | +| :-------- | :----------------------------------------------------------------------------------- | :-------------: | +| state | List state to control and store current state values | `ListState` | +| structure | Normalized representation of list and some helpful data structures to work with list | `ListStructure` | + +#### ListState: + +| Name | Description | Type | +| :-------------- | :------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------: | +| selectedById | Key-value selected elements state | `Record` | +| disabledById | Key-value disabled elements state | `Record` | +| expandedById | Key-value expanded elements state. Available is only `withExpandedState` option of `useList` hook is `true` | `Record` | +| activeItemId | Active item id | `ListItemId`, `undefined` | +| setSelected | Method to handle selected state list items state | `(payload: Record) => void` , `(fn: (payload: Record) => void) => void` | +| setDisabled | Method to handle disable state list items state | `(payload: Record) => void` , `(fn: (payload: Record) => void) => void` | +| setExpanded | Method to handle expanded state list items state. Available is only `withExpandedState` option of `useList` hook is `true` | `(payload: Record) => void` , `(fn: (payload: Record) => void) => void` | +| setExpanded | Normalized representation of list and some helpful data structures to work with list | `ListStructure` | +| setActiveItemId | Method to handle current active list item state | `(listItemId: ListItemId or undefined) => void` | + +#### ListStructure: + +| Name | Description | Type | +| :---------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------: | +| items | Link for original `items` property | `ListItemType` | +| itemsState | List item state | `Record` | +| groupState | A normalized representation of metadata about a group if the item is both a list item and a group: | `Record` | +| itemsById | 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`getItemId` function. | `Record` | +| visibleFlattenIds | Sequential representation of list items by id, taking into account invisible elements inside collapsed groups. Items visibility determine by `expandedById` state | `ListItemId[]` | +| idToFlattenIndex | Auxiliary data structure for quick get item index by id. Needed, for example, for DnD things with list | `Record` | +| itemsSchema | Item structure to use in custom view implementations if needed. Items visibility determine by `expandedById` state | `ItemSchema[]` | + +##### ItemSchema + +```ts +export type ItemSchema = { + id: ListItemId; + index: number; + children?: ItemSchema[]; +}; +``` + +#### Item variants + +```ts +const simple: ListItemType[] = ['one', 'two', 'free', 'four', 'five']; + +const arbitraryObject: ListItemType<{text: string}>[] = [ + {text: 'one'}, + {text: 'two'}, + {text: 'free'}, + {text: 'four'}, + {text: 'five'}, +]; + +const withNestedChildren: ListItemType[] = [ + {data: 'one'}, + {data: 'two', children: [{data: 'tree', children: [{data: 'four'}, {data: 'five'}]}]}, +]; + +const withNestedChildrenComplexExample: ListItemType[] = [ + {disabled: true, data: {title: 'one', id: '1'}}, + { + expanded: true, + data: {title: 'two', id: '2'}, + children: [ + { + data: {title: 'tree', id: '3'}, + children: [{data: {title: 'four', id: '4'}}, {data: {title: 'five', id: '5'}}], + }, + ], + }, +]; +``` + +#### Object decl reserved properties: + +```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; +``` + +#### ControlledValues example: + +```tsx +const [selectedById] = React.useState>({}); + +const list = useList({ + // outer controlled state + controlledState: { + selectedById, + }, +}); +``` diff --git a/src/components/useList/__stories__/useList.mdx b/src/components/useList/__stories__/useList.mdx deleted file mode 100644 index 189da940b7..0000000000 --- a/src/components/useList/__stories__/useList.mdx +++ /dev/null @@ -1,780 +0,0 @@ -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); -- [getListItemQa](#getlistitemqa); - -## Quick code snippets for beginners: - -### flatten items: - -```tsx -import { - type unstable_ListItemId as ListItemId, - type unstable_ListItemType as ListItemType, - unstable_ListContainerView as ListContainerView, - unstable_ListItemView as ListItemView, - unstable_getItemRenderState as getItemRenderState, - unstable_useList as useList, - unstable_useListKeydown as useListKeydown, - unstable_useListState as 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), - mapItemDataToProps: (title) => ({title}), - onItemClick, - ...list, - ...listState, - }); - - return ; - })} - - ); - - // multiple selection - function onItemClick(id: ListItemId) { - listState.setSelected((prevState) => ({ - ...prevState, - [id]: !prevState[id], - })); - - listState.setActiveItemId(id); - } -} -``` - -### tree item structure: - -```tsx -import { - unstable_ListItemRecursiveRenderer as ListItemRecursiveRenderer, - unstable_ListContainerView as ListContainerView, - unstable_ListItemView as ListItemView, - unstable_getItemRenderState as getItemRenderState, -} from '@gravity-ui/uikit/unstable'; - -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 {props} = getItemRenderState({ - id: String(i), - mapItemDataToProps: (title) => ({title}), - 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: - - ##### Item structure variants - - ```ts - const simple: ListItemType[] = ['one', 'two', 'free', 'four', 'five']; - - const arbitraryObject: ListItemType<{text: string}>[] = [ - {text: 'one'}, - {text: 'two'}, - {text: 'free'}, - {text: 'four'}, - {text: 'five'}, - ]; - - const withNestedChildren: ListItemType[] = [ - {data: 'one'}, - {data: 'two', children: [{data: 'tree', children: [{data: 'four'}, {data: 'five'}]}]}, - ]; - - const withNestedChildrenComplexExample: ListItemType[] = [ - {disabled: true, data: {title: 'one', id: '1'}}, - { - expanded: true, - data: {title: 'two', id: '2'}, - children: [ - { - data: {title: 'tree', id: '3'}, - ex - children: [{data: {title: 'four', id: '4'}}, {data: {title: 'five', id: '5'}}], - }, - ], - }, - ]; - ``` - - ##### Object decl reserved propeties: - - ```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; -- `getItemId` - 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, - getItemId: ({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`getItemId` 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 -import { - unstable_useList as useList, - unstable_useListKeydown as useListKeydown, - unstable_useListState as useListState, -} from '@gravity-ui/uikit/unstable'; - -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 -import { - unstable_useList as useList, - unstable_useListKeydown as useListFilter, -} from '@gravity-ui/uikit/unstable'; - -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 -import {unstable_useListState as useListState} from '@gravity-ui/uikit/unstable'; - -type ListState = { - disabledById: Record; - selectedById: Record; - expandedById: Record; - activeItemId?: ListItemId; -}; - -const { - disabledById, - setDisabled, - selectedById, - setSelected, - expandedById, - setExpanded, - activeItemId, - setActiveItemId, -} = useListState(); -``` - -#### props: - -```tsx -interface UseListStateProps { - /** - * Initial state values - */ - initialValues?: Partial; - /** - * Ability to pass link to another state value - */ - controlledValues?: Partial; -} -``` - -##### controlledValues example: - -```tsx -const listState = useListState(); - -// inside your component -const innerListState = useListState({ - controlledValues: listState, -}); -``` - -## 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 - -```tsx -import { - type unstable_ListItemType as ListItemType, - unstable_ListItemView as ListItemView, -} from '@gravity-ui/uikit/unstable'; - -type Entity = {title: stirng, subtitle: string, icon: React.ReactNode}; - -const items: ListItemType[] = [ - {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 ( - - ) - }} - - ) -}; -``` - -#### 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. This also affects the rounding radius of the list element . By default, `m`. Available sizes are `s`, `m`, `l` and `xl`; -- `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; affects the `activeOnHover` if value if the value is different from `undefined`; -- `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`- directly control hover behaviour; -- `indentation` - affects the visual indentation of the element content; -- `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` - adds a visual representation of a group element if the value is different from `undefined`; -- `dragging` - manage view of dragging element. Required for graggable list implementation - -### 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; -- `getItemId` - 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 -import { - unstable_ListItemView as ListItemView, - unstable_ListContainerView as ListContainerView, - unstable_ListItemRecursiveRenderer as ListItemRecursiveRenderer, - type unstable_ListItemType as ListItemType, - unstable_getItemRenderState as getItemRenderState, -} from '@gravity-ui/uikit/unstable'; - -type Entity = {text: string}: - -const items: ListItemType[] = [{data: {text: 'one'}}, {data: {text: 'two'}}] - - - {items.map((item, index) => ( - - {(id) => { - const {props} = getItemRenderState({ - qa: 'some-qa-id', - id, - multiple: false, - size: 'm', - mapItemDataToProps: (item) => ({title: item.title}), - ...list, - ...listState, - }); - - return ; - }} - - ))} -; -``` - -## 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 -import { - unstable_ListContainerView as ListContainerView, - unstable_scrollToListItem as scrollToListItem, -} from '@gravity-ui/uikit/unstable'; - -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 ListItemView render props; - -```tsx -import { - unstable_ListItemView as ListItemView, - unstable_getItemRenderState as getItemRenderState, - unstable_useListState as useListState, - unstable_useList as useList, -} from '@gravity-ui/uikit/unstable'; - -const list = useList(); -const listState = useListState(); - -const {data, props, context} = getItemRenderState({ - qa: 'some-qa-id', - id, - multiple: true, - size, // list size - onItemClick: (id: ListItemId) => { - // ... - }, - mapItemDataToProps: (item) => ({title: item.title}), - ...list, - ...listState, -}); - -return ; -``` - -#### Props: - -```tsx -type ListItemSize = 's' | 'm' | 'l' | 'xl'; -type ListItemid = string; - -type GetItemRenderStateProps = { - /** - * `id` of list item; - */ - id: ListItemid; - /** - * map item data to view render props with `ListItemCommonProps` interface - */ - mapItemDataToProps(data: T): { - title: React.ReactNode; - endIcon?: React.ReactNode; - startIcon: React.ReactNode; - subtitle?: React.ReactNode; - }; - size: ListItemSize; - /** - * Affects the view of the selected items; - */ - multiple?: boolean; - /** - * Group expanded initial state. Default value `true` - */ - defaultExpanded?: boolean; - onItemClick?(id: ListItemid): void; -} & ReturnType & - ReturnType; -``` - -#### Returns: - -##### item data (`T`); - -```tsx -item = { - data: T, - children: [...] -} -// or, if flatten list declaration variant -item = T -``` - -##### item state props: - -```tsx -type ListItemSize = 's' | 'm' | 'l' | 'xl'; - -interface ItemRenderProps { - // item id; - id: string; - // qa attribute for tests - qa?: string; - // item size; - size: ListItemSize; - // expanded state if item group; - expanded?: boolean; - // is item active - active: boolean; - // item nest level; - indentation: number; - // is item disabled; - disabled: boolean; - // is item selected; - selected?: boolean; - // on item click handle if exists; - onClick?(): void; - // affects the view of the selected items; - hasSelectionIcon?: boolean; - // one required field of result `mapItemDataToProps` function work; - title: React.ReactNode; -} -``` - -##### item list context: - -```tsx -/** - * useful information about the current list item: - */ -interface ItemContext { - // meta info about item - itemState: { - // integer number, representing nested list level - indentation: number; - // `id` of parent list item if it exists - parentId?: string; - }; - // An optional parameter. If the list item is also the first item of the nested list - groupState: { - // array of `id` of nested list items; - childrenIds: string[]; - }; - // is the current item the last one in the list. For example needed to implement custom infinity lists variants - isLastItem: boolean; -} -``` - -```tsx -import { - unstable_ListContainerView as ListItemRecursiveRenderer, - unstable_ListItemView as ListItemView, - unstable_getItemRenderState as getItemRenderState, - unstable_useList as useList, - unstable_useListState as useListState, -} from '@gravity-ui/uikit/unstable'; - -const listState = useListState(); -const list = useList({ - items, - ...listState, -}); -const handleItemClick = () => {}; - - - {(id) => { - const {data, props} = getItemRenderState({ - qa: 'some-qa-id', - id, - multiple: false, - size, // list size - onItemClick: handleItemClick, - mapItemDataToProps, - ...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 -import {unstable_getListParsedState as getListParsedState} from '@gravity-ui/uikit/unstable'; - -// custom controlled state from computed initial state -const [expandedById, setExpanded] = React.useState( - () => getListParsedState(items).initialState.expandedById, -); -``` - -### getListItemQa - -Function is used to generate `qa` attributes in list items; -Also use this function in tests to create a unique data attribute for accessing a specific list item. - -```ts -import {unstable_getListItemQa as getListItemQa} from '@gravity-ui/uikit/unstable'; - -await locator.getByTestId(getListItemQa('some-list-qa', '0')); // select the first item in the list if auto-generated `id` are used -``` diff --git a/src/components/useList/components/ListContainer/ListContainer.tsx b/src/components/useList/components/ListContainer/ListContainer.tsx new file mode 100644 index 0000000000..4f7cde3368 --- /dev/null +++ b/src/components/useList/components/ListContainer/ListContainer.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import type {ListItemId, UseListResult} from '../../types'; +import {ListContainerView} from '../ListContainerView'; +import type {ListContainerViewProps} from '../ListContainerView/ListContainerView'; +import {ListItemRecursiveRenderer} from '../ListRecursiveRenderer/ListRecursiveRenderer'; + +export type ListContainerProps = Omit & { + list: UseListResult; + containerRef?: React.RefObject; + renderItem( + id: ListItemId, + index: number, + /** + * Ability to transfer props from an overridden container render + */ + renderContainerProps?: Object, + ): React.JSX.Element; +}; + +export function ListContainer({ + containerRef, + renderItem, + list, + ...props +}: ListContainerProps) { + return ( + + {list.structure.items.map((item, index) => ( + + {renderItem} + + ))} + + ); +} diff --git a/src/components/useList/components/ListContainer/index.ts b/src/components/useList/components/ListContainer/index.ts new file mode 100644 index 0000000000..efec57ed9c --- /dev/null +++ b/src/components/useList/components/ListContainer/index.ts @@ -0,0 +1,2 @@ +export {ListContainer} from './ListContainer'; +export type {ListContainerProps} from './ListContainer'; diff --git a/src/components/useList/components/ListContainerView/ListContainerView.tsx b/src/components/useList/components/ListContainerView/ListContainerView.tsx index fa05e7ae34..9d06ac9242 100644 --- a/src/components/useList/components/ListContainerView/ListContainerView.tsx +++ b/src/components/useList/components/ListContainerView/ListContainerView.tsx @@ -16,6 +16,7 @@ export interface ListContainerViewProps extends QAProps { id?: string; role?: React.AriaRole; className?: string; + style?: React.CSSProperties; /** * Removes `overflow: auto` from container and set fixed container size (`--g-list-height` = `300px`) */ @@ -26,7 +27,7 @@ export interface ListContainerViewProps extends QAProps { export const ListContainerView = React.forwardRef( function ListContainerView( - {as = 'div', role = 'listbox', children, id, className, fixedHeight, extraProps, qa}, + {as = 'div', role = 'listbox', children, id, className, fixedHeight, extraProps, qa, style}, ref, ) { return ( @@ -39,6 +40,7 @@ export const ListContainerView = React.forwardRef diff --git a/src/components/useList/components/ListContainerView/index.ts b/src/components/useList/components/ListContainerView/index.ts new file mode 100644 index 0000000000..b3dfd217b5 --- /dev/null +++ b/src/components/useList/components/ListContainerView/index.ts @@ -0,0 +1 @@ +export {ListContainerView, type ListContainerViewProps} from './ListContainerView'; diff --git a/src/components/useList/components/ListItemView/ListItemView.scss b/src/components/useList/components/ListItemView/ListItemView.scss index 5fab17fa17..b589561020 100644 --- a/src/components/useList/components/ListItemView/ListItemView.scss +++ b/src/components/useList/components/ListItemView/ListItemView.scss @@ -30,6 +30,9 @@ $block: '.#{variables.$ns}list-item-view'; &_dragging#{$block}_selected, &_dragging#{$block}_active { background: var(--g-color-base-simple-hover-solid); + // more than `Sheet` z-index (100000) if `ListItemView` used in this component + /* stylelint-disable-next-line declaration-no-important */ + z-index: 100001 !important; } &_radius_s { diff --git a/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx b/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx index ccac8a7978..2a9d5be1ec 100644 --- a/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx +++ b/src/components/useList/components/ListItemView/__stories__/ListItemView.stories.tsx @@ -6,12 +6,12 @@ import {Avatar} from '../../../../Avatar'; import {DropdownMenu} from '../../../../DropdownMenu'; import {Text} from '../../../../Text'; import {Flex, sp} from '../../../../layout'; -import {useListState} from '../../../hooks/useListState'; +import type {ListItemId} from '../../../../useList/types'; import {ListItemView as ListItemViewComponent} from '../ListItemView'; import type {ListItemViewProps} from '../ListItemView'; export default { - title: 'Unstable/useList/ListItemView', + title: 'Lab/useList/ListItemView', component: ListItemViewComponent, parameters: { a11y: { @@ -198,7 +198,8 @@ const stories: ListItemViewProps[] = [ ]; const ListItemViewTemplate: StoryFn = () => { - const listState = useListState(); + const [expandedById, setExpandedById] = React.useState>({}); + const [selectedById, setSelectedById] = React.useState>({}); return ( @@ -206,8 +207,8 @@ const ListItemViewTemplate: StoryFn = () => { ))} @@ -219,12 +220,12 @@ const ListItemViewTemplate: StoryFn = () => { return () => { if (isGroup) { - listState.setExpanded((prevState) => ({ + setExpandedById((prevState) => ({ ...prevState, [id]: typeof prevState[id] === 'undefined' ? !expanded : !prevState[id], })); } else { - listState.setSelected((prevState) => ({ + setSelectedById((prevState) => ({ ...prevState, [id]: !prevState[id], })); diff --git a/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx index 5711469193..a7339fc520 100644 --- a/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx +++ b/src/components/useList/components/ListRecursiveRenderer/ListRecursiveRenderer.tsx @@ -1,52 +1,48 @@ 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 type {ListItemId, ListItemType, UseListResult} from '../../types'; import {isTreeItemGuard} from '../../utils/isTreeItemGuard'; import './ListRecursiveRenderer.scss'; const b = block('list-recursive-renderer'); -export interface ListRecursiveRendererProps extends Partial> { +export interface ListItemRecursiveRendererProps { + id: ListItemId; + list: UseListResult; itemSchema: ListItemType; children(id: ListItemId, index: number): React.JSX.Element; - index: number; - parentId?: string; className?: string; - getItemId?(item: T): ListItemId; style?: React.CSSProperties; - idToFlattenIndex: Record; } // Saves the nested html structure for tree data structure export function ListItemRecursiveRenderer({ + id, itemSchema, - index, - parentId, + list, ...props -}: ListRecursiveRendererProps) { - const groupedId = getGroupItemId(index, parentId); - const id = getListItemId({item: itemSchema, groupedId, getItemId: props.getItemId}); - - const node = props.children(id, props.idToFlattenIndex[id]); +}: ListItemRecursiveRendererProps) { + const node = props.children(id, list.structure.idToFlattenIndex[id]); if (isTreeItemGuard(itemSchema) && itemSchema.children) { const isExpanded = - props.expandedById && id in props.expandedById ? props.expandedById[id] : true; + list.state.expandedById && id in list.state.expandedById + ? list.state.expandedById[id] + : true; return (
    {node} {isExpanded && + Boolean(list.structure.groupsState[id]?.childrenIds) && itemSchema.children.map((item, index) => ( ))} diff --git a/src/components/useList/components/ListRecursiveRenderer/index.ts b/src/components/useList/components/ListRecursiveRenderer/index.ts new file mode 100644 index 0000000000..78fa5d12da --- /dev/null +++ b/src/components/useList/components/ListRecursiveRenderer/index.ts @@ -0,0 +1,2 @@ +export {ListItemRecursiveRenderer} from './ListRecursiveRenderer'; +export type {ListItemRecursiveRendererProps} from './ListRecursiveRenderer'; diff --git a/src/components/useList/hooks/useFlattenListItems.ts b/src/components/useList/hooks/useFlattenListItems.ts index 6043e53ee9..560fecee5e 100644 --- a/src/components/useList/hooks/useFlattenListItems.ts +++ b/src/components/useList/hooks/useFlattenListItems.ts @@ -21,7 +21,7 @@ export function useFlattenListItems({ getItemId, }: UseFlattenListItemsProps) { const order = React.useMemo(() => { - return flattenItems(items, expandedById, getItemId); + return flattenItems({items, expandedById, getItemId}); }, [items, expandedById, getItemId]); return order; diff --git a/src/components/useList/hooks/useList.ts b/src/components/useList/hooks/useList.ts index b3153afb2a..69000f2a74 100644 --- a/src/components/useList/hooks/useList.ts +++ b/src/components/useList/hooks/useList.ts @@ -1,50 +1,85 @@ /* eslint-disable valid-jsdoc */ -import type { - InitialListParsedState, - ListItemId, - ListItemType, - ListParsedState, - ListState, -} from '../types'; +import React from 'react'; + +import type {ListState, UseListResult} from '../types'; import {useFlattenListItems} from './useFlattenListItems'; import {useListParsedState} from './useListParsedState'; +import type {UseListParsedStateProps} from './useListParsedState'; +import {useListState} from './useListState'; +import type {UseListStateProps} from './useListState'; -export interface UseListProps extends Partial { - items: ListItemType[]; - /** - * Control expanded items state from external source - */ - getItemId?(item: T): ListItemId; +interface UseListProps extends UseListParsedStateProps, UseListStateProps { + controlledState?: Partial; } -export type UseListResult = ListParsedState & {initialState: InitialListParsedState}; - /** - * Take array of items as a argument and returns parsed representation of this data structure to work with + * Take array of items as a argument with params described what type of list initial data represents. */ -export const useList = ({items, expandedById, getItemId}: UseListProps): UseListResult => { +export const useList = ({ + items, + getItemId, + defaultExpandedState = 'expanded', + withExpandedState = true, + initialState: initialValues, + controlledState, +}: UseListProps): UseListResult => { const {itemsById, groupsState, itemsState, initialState} = useListParsedState({ items, getItemId, + defaultExpandedState, + }); + + const initValues = React.useMemo(() => { + return { + expandedById: {...initialValues?.expandedById, ...initialState.expandedById}, + selectedById: {...initialValues?.selectedById, ...initialState.selectedById}, + disabledById: {...initialValues?.disabledById, ...initialState.disabledById}, + }; + }, [ + initialState.disabledById, + initialState.expandedById, + initialState.selectedById, + initialValues?.disabledById, + initialValues?.expandedById, + initialValues?.selectedById, + ]); + + const innerState = useListState({ + initialState: initValues, + withExpandedState, }); - const {visibleFlattenIds, idToFlattenIndex} = useFlattenListItems({ + const {visibleFlattenIds, idToFlattenIndex, rootIds} = useFlattenListItems({ items, /** * By default controlled from list items declaration state */ - expandedById: expandedById || initialState.expandedById, + expandedById: innerState.expandedById, getItemId, }); + const realState = React.useMemo(() => { + if (controlledState) { + return { + ...innerState, + ...controlledState, + }; + } + + return innerState; + }, [controlledState, innerState]); + return { - items, - visibleFlattenIds, - idToFlattenIndex, - itemsById, - groupsState, - itemsState, - initialState, + state: realState, + structure: { + rootIds, + items, + visibleFlattenIds, + idToFlattenIndex, + itemsById, + groupsState, + itemsState, + }, }; }; diff --git a/src/components/useList/hooks/useListFilter.ts b/src/components/useList/hooks/useListFilter.ts index 3c9e853b6a..979893b61c 100644 --- a/src/components/useList/hooks/useListFilter.ts +++ b/src/components/useList/hooks/useListFilter.ts @@ -21,24 +21,26 @@ interface UseListFilterProps { * Override only logic with item affiliation */ filterItem?(value: string, item: T): boolean; + onFilterChange?(value: string): void; 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, + onFilterChange, filterItems, debounceTimeout = 300, }: UseListFilterProps) { @@ -79,14 +81,16 @@ export function useListFilter({ return { reset: () => { setFilter(initialFilterValue); + onFilterChange?.(initialFilterValue); debouncedFn(initialFilterValue); }, onFilterUpdate: (nextFilterValue: string) => { setFilter(nextFilterValue); + onFilterChange?.(nextFilterValue); debouncedFn(nextFilterValue); }, }; - }, [debouncedFn, initialFilterValue]); + }, [debouncedFn, initialFilterValue, onFilterChange]); return { filterRef, diff --git a/src/components/useList/hooks/useListKeydown.tsx b/src/components/useList/hooks/useListKeydown.tsx index 802b0f4b42..913b0fbc27 100644 --- a/src/components/useList/hooks/useListKeydown.tsx +++ b/src/components/useList/hooks/useListKeydown.tsx @@ -1,57 +1,58 @@ import React from 'react'; import {KeyCode} from '../../../constants'; -import type {ListItemId, ListState} from '../types'; +import type {ListOnItemClick, UseListResult} from '../types'; import {findNextIndex} from '../utils/findNextIndex'; import {scrollToListItem} from '../utils/scrollToListItem'; -interface UseListKeydownProps extends Partial> { - visibleFlattenIds: ListItemId[]; - onItemClick?(itemId: ListItemId): void; +interface UseListKeydownProps { + onItemClick?: ListOnItemClick; containerRef?: React.RefObject; - setActiveItemId?(id: ListItemId): void; enabled?: boolean; + list: UseListResult; } // Use this hook if you need keyboard support for tree structure lists -export const useListKeydown = ({ - visibleFlattenIds, - onItemClick, - containerRef, - disabledById = {}, - activeItemId, - setActiveItemId, - enabled, -}: UseListKeydownProps) => { +export const useListKeydown = ({containerRef, onItemClick, enabled, list}: UseListKeydownProps) => { const activateItem = React.useCallback( (index?: number, scrollTo = true) => { - if (typeof index === 'number' && visibleFlattenIds[index]) { + if (typeof index === 'number' && list.structure.visibleFlattenIds[index]) { if (scrollTo) { - scrollToListItem(visibleFlattenIds[index], containerRef?.current); + scrollToListItem( + list.structure.visibleFlattenIds[index], + containerRef?.current, + ); } - setActiveItemId?.(visibleFlattenIds[index]); + list.state.setActiveItemId?.(list.structure.visibleFlattenIds[index]); } }, - [containerRef, visibleFlattenIds, setActiveItemId], + [list.structure.visibleFlattenIds, list.state, containerRef], ); const handleKeyMove = React.useCallback( (event: KeyboardEvent, step: number, defaultItemIndex = 0) => { event.preventDefault(); - const maybeIndex = visibleFlattenIds.findIndex((i) => i === activeItemId); + const maybeIndex = list.structure.visibleFlattenIds.findIndex( + (i) => i === list.state.activeItemId, + ); const nextIndex = findNextIndex({ - list: visibleFlattenIds, + list: list.structure.visibleFlattenIds, index: (maybeIndex > -1 ? maybeIndex : defaultItemIndex) + step, step: Math.sign(step), - disabledItems: disabledById, + disabledItems: list.state.disabledById, }); activateItem(nextIndex); }, - [activateItem, activeItemId, disabledById, visibleFlattenIds], + [ + activateItem, + list.state.activeItemId, + list.state.disabledById, + list.structure.visibleFlattenIds, + ], ); React.useLayoutEffect(() => { @@ -73,10 +74,13 @@ export const useListKeydown = ({ } case KeyCode.SPACEBAR: case KeyCode.ENTER: { - if (activeItemId && !disabledById[activeItemId]) { + if ( + list.state.activeItemId && + !list.state.disabledById[list.state.activeItemId] + ) { event.preventDefault(); - onItemClick?.(activeItemId); + onItemClick?.({id: list.state.activeItemId}); } break; } @@ -90,5 +94,12 @@ export const useListKeydown = ({ return () => { anchor.removeEventListener('keydown', handleKeyDown); }; - }, [activeItemId, containerRef, disabledById, enabled, handleKeyMove, onItemClick]); + }, [ + containerRef, + enabled, + handleKeyMove, + list.state.activeItemId, + list.state.disabledById, + onItemClick, + ]); }; diff --git a/src/components/useList/hooks/useListParsedState.ts b/src/components/useList/hooks/useListParsedState.ts index cfa26652f4..c343e6e7ac 100644 --- a/src/components/useList/hooks/useListParsedState.ts +++ b/src/components/useList/hooks/useListParsedState.ts @@ -1,25 +1,25 @@ /* eslint-disable valid-jsdoc */ import React from 'react'; -import type {ListItemId, ListItemType} from '../types'; import {getListParsedState} from '../utils/getListParsedState'; +import type {GetListParsedStateProps} from '../utils/getListParsedState'; -interface UseListParsedStateProps { - items: ListItemType[]; - /** - * List item id dependant of data - */ - getItemId?(item: T): ListItemId; -} +export interface UseListParsedStateProps extends GetListParsedStateProps {} /** * 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, getItemId}: UseListParsedStateProps) { +export function useListParsedState({ + items, + getItemId: propsGetItemId, + defaultExpandedState, +}: UseListParsedStateProps) { + const getItemId = React.useRef(propsGetItemId).current; + const result = React.useMemo(() => { - return getListParsedState(items, getItemId); - }, [getItemId, items]); + return getListParsedState({items, getItemId, defaultExpandedState}); + }, [getItemId, defaultExpandedState, items]); return result; } diff --git a/src/components/useList/hooks/useListState.ts b/src/components/useList/hooks/useListState.ts index 2e51d0097e..c5333964a1 100644 --- a/src/components/useList/hooks/useListState.ts +++ b/src/components/useList/hooks/useListState.ts @@ -3,58 +3,50 @@ import React from 'react'; import type {ListState} from '../types'; -interface UseListStateProps { +export interface UseListStateProps { /** * Initial state values */ - initialValues?: Partial; - /** - * Ability to pass link to another state value - * - * ```tsx - * const listState = useListState() - * - * // inside your component - * const innerListState = useListState({ - * controlledValues: listState - * }) - * ``` - */ - controlledValues?: Partial; + initialState?: Partial; + withExpandedState?: boolean; } -function useControlledState(value: T, defaultValue: T) { - const [state, setState] = React.useState(value || defaultValue); +export const useListState = ({initialState, withExpandedState}: UseListStateProps): ListState => { + const initialStateRef = React.useRef(initialState); + const needToUpdateInitValues = initialStateRef.current !== initialState; + initialStateRef.current = initialState; - return [value || state, setState] as const; -} + const [disabledById, setDisabled] = React.useState(() => initialState?.disabledById ?? {}); + const [selectedById, setSelected] = React.useState(() => initialState?.selectedById ?? {}); + const [expandedById, setExpanded] = React.useState(() => initialState?.expandedById ?? {}); + const [activeItemId, setActiveItemId] = React.useState(() => initialState?.activeItemId); -export const useListState = ({initialValues, controlledValues}: UseListStateProps = {}) => { - const [disabledById, setDisabled] = useControlledState( - controlledValues?.disabledById!, - initialValues?.disabledById || {}, - ); - const [selectedById, setSelected] = useControlledState( - controlledValues?.selectedById!, - initialValues?.selectedById || {}, - ); - const [expandedById, setExpanded] = useControlledState( - controlledValues?.expandedById!, - initialValues?.expandedById || {}, - ); - const [activeItemId, setActiveItemId] = useControlledState( - controlledValues?.activeItemId, - initialValues?.activeItemId, - ); - - return { + if (needToUpdateInitValues) { + if (initialState?.disabledById) { + setDisabled((prevValues) => ({...initialState.disabledById, ...prevValues})); + } + if (initialState?.selectedById) { + setSelected((prevValues) => ({...initialState.selectedById, ...prevValues})); + } + if (initialState?.expandedById) { + setExpanded((prevValues) => ({...initialState.expandedById, ...prevValues})); + } + setActiveItemId((prevValue) => prevValue ?? initialState?.activeItemId); + } + + const result: ListState = { disabledById, - setDisabled, selectedById, - setSelected, - expandedById, - setExpanded, activeItemId, + setDisabled, + setSelected, setActiveItemId, }; + + if (withExpandedState) { + result.expandedById = expandedById; + result.setExpanded = setExpanded; + } + + return result; }; diff --git a/src/components/useList/index.ts b/src/components/useList/index.ts index c2237d6d36..0f8ff3ce06 100644 --- a/src/components/useList/index.ts +++ b/src/components/useList/index.ts @@ -1,11 +1,12 @@ export * from './hooks/useListFilter'; export * from './hooks/useList'; export * from './hooks/useListKeydown'; -export * from './hooks/useListState'; export * from './types'; export * from './components/ListItemView'; -export * from './components/ListRecursiveRenderer/ListRecursiveRenderer'; -export * from './components/ListContainerView/ListContainerView'; +export * from './components/ListRecursiveRenderer'; +export * from './components/ListContainerView'; +export * from './components/ListContainer'; +export * from './utils/getListItemClickHandler'; export * from './utils/computeItemSize'; export * from './utils/getItemRenderState'; export * from './utils/scrollToListItem'; diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index 75be6b1531..5afa809406 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -48,19 +48,15 @@ export type ListItemCommonProps = { endSlot?: React.ReactNode; }; -export type RenderItemContext = { - itemState: ItemState; - /** - * Exists if item is group - */ - groupState?: GroupParsedState; - isLastItem: boolean; -}; +export type ListItemListContextProps = ItemState & + Partial & { + isLastItem: boolean; + }; export type RenderItemProps = { size: ListItemSize; id: ListItemId; - onClick: (() => void) | undefined; + onClick: ((e: React.SyntheticEvent) => void) | undefined; selected: boolean | undefined; disabled: boolean; expanded: boolean | undefined; @@ -86,10 +82,18 @@ export type ParsedState = { groupsState: Record; }; +type SetStateAction = S | ((prevState: S) => S); + +export type ListStateHandler = (arg: SetStateAction) => void; + export type ListState = { disabledById: Record; selectedById: Record; - expandedById: Record; + expandedById?: Record; + setExpanded?: ListStateHandler>; + setSelected: ListStateHandler>; + setDisabled: ListStateHandler>; + setActiveItemId: ListStateHandler; activeItemId?: ListItemId; }; @@ -98,12 +102,33 @@ export type InitialListParsedState = Pick< 'disabledById' | 'expandedById' | 'selectedById' >; +export type ItemSchema = { + id: ListItemId; + index: number; + children?: ItemSchema[]; +}; + export type ParsedFlattenState = { + /** + * Original list ordered ids without flatten elements. + * Use it to get internal item id + */ + rootIds: ListItemId[]; visibleFlattenIds: ListItemId[]; idToFlattenIndex: Record; }; -export type ListParsedState = ParsedState & +type ListStructure = ParsedState & ParsedFlattenState & { items: ListItemType[]; }; + +export type UseListResult = { + state: ListState; + structure: ListStructure; +}; + +export type ListOnItemClick = ( + payload: {id: ListItemId}, + e?: React.SyntheticEvent, +) => void; diff --git a/src/components/useList/utils/flattenItems.test.ts b/src/components/useList/utils/flattenItems.test.ts index 2fc9b44956..f279b09fe6 100644 --- a/src/components/useList/utils/flattenItems.test.ts +++ b/src/components/useList/utils/flattenItems.test.ts @@ -1,3 +1,5 @@ +import type {ParsedFlattenState} from '../types'; + import {flattenItems} from './flattenItems'; const data = [ @@ -31,46 +33,67 @@ const data = [ describe('flattenItems', () => { test('should return expected result', () => { - expect(flattenItems(data)).toEqual({ + const result: ParsedFlattenState = { visibleFlattenIds: ['0', '1', '1-0', '1-1', '1-1-0', '1-2', '2'], idToFlattenIndex: {0: 0, 1: 1, '1-0': 2, '1-1': 3, '1-1-0': 4, '1-2': 5, 2: 6}, - }); + rootIds: ['0', '1', '2'], + }; + + expect(flattenItems({items: data})).toEqual(result); }); test('should return expected result with expanded state', () => { + const result: ParsedFlattenState = { + visibleFlattenIds: ['0', '1', '2'], + idToFlattenIndex: {0: 0, 1: 1, 2: 2}, + rootIds: ['0', '1', '2'], + }; + expect( - flattenItems(data, { - '1': false, + flattenItems({ + items: data, + expandedById: { + '1': false, + }, }), - ).toEqual({visibleFlattenIds: ['0', '1', '2'], idToFlattenIndex: {0: 0, 1: 1, 2: 2}}); + ).toEqual(result); }); test('should return expected result with expanded state 2', () => { - expect( - flattenItems(data, { - '1-1': false, - }), - ).toEqual({ + const result: ParsedFlattenState = { visibleFlattenIds: ['0', '1', '1-0', '1-1', '1-2', '2'], idToFlattenIndex: {0: 0, 1: 1, '1-0': 2, '1-1': 3, '1-2': 4, 2: 5}, - }); - }); + rootIds: ['0', '1', '2'], + }; - test('should return expected result with expanded state and id getter override', () => { expect( - flattenItems( - data, - { - 'item-1': false, + flattenItems({ + items: data, + expandedById: { + '1-1': false, }, - ({title}) => title, - ), - ).toEqual({ + }), + ).toEqual(result); + }); + + test('should return expected result with expanded state and id getter override', () => { + const result: ParsedFlattenState = { visibleFlattenIds: ['item-0', 'item-1', 'item-2'], idToFlattenIndex: { 'item-0': 0, 'item-1': 1, 'item-2': 2, }, - }); + rootIds: ['item-0', 'item-1', 'item-2'], + }; + + expect( + flattenItems({ + items: data, + expandedById: { + 'item-1': false, + }, + getItemId: ({title}) => title, + }), + ).toEqual(result); }); }); diff --git a/src/components/useList/utils/flattenItems.ts b/src/components/useList/utils/flattenItems.ts index 8c013ed556..285e7ebe46 100644 --- a/src/components/useList/utils/flattenItems.ts +++ b/src/components/useList/utils/flattenItems.ts @@ -4,11 +4,19 @@ import {getListItemId} from './getListItemId'; import {getGroupItemId} from './groupItemId'; import {isTreeItemGuard} from './isTreeItemGuard'; -export function flattenItems( - items: ListItemType[], - expandedById: Record = {}, - getItemId?: (item: T) => ListItemId, -): ParsedFlattenState { +interface FlattenItemsProps { + items: ListItemType[]; + expandedById?: Record; + getItemId?: (item: T) => ListItemId; +} + +export function flattenItems({ + items, + getItemId, + expandedById = {}, +}: FlattenItemsProps): ParsedFlattenState { + const rootIds: ListItemId[] = []; + const getNestedIds = ( order: string[], item: ListItemType, @@ -18,6 +26,11 @@ export function flattenItems( const groupedId = getGroupItemId(index, parentId); const id = getListItemId({groupedId, item, getItemId}); + // only top level array + if (!parentId) { + rootIds.push(id); + } + order.push(id); if (isTreeItemGuard(item) && item.children) { @@ -47,6 +60,7 @@ export function flattenItems( } return { + rootIds, visibleFlattenIds, idToFlattenIndex, }; diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index 3d1df14850..c66cc05fb6 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -3,88 +3,69 @@ import type {QAProps} from '../../types'; import type { ListItemCommonProps, ListItemId, + ListItemListContextProps, ListItemSize, - ListParsedState, - ListState, - RenderItemContext, + ListOnItemClick, RenderItemProps, + UseListResult, } from '../types'; import {getListItemQa} from './getListItemQa'; -type ItemRendererProps = Partial & - QAProps & - ListParsedState & { - size?: ListItemSize; - /** - * Affects the view of the selected items - */ - multiple?: boolean; - /** - * @default true - * - * Group expanded initial state - */ - defaultExpanded?: boolean; - id: ListItemId; - mapItemDataToProps(data: T): ListItemCommonProps; - onItemClick?(id: ListItemId): void; - }; +type ItemRendererProps = QAProps & { + size?: ListItemSize; + /** + * Affects the view of the selected items + */ + multiple?: boolean; + id: ListItemId; + mapItemDataToProps(data: T): ListItemCommonProps; + onItemClick?: ListOnItemClick; + list: UseListResult; +}; /** * Map list state and parsed list state to item render props */ export const getItemRenderState = ({ qa, - itemsById, - disabledById, - expandedById, - groupsState, + list, onItemClick, mapItemDataToProps, - visibleFlattenIds, size = 'm', - itemsState, - selectedById, - activeItemId, multiple = false, - defaultExpanded = true, id, }: ItemRendererProps) => { - const context: RenderItemContext = { - itemState: itemsState[id], - groupState: groupsState[id], - isLastItem: id === visibleFlattenIds[visibleFlattenIds.length - 1], + const context: ListItemListContextProps = { + ...list.structure.itemsState[id], + ...list.structure.groupsState[id], + isLastItem: + id === list.structure.visibleFlattenIds[list.structure.visibleFlattenIds.length - 1], }; let expanded; // `undefined` value means than tree list will look as nested list without groups - let selected; // the absence of the value of the selected element affects its view. For example, an element without a value will not have a visual highlight on the hover // isGroup - if (groupsState[id] && expandedById) { - expanded = expandedById[id] ?? defaultExpanded; - } - - if (selectedById) { - selected = Boolean(selectedById[id]); + if (list.state.expandedById && id in list.state.expandedById) { + expanded = list.state.expandedById[id]; } const props: RenderItemProps = { id, size, expanded, - active: id === activeItemId, - indentation: context.itemState.indentation, - disabled: Boolean(disabledById?.[id]), - selected, - hasSelectionIcon: Boolean(multiple) && !context.groupState, - onClick: onItemClick ? () => onItemClick(id) : undefined, - ...mapItemDataToProps(itemsById[id]), + active: id === list.state.activeItemId, + indentation: context.indentation, + disabled: Boolean(list.state.disabledById?.[id]), + selected: Boolean(list.state.selectedById[id]), + hasSelectionIcon: Boolean(multiple) && !context.childrenIds, // hide multiple selection view at group nodes + onClick: onItemClick ? (e: React.SyntheticEvent) => onItemClick({id}, e) : undefined, + ...mapItemDataToProps(list.structure.itemsById[id]), }; if (qa) { props.qa = getListItemQa(qa, id); } - return {data: itemsById[id], props, context}; + return {data: list.structure.itemsById[id], props, context}; }; diff --git a/src/components/useList/utils/getListItemClickHandler.ts b/src/components/useList/utils/getListItemClickHandler.ts new file mode 100644 index 0000000000..b311495ac4 --- /dev/null +++ b/src/components/useList/utils/getListItemClickHandler.ts @@ -0,0 +1,32 @@ +import type {ListOnItemClick, UseListResult} from '../types'; + +interface GetListItemClickHandlerProps { + multiple?: boolean; + list: UseListResult; +} + +export const getListItemClickHandler = ({ + list, + multiple, +}: GetListItemClickHandlerProps) => { + const onItemClick: ListOnItemClick = ({id}) => { + if (list.state.disabledById[id]) return; + + // always activate selected item + list.state.setActiveItemId(id); + + if (list.state.expandedById && id in list.state.expandedById && list.state.setExpanded) { + list.state.setExpanded((prevState) => ({ + ...prevState, + [id]: !prevState[id], // expanded by id + })); + } else { + list.state.setSelected((prevState) => ({ + ...(multiple ? prevState : {}), + [id]: multiple ? !prevState[id] : true, // always select on click in single select variant + })); + } + }; + + return onItemClick; +}; diff --git a/src/components/useList/utils/getListParsedState.test.ts b/src/components/useList/utils/getListParsedState.test.ts index e893904422..0b40db9151 100644 --- a/src/components/useList/utils/getListParsedState.test.ts +++ b/src/components/useList/utils/getListParsedState.test.ts @@ -7,6 +7,7 @@ describe('getListParsedState', () => { const data: ListItemType[] = [ { data: {title: 'item-0'}, + expanded: true, disabled: true, willNotBeIncluded: '123', }, @@ -33,7 +34,7 @@ describe('getListParsedState', () => { }, ]; - expect(getListParsedState(data)).toEqual({ + expect(getListParsedState({items: data})).toEqual({ initialState: { selectedById: { 2: true, @@ -42,7 +43,10 @@ describe('getListParsedState', () => { 0: true, }, expandedById: { + '1': true, '1-1': false, + '1-1-0': true, + '2': true, }, }, itemsById: { @@ -90,7 +94,7 @@ describe('getListParsedState', () => { }, ]; - expect(getListParsedState(data)).toEqual({ + expect(getListParsedState({items: data})).toEqual({ initialState: { selectedById: { 1: true, @@ -136,19 +140,27 @@ describe('getListParsedState', () => { }, { data: {title: 'child-1-2', id: 'id-4'}, - expanded: false, + expanded: true, children: [{data: {title: 'child-1-2-1', id: 'id-5'}, children: []}], }, ], }, ]; - expect(getListParsedState(data, ({id}) => id)).toEqual({ + expect( + getListParsedState({ + items: data, + defaultExpandedState: 'closed', + getItemId: ({id}) => id, + }), + ).toEqual({ initialState: { selectedById: {}, disabledById: {}, expandedById: { - 'id-4': false, + 'id-2': false, + 'id-4': true, + 'id-5': false, }, }, itemsById: { diff --git a/src/components/useList/utils/getListParsedState.ts b/src/components/useList/utils/getListParsedState.ts index 8b26c0d0d8..39afd6b70e 100644 --- a/src/components/useList/utils/getListParsedState.ts +++ b/src/components/useList/utils/getListParsedState.ts @@ -31,14 +31,20 @@ type ListParsedStateResult = ParsedState & { initialState: InitialListParsedState; }; -export function getListParsedState( - items: ListItemType[], +export interface GetListParsedStateProps { + items: ListItemType[]; + defaultExpandedState?: 'closed' | 'expanded'; /** * 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 */ - getItemId?: (item: T) => ListItemId, -): ListParsedStateResult { + getItemId?: (item: T) => ListItemId; +} +export function getListParsedState({ + items, + defaultExpandedState = 'expanded', + getItemId, +}: GetListParsedStateProps): ListParsedStateResult { const result: ListParsedStateResult = { itemsById: {}, groupsState: {}, @@ -114,8 +120,12 @@ export function getListParsedState( childrenIds: [], }; - if (typeof item.expanded !== 'undefined') { - result.initialState.expandedById[id] = item.expanded; + if (result.initialState.expandedById) { + if (typeof item.expanded === 'undefined') { + result.initialState.expandedById[id] = defaultExpandedState === 'expanded'; + } else { + result.initialState.expandedById[id] = item.expanded; + } } item.children.forEach((treeItem, index) => { diff --git a/src/unstable.ts b/src/unstable.ts index 1d5149a1de..92a4858717 100644 --- a/src/unstable.ts +++ b/src/unstable.ts @@ -1,15 +1,18 @@ /* eslint-disable camelcase */ export { useList as unstable_useList, - useListState as unstable_useListState, useListFilter as unstable_useListFilter, useListKeydown as unstable_useListKeydown, + getListItemClickHandler as unstable_getListItemClickHandler, ListItemView as unstable_ListItemView, type ListItemViewProps as unstable_ListItemViewProps, ListContainerView as unstable_ListContainerView, + type ListContainerProps as unstable_ListContainerProps, + ListContainer as unstable_ListContainer, type ListContainerViewProps as unstable_ListContainerViewProps, type ListItemType as unstable_ListItemType, type ListItemId as unstable_ListItemId, + type UseListResult as unstable_UseListResult, getItemRenderState as unstable_getItemRenderState, scrollToListItem as unstable_scrollToListItem, getListItemQa as unstable_getListItemQa,