diff --git a/src/components/TreeList/TreeList.tsx b/src/components/TreeList/TreeList.tsx index b0d7cde5b9..ee94424692 100644 --- a/src/components/TreeList/TreeList.tsx +++ b/src/components/TreeList/TreeList.tsx @@ -11,6 +11,7 @@ import type {TreeListProps, TreeListRenderContainerProps} from './types'; const b = block('tree-list'); export const TreeList = ({ + qa, id, size = 'm', items, @@ -19,6 +20,7 @@ export const TreeList = ({ disabledById, activeItemId, selectedById, + defaultGroupsExpanded = true, getId, renderItem: propsRenderItem, renderContainer = TreeListContainer, @@ -42,32 +44,48 @@ export const TreeList = ({ selectedById, }); - const handleItemClick = React.useCallback( - (listItemId: ListItemId) => { - onItemClick?.({ - id: listItemId, - data: listParsedState.itemsById[listItemId], - disabled: disabledById - ? Boolean(disabledById[listItemId]) - : Boolean(listParsedState.initialState.disabledById[listItemId]), - isLastItem: - listParsedState.visibleFlattenIds[ - listParsedState.visibleFlattenIds.length - 1 - ] === listItemId, - groupState: listParsedState.groupsState[listItemId], - itemState: listParsedState.itemsState[listItemId], - }); - }, - [ - disabledById, - listParsedState.groupsState, - listParsedState.initialState.disabledById, - listParsedState.itemsById, - listParsedState.itemsState, - listParsedState.visibleFlattenIds, - onItemClick, - ], - ); + 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]), + + context: { + isLastItem: + listParsedState.visibleFlattenIds[ + listParsedState.visibleFlattenIds.length - 1 + ] === listItemId, + groupState: listParsedState.groupsState[listItemId], + itemState: listParsedState.itemsState[listItemId], + }, + }); + }; + } + + return undefined; + }, [ + defaultGroupsExpanded, + disabledById, + expandedById, + selectedById, + listParsedState, + onItemClick, + ]); useListKeydown({ containerRef, @@ -81,11 +99,13 @@ export const TreeList = ({ const renderItem: TreeListRenderContainerProps['renderItem'] = ( itemId, index, - renderContextProps, + renderContainerProps, ) => { const renderState = getItemRenderState({ + qa, id: itemId, size, + multiple, mapItemDataToProps, onItemClick: handleItemClick, ...listParsedState, @@ -93,26 +113,25 @@ export const TreeList = ({ disabledById, activeItemId, selectedById, + defaultExpanded: defaultGroupsExpanded, }); - // redefining the view logic for groups and multiple selection of list items - renderState.props.hasSelectionIcon = Boolean(multiple) && !renderState.context.groupState; - if (propsRenderItem) { return propsRenderItem({ data: renderState.data, props: renderState.props, - itemState: renderState.context, + context: renderState.context, index, - renderContext: renderContextProps, + renderContainerProps, }); } - return ; + return ; }; // not JSX decl here is from weird `react-beautiful-dnd` render bug return renderContainer({ + qa, id: `list-${treeListId}`, size, containerRef, diff --git a/src/components/TreeList/__stories__/TreeList.mdx b/src/components/TreeList/__stories__/TreeList.mdx new file mode 100644 index 0000000000..544cd10661 --- /dev/null +++ b/src/components/TreeList/__stories__/TreeList.mdx @@ -0,0 +1,315 @@ +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); +- [getId](#getid); +- [renderItem](#renderitem); +- [renderContainer](#rendercontainer); +- [onItemClick](#onitemclick); +- [...useListState](/docs/unstable-uselist--docs#useliststate) + +## Quick start: + +### Basic example: + +```tsx +import {type ListItemType, getItemRenderState, TreeList} from '@gravity-ui/uikit'; + +const items: ListItemType[] = ['one', 'two', 'free', 'four', 'five']; + + ({title})} />; +``` + +### Example with state: + +```tsx +import {type ListItemType, getItemRenderState, TreeList} from '@gravity-ui/uikit'; + +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 tate to the component itself: + +```ts +const {expandedById, setExpandedById, ...listState} = useListState(); + + +``` + +## Component props: + +### items + +array of list item. 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 + +The ability 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 {type ListItemType, getItemRenderState, TreeList, Button, Alert} from '@gravity-ui/uikit'; + +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})} + /> + + ); +}; +``` + +### getId + +Ability to generate an id for a list item depending on the list data. It is 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 + +The ability to set a qa attribute for the container and sheet elements. The 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 + +The ability to transfer custom css class name for the list container; + +### id + +The ability to set a custom id data attribute. By default, a unique identifier will be assigned; + +### multiple + +Since the state of the selected elements is controlled from above the component, this prop is necessary for the correct visual display of the selected elements; + +### setActiveItemId + +> Required for keyboard correct work. Because when navigating from the keyboard, you need to set the next active element + +### defaultGroupsExpanded + +default value - true + +Ability to handle default groups expanded behavior. +Works if `expandedById` state passed + +### renderItem + +The ability to completely 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 + { + return ; + }} +/> +``` + +#### renderItem function argument object: + +- `data` - access to the original object with the data of the sheet element; +- `props` - 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); +- `index` - ordinal index of the element, taking into account that with a tree-like data structure, the list elements have a flatten representation; +- `renderContainerProps` - the passes thrown from the redefined container. In the vast majority of cases, you won't need it, but it's worth knowing that there is such a possibility.; +- `context` - useful information about the current list item: + - `itemState` - meta info about item + - `indentation` - integer number representing nested list level + - `parentId` - `id` of parent list item; + - `groupState` - An optional parameter. If the list item is also the first item of the nested list: + - `childrenIds` - array of `id` of nested list items; + - `isLastItem` - is the current item the last one in the list; + +> 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 sheet elements. + +### renderContainer + +Ability to override default list container `ListContainerView`; + +```tsx +) => { + return ( + + computeItemSize(size)} + > + {(id, index) => + renderItem( + id, + index, + _, // here you can optionally pass any props depending of render context */, + ) + } + + + ); + }} +/> +``` + +### onItemClick + +Ability to define on item click callback. Also this callback will be called with handling keyboard actions + +```tsx + { + // just do it! + }} +/> +``` + +#### onItemClick function argument object: + +- `id` - of the current element; +- `data` - access to the original payload (`T`) list item; +- `index` - the ordinal index of the element, taking into account that with a tree-like data structure, the list elements have a flatten representation; +- `selected` - whether the item is selected or not; +- `disabled` - is the element disabled; +- `expanded` - are nested child elements hidden; +- `context` - useful information about the current list item: + - `itemState` - meta info about item + - `indentation` - integer number representing nested list level + - `parentId` - `id` of parent list item; + - `groupState` - An optional parameter. If the list item is also the first item of the nested list: + - `childrenIds` - `id` array of nested elements; + - `isLastItem` - is the current item the last one in the list; diff --git a/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx b/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx index a751e3601d..553ca73b8b 100644 --- a/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx +++ b/src/components/TreeList/__stories__/components/RenderVirtualizedContainer.tsx @@ -7,6 +7,7 @@ import type {TreeListRenderContainerProps} from '../../types'; // custom container renderer example export const RenderVirtualizedContainer = ({ id, + qa, containerRef, visibleFlattenIds, renderItem, @@ -15,6 +16,7 @@ export const RenderVirtualizedContainer = ({ }: TreeListRenderContainerProps) => { return ( { const listState = useListState(); - const handleItemClick: TreeListOnItemClick<{title: string}> = ({id, groupState, disabled}) => { + const handleItemClick: TreeListOnItemClick<{title: string}> = ({ + id, + disabled, + expanded, + selected, + context: {groupState}, + }) => { if (disabled) return; listState.setActiveItemId(id); @@ -33,12 +39,12 @@ export const InfinityScrollStory = ({itemsCount = 5, ...storyProps}: InfinityScr if (groupState) { listState.setExpanded((prevState) => ({ ...prevState, - [id]: id in prevState ? !prevState[id] : false, + [id]: !expanded, })); } else { listState.setSelected((prevState) => ({ ...prevState, - [id]: !prevState[id], + [id]: !selected, })); } }; @@ -60,7 +66,7 @@ export const InfinityScrollStory = ({itemsCount = 5, ...storyProps}: InfinityScr items={items} multiple onItemClick={handleItemClick} - renderItem={({data, props, itemState: {isLastItem, groupState}}) => { + renderItem={({data, props, context: {isLastItem, groupState}}) => { const node = ( { data, props, index, - renderContext: renderContextProps, + renderContainerProps, }) => { const commonProps = { ...props, @@ -118,12 +118,12 @@ export const WithDndListStory = (storyProps: WithDndListStoryProps) => { }; // here passed props from `renderContainer` method. - if (renderContextProps) { + if (renderContainerProps) { return ( ); } @@ -149,7 +149,7 @@ export const WithDndListStory = (storyProps: WithDndListStoryProps) => { mapItemDataToProps={({someRandomKey}) => ({title: someRandomKey})} // you can omit this prop here. If prop `id` passed, TreeSelect would take it by default getId={({id}) => id} - onItemClick={({id, groupState, disabled}) => { + onItemClick={({id, disabled, context: {groupState}}) => { if (!groupState && !disabled) { listState.setSelected((prevState) => ({ [id]: !prevState[id], diff --git a/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx b/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx index 534436d04d..53d1bf4525 100644 --- a/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx +++ b/src/components/TreeList/__stories__/stories/WithFiltrationAndControlsStory.tsx @@ -58,7 +58,7 @@ export const WithFiltrationAndControlsStory = ({ { + onItemClick={({id, disabled, context: {groupState}}) => { if (disabled) return; if (groupState) { diff --git a/src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx b/src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx index 5c42291911..d1c891b6e1 100644 --- a/src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx +++ b/src/components/TreeList/__stories__/stories/WithGroupSelectionAndCustomIconStory.tsx @@ -65,7 +65,7 @@ export const WithGroupSelectionAndCustomIconStory = ({ expanded, // don't use default ListItemView expand icon ...state }, - itemState: {groupState}, + context: {groupState}, }) => { return ( { + onItemClick={({id, selected, disabled, context: {groupState}}) => { if (!groupState && !disabled) { - listState.setSelected((prevState) => ({[id]: !prevState[id]})); + listState.setSelected({[id]: !selected}); } }} renderItem={({ @@ -44,7 +44,7 @@ export const WithItemLinksAndActionsStory = (props: WithItemLinksAndActionsStory expanded, // don't use build in expand icon ListItemView behavior ...state }, - itemState: {groupState}, + context: {groupState}, }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-is-valid diff --git a/src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx b/src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx index 432a522266..b9d8e20ca0 100644 --- a/src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx +++ b/src/components/TreeList/components/TreeListContainer/TreeListContainer.tsx @@ -5,6 +5,7 @@ import {ListItemRecursiveRenderer} from '../../../useList/components/ListRecursi import type {TreeListRenderContainerProps} from '../../types'; export const TreeListContainer = ({ + qa, items, id, containerRef, @@ -14,7 +15,7 @@ export const TreeListContainer = ({ idToFlattenIndex, }: TreeListRenderContainerProps & {className?: string}) => { return ( - + {items.map((itemSchema, index) => ( = (props: { // required item props to render props: RenderItemProps; // internal list context props - itemState: RenderItemContext; + context: RenderItemContext; index: number; - renderContext?: P; + renderContainerProps?: P; }) => React.JSX.Element; interface ItemClickContext { id: ListItemId; + data: T; + index: number; /** - * Defined only if item is group + * Current item `disabled` value */ - groupState?: ListParsedState['groupsState'][number]; - itemState: ListParsedState['itemsState'][number]; - isLastItem: boolean; disabled: boolean; - data: T; + /** + * Current item `selected` value + */ + selected: boolean; + /** + * Current item `expanded` value for group + */ + expanded: boolean; + /** + * List content item info + */ + context: RenderItemContext; } -export type TreeListOnItemClick = (ctx: ItemClickContext & R) => void; +export type TreeListOnItemClick = (ctx: ItemClickContext, defaultCb: R) => void; export type TreeListRenderContainerProps = ListParsedState & + QAProps & Partial & { id: string; size: ListItemSize; @@ -46,7 +57,7 @@ export type TreeListRenderContainerProps = ListParsedState & /** * Ability to transfer props from an overridden container render */ - renderContextProps?: Object, + renderContainerProps?: Object, ): React.JSX.Element; containerRef?: React.RefObject; className?: string; @@ -68,6 +79,13 @@ export interface TreeListProps extends QAProps, Partial { items: ListItemType[]; multiple?: boolean; size?: ListItemSize; + /** + * @default true + * + * Ability to handle default groups expanded behavior. + * Works if `expandedById` state passed + */ + defaultGroupsExpanded?: boolean; /** * Define custom id depended on item data value to use in controlled state component variant */ @@ -77,9 +95,6 @@ export interface TreeListProps extends QAProps, Partial { */ renderItem?: TreeListRenderItem; renderContainer?: TreeListRenderContainer; - /** - * If you want to disable default behavior pass `disabled` as a value; - */ onItemClick?: TreeListOnItemClick; mapItemDataToProps: TreeListMapItemDataToProps; /** diff --git a/src/components/TreeSelect/TreeSelect.tsx b/src/components/TreeSelect/TreeSelect.tsx index f0d5ccf763..a067d27f1f 100644 --- a/src/components/TreeSelect/TreeSelect.tsx +++ b/src/components/TreeSelect/TreeSelect.tsx @@ -20,7 +20,7 @@ import './TreeSelect.scss'; const b = block('tree-select'); const defaultItemRenderer: TreeListRenderItem = (renderState) => { - return ; + return ; }; export const TreeSelect = React.forwardRef(function TreeSelect( @@ -48,6 +48,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( popupDisablePortal, groupsBehavior = 'expandable', value: propsValue, + defaultGroupsExpanded, onClose, onUpdate, getId, @@ -113,38 +114,30 @@ export const TreeSelect = React.forwardRef(function TreeSelect( }); const handleItemClick = React.useCallback>( - ({id: listItemId, data, groupState, isLastItem, itemState}) => { + (onClickProps) => { + const {groupState} = onClickProps.context; + const defaultHandleClick = () => { - if (listState.disabledById[listItemId]) return; + if (listState.disabledById[onClickProps.id]) return; // always activate selected item - setActiveItemId(listItemId); + setActiveItemId(onClickProps.id); if (groupState && groupsBehavior === 'expandable') { - listState.setExpanded((state) => ({ - ...state, - // toggle expanded state by id, by default all groups expanded - [listItemId]: - typeof state[listItemId] === 'boolean' ? !state[listItemId] : false, + listState.setExpanded((prvState) => ({ + ...prvState, + [onClickProps.id]: !onClickProps.expanded, })); } else if (multiple) { - handleMultipleSelection(listItemId); + handleMultipleSelection(onClickProps.id); } else { - handleSingleSelection(listItemId); + handleSingleSelection(onClickProps.id); toggleOpen(false); } }; if (onItemClick) { - return onItemClick({ - id: listItemId, - data, - groupState, - itemState, - isLastItem, - disabled: listState.disabledById[listItemId], - defaultClickCallback: defaultHandleClick, - }); + return onItemClick(onClickProps, defaultHandleClick); } return defaultHandleClick(); @@ -253,6 +246,7 @@ export const TreeSelect = React.forwardRef(function TreeSelect( setActiveItemId={setActiveItemId} onItemClick={handleItemClick} items={items} + defaultGroupsExpanded={defaultGroupsExpanded} renderContainer={renderContainer} mapItemDataToProps={mapItemDataToProps} renderItem={renderItem ?? defaultItemRenderer} diff --git a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx index 0761d9f4f7..a68e546e83 100644 --- a/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx +++ b/src/components/TreeSelect/__stories__/components/InfinityScrollExample.tsx @@ -41,7 +41,7 @@ export const InfinityScrollExample = ({ mapItemDataToProps={identity} items={items} value={value} - renderItem={({data, props, itemState: {isLastItem, groupState}}) => { + renderItem={({data, props, context: {isLastItem, groupState}}) => { const node = ( { data, props, index, - renderContext: renderContextProps, + renderContainerProps, }) => { const commonProps = { ...props, @@ -119,12 +119,12 @@ export const WithDndListExample = (storyProps: WithDndListExampleProps) => { }; // here passed props from `renderContainer` method. - if (renderContextProps) { + if (renderContainerProps) { return ( ); } @@ -154,7 +154,7 @@ export const WithDndListExample = (storyProps: WithDndListExampleProps) => { mapItemDataToProps={({someRandomKey}) => ({ title: someRandomKey, })} - onItemClick={({id, groupState, disabled}) => { + onItemClick={({id, disabled, context: {groupState}}) => { if (!groupState && !disabled) { setValue([id]); setActiveItemId(id); diff --git a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx index 754874cf2c..d8e3805fea 100644 --- a/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx +++ b/src/components/TreeSelect/__stories__/components/WithGroupSelectionControlledStateAndCustomIcon.tsx @@ -58,7 +58,7 @@ export const WithGroupSelectionControlledStateAndCustomIconExample = ({ expanded, // don't use default ListItemView expand icon ...state }, - itemState: {groupState}, + context: {groupState}, }) => { return ( { + onItemClick={({id, context: {groupState}, disabled}) => { if (!groupState && !disabled) { setValue([id]); } @@ -52,7 +52,7 @@ export const WithItemLinksAndActionsExample = (props: WithItemLinksAndActionsExa expanded, // don't use build in expand icon ListItemView behavior ...state }, - itemState: {groupState}, + context: {groupState}, }) => { return ( // eslint-disable-next-line jsx-a11y/anchor-is-valid diff --git a/src/components/TreeSelect/types.ts b/src/components/TreeSelect/types.ts index 351b81cc99..a904c629b0 100644 --- a/src/components/TreeSelect/types.ts +++ b/src/components/TreeSelect/types.ts @@ -34,9 +34,9 @@ export type TreeSelectRenderItem = (props: { // required item props to render props: RenderItemProps; // internal list context props - itemState: RenderItemContext; + context: RenderItemContext; index: number; - renderContext?: P; + renderContainerProps?: P; }) => React.JSX.Element; export type TreeSelectRenderContainerProps = TreeListRenderContainerProps; @@ -76,6 +76,7 @@ export interface TreeSelectProps extends QAProps, Partial extends QAProps, Partial; - onItemClick?: TreeListOnItemClick; + onItemClick?: TreeListOnItemClick void>; /** * Map item data to view props */ diff --git a/src/components/useList/__stories__/components/InfinityScrollList.tsx b/src/components/useList/__stories__/components/InfinityScrollList.tsx index 1a3fd6b3f6..257a2c035a 100644 --- a/src/components/useList/__stories__/components/InfinityScrollList.tsx +++ b/src/components/useList/__stories__/components/InfinityScrollList.tsx @@ -96,6 +96,7 @@ export const InfinityScrollList = ({size}: InfinityScrollListProps) => { id, size, onItemClick, + multiple: true, mapItemDataToProps: (x) => x, ...list, ...listState, diff --git a/src/components/useList/__stories__/components/RecursiveList.tsx b/src/components/useList/__stories__/components/RecursiveList.tsx index e67dd17c04..2a0ba2b4ba 100644 --- a/src/components/useList/__stories__/components/RecursiveList.tsx +++ b/src/components/useList/__stories__/components/RecursiveList.tsx @@ -86,6 +86,7 @@ export const RecursiveList = ({size, itemsCount}: RecursiveListProps) => { id, size, onItemClick, + multiple: true, mapItemDataToProps: (x) => x, ...list, ...listState, diff --git a/src/components/useList/__stories__/useList.mdx b/src/components/useList/__stories__/useList.mdx index 3b920e2f5a..6a8a9ced4e 100644 --- a/src/components/useList/__stories__/useList.mdx +++ b/src/components/useList/__stories__/useList.mdx @@ -29,6 +29,7 @@ The basic idea is that hooks take all the complex logic on themselves, and all y - [scrollToListItem](#scrolltolistitem); - [getItemRenderState](#getitemrenderstate); - [getListParsedState](#getlistparsedstate); +- [getListItemQa](#getlistitemqa); ## Quick code snippets for beginners: @@ -147,36 +148,74 @@ The main hook for creating a stateless version of the sheet. - `items` - `ListItemType[]` - a flat or tree-like data structure, with `List` declaration: -```tsx -interface ListItemInitialProps { - /** - * If you need to control the state from the outside, - * you can set a unique id for each element - */ - id?: string; - /** - * Initial disabled item state - */ - disabled?: boolean; - /** - * Initial selected item state - */ - selected?: boolean; - /** - * Default expanded state if group - */ - expanded?: boolean; -} + ##### items supported data structure: + + ```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'}}], + }, + ], + }, + ]; + ``` + + > It is **not recommended** to control the state of a list through an array of its elements, recreating it every time the state changes. Use `useListState` hook to control list state + + ##### 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; + type ListFlattenItemType = T & ListItemInitialProps; -interface ListTreeItemType extends ListItemInitialProps { - data: T; - children?: ListTreeItemType[]; -} + interface ListTreeItemType extends ListItemInitialProps { + data: T; + children?: ListTreeItemType[]; + } -export type ListItemType = ListTreeItemType | ListFlattenItemType; -``` + export type ListItemType = ListTreeItemType | ListFlattenItemType; + ``` - `expandedById` - state for open/closed `List` elements. Affects the formation of the `visibleFlattenIds` - if the element id in this object is set to `false` - all elements of this group and all nested groups will not be present in the final ids order; - `getId` - the property is optional. Allows you to generate an id for a list item depending on the list data: @@ -339,14 +378,19 @@ Use it even if the functionality of the `useList` hook seems redundant to you - `id` - required prop. Set `[data-list-item="${id}"]` data attribute. By this it core list engine finds elements to scroll to. - `title` - base required prop to use. If passed string, applas default component styles according desig system. Pass you own componnet if you wont custom behaviour; - `as` - if needed, override `html` tag. By default - `li`; -- `size` - the size of the element. By default, `m`. Available options are `s`, `m`, `L`, `xl`. It also affects the radii of the fillets; +- `size` - the size of the element. This also affects the rounding radius of the list element . By default, `m`. + ##### Size available options: + - `s`, + - `m`, + - `l`, + - `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; +- `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`- By default hovered elements has active styles. You can disable this behavior; -- `indentation` - Build in indentation component to render nested views structure; -- `hasSelectionIcon` - Show selected icon if selected and reserve space for this icon; +- `disabled` - the disabled state. It also prevents clicking on an element; +- `activeOnHover`- directly control on 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; @@ -354,7 +398,7 @@ Use it even if the functionality of the `useList` hook seems redundant to you - `endSlot` - custom slot after `title`; - `corners` - Prop to remove default border radiuses from element; - `className` - custom class name to mix with; -- `expanded` - Build in supoort expanded behaviour for list item groups; +- `expanded` - adds a visual representation of a group element if the value is different from `undefined`; ```tsx const items = [ @@ -474,11 +518,22 @@ React.useLayoutEffect(() => { ### getItemRenderState; -Map list state to item render props; +Map list state to ListItemView render props; + +#### Props: + +- `mapItemDataToProps` - map item data to view render props with `KnownItemStructure` interface; +- `size` - list item size; +- `multiple` - Affects the view of the selected items; +- `defaultExpanded` - Group expanded initial state. Default value `true` +- `id` - of list item; +- `onItemClick` - item click handler; +- `[...useList]` - result of hook `useList`; +- `[...useListState]` - retult of hook `useListState`; #### Returns: -- item data (`T`); +##### item data (`T`); ```tsx item = { @@ -489,8 +544,10 @@ item = { item = T ``` -- item state props: +##### item state props: + - `id` - item id; + - `qa` - qa attribute for tests; - `size` - item size; - `expanded` - expanded state if item group; - `active` - is item active; @@ -498,8 +555,10 @@ item = T - `disabled` - is item disabled; - `selected` - is item selected; - `onClick` - on item click handle if exists; - - `mapItemDataToProps` - map item data to view render props with `KnownItemStructure` interface -- item list context: + - `hasSelectionIcon` - affects the view of the selected items; + +##### item list context: + - `itemState`: - `parentId?` - id of parant element; - `indentation` - item nest level; @@ -518,7 +577,9 @@ const handleItemClick = () => {}; {(id) => { const {data, props} = getItemRenderState({ + qa: 'some-qa-id', id, + multiple: false, size, // list size onItemClick: handleItemClick, mapItemDataToProps, @@ -526,7 +587,7 @@ const handleItemClick = () => {}; ...listState, }); - return ; + return ; }} ; ``` @@ -541,3 +602,12 @@ const [expandedById, setExpanded] = React.useState( () => getListParsedState(items).initialState.expandedById, ); ``` + +### getListItemQa + +Функция используется для генерации `qa` атрибутов в элементах списка; +Также используйте эту функцию в тестах для составления уникального data атрибута для доступа к конкретному элементу списка + +```ts +await locator.getByTestId(getListItemQa('some-list-qa', '0')); // выбрать первый элемент списка, если не используются автосгенеренные id +``` diff --git a/src/components/useList/components/ListContainerView/ListContainerView.tsx b/src/components/useList/components/ListContainerView/ListContainerView.tsx index 855ddd1829..7dc66d433a 100644 --- a/src/components/useList/components/ListContainerView/ListContainerView.tsx +++ b/src/components/useList/components/ListContainerView/ListContainerView.tsx @@ -26,11 +26,12 @@ export interface ListContainerViewProps extends QAProps { export const ListContainerView = React.forwardRef( function ListContainerView( - {as = 'div', role = 'listbox', children, id, className, fixedHeight, extraProps}, + {as = 'div', role = 'listbox', children, id, className, fixedHeight, extraProps, qa}, ref, ) { return ( { export const ListItemView = React.forwardRef( ( { + qa, id, as = 'div', size = 'm', active, selected, disabled, - activeOnHover = true, + activeOnHover: propsActiveOnHover, className, hasSelectionIcon = true, indentation, @@ -118,9 +120,12 @@ export const ListItemView = React.forwardRef( ) => { const isGroup = typeof expanded === 'boolean'; const onClick = disabled ? undefined : _onClick; + const activeOnHover = + typeof propsActiveOnHover === 'boolean' ? propsActiveOnHover : Boolean(onClick); return ( , }, diff --git a/src/components/useList/components/ListItemView/index.ts b/src/components/useList/components/ListItemView/index.ts new file mode 100644 index 0000000000..8b2f736a59 --- /dev/null +++ b/src/components/useList/components/ListItemView/index.ts @@ -0,0 +1,2 @@ +export {ListItemView} from './ListItemView'; +export type {ListItemViewProps} from './ListItemView'; diff --git a/src/components/useList/index.ts b/src/components/useList/index.ts index a2e315811d..d0e2d25615 100644 --- a/src/components/useList/index.ts +++ b/src/components/useList/index.ts @@ -3,7 +3,7 @@ export * from './hooks/useList'; export * from './hooks/useListKeydown'; export * from './hooks/useListState'; export * from './types'; -export * from './components/ListItemView/ListItemView'; +export * from './components/ListItemView'; export * from './components/ListRecursiveRenderer/ListRecursiveRenderer'; export * from './components/ListContainerView/ListContainerView'; export * from './utils/computeItemSize'; diff --git a/src/components/useList/types.ts b/src/components/useList/types.ts index ca9d06c7e9..53e8fc171e 100644 --- a/src/components/useList/types.ts +++ b/src/components/useList/types.ts @@ -1,3 +1,5 @@ +import type {QAProps} from '../types'; + export type ListItemId = string; export type ListItemSize = 's' | 'm' | 'l' | 'xl'; @@ -47,11 +49,6 @@ export type KnownItemStructure = { }; export type RenderItemContext = { - /** - * optional, because ids may be skipped in the flatten order list, - * depending on the expanded state - */ - visibleFlattenIndex?: number; itemState: ItemState; /** * Exists if item is group @@ -63,14 +60,15 @@ export type RenderItemContext = { export type RenderItemProps = { size: ListItemSize; id: ListItemId; - onClick?(): void; - selected: boolean; + onClick: (() => void) | undefined; + selected: boolean | undefined; disabled: boolean; - expanded?: boolean; + expanded: boolean | undefined; active: boolean; indentation: number; hasSelectionIcon?: boolean; -} & KnownItemStructure; +} & KnownItemStructure & + QAProps; export type ParsedState = { /** diff --git a/src/components/useList/utils/getItemRenderState.tsx b/src/components/useList/utils/getItemRenderState.tsx index eb6ab2244e..d07c4ca63a 100644 --- a/src/components/useList/utils/getItemRenderState.tsx +++ b/src/components/useList/utils/getItemRenderState.tsx @@ -1,4 +1,5 @@ /* eslint-disable valid-jsdoc */ +import type {QAProps} from '../../types'; import type { KnownItemStructure, ListItemId, @@ -9,9 +10,22 @@ import type { RenderItemProps, } 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): KnownItemStructure; onItemClick?(id: ListItemId): void; @@ -20,49 +34,57 @@ type ItemRendererProps = Partial & /** * Map list state and parsed list state to item render props */ -export const getItemRenderState = ( - { - itemsById, - disabledById, - expandedById, - groupsState, - onItemClick, - mapItemDataToProps, - visibleFlattenIds, - size = 'm', - itemsState, - selectedById, - activeItemId, - id, - idToFlattenIndex, - }: ItemRendererProps, - {defaultExpanded = true}: {defaultExpanded?: boolean} = {}, -) => { +export const getItemRenderState = ({ + qa, + itemsById, + disabledById, + expandedById, + groupsState, + onItemClick, + mapItemDataToProps, + visibleFlattenIds, + size = 'm', + itemsState, + selectedById, + activeItemId, + multiple = false, + defaultExpanded = true, + id, +}: ItemRendererProps) => { const context: RenderItemContext = { - visibleFlattenIndex: idToFlattenIndex[id], itemState: itemsState[id], groupState: groupsState[id], isLastItem: id === visibleFlattenIds[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; } - const stateProps: RenderItemProps = { + if (selectedById) { + selected = Boolean(selectedById[id]); + } + + const props: RenderItemProps = { id, size, expanded, active: id === activeItemId, indentation: context.itemState.indentation, disabled: Boolean(disabledById?.[id]), - selected: Boolean(selectedById?.[id]), + selected, + hasSelectionIcon: Boolean(multiple) && !context.groupState, onClick: onItemClick ? () => onItemClick(id) : undefined, ...mapItemDataToProps(itemsById[id]), }; - return {data: itemsById[id], props: stateProps, context}; + if (qa) { + props.qa = getListItemQa(qa, id); + } + + return {data: itemsById[id], props, context}; }; diff --git a/src/components/useList/utils/getListItemQa.ts b/src/components/useList/utils/getListItemQa.ts new file mode 100644 index 0000000000..60484ddcd5 --- /dev/null +++ b/src/components/useList/utils/getListItemQa.ts @@ -0,0 +1,3 @@ +import type {ListItemId} from '../types'; + +export const getListItemQa = (qa: string, id: ListItemId): string => `${qa}-${id}`; diff --git a/src/constants.ts b/src/constants.ts index 97396f5e8a..87057716be 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -9,3 +9,5 @@ export const KeyCode = { ARROW_UP: 'ArrowUp', ARROW_DOWN: 'ArrowDown', }; + +export const QA_DATA_ATTR = 'data-qa';