Skip to content

Commit

Permalink
feat: new list proposal
Browse files Browse the repository at this point in the history
  • Loading branch information
IsaevAlexandr committed Oct 4, 2023
1 parent 44d7160 commit 868b383
Showing 33 changed files with 1,386 additions and 0 deletions.
24 changes: 24 additions & 0 deletions src/components/ListComposable/ListComposable.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@use '../variables';

$block: '.#{variables.$ns}list-composable';

#{$block} {
& ul {
// TODO: more clever reset default styles?
padding-inline-start: 0;
}

outline: none;
display: flex;
flex-direction: column;
flex: 1 1 auto;
width: 100%;

&__container {
flex: 1 1 auto;

&_virtualized {
height: var(--yc-list-height);
}
}
}
13 changes: 13 additions & 0 deletions src/components/ListComposable/ListComposable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';

import {ListContainer} from './components/ListContainer/ListContainer';
import {ListProvider} from './components/ListProvider/ListProvider';
import type {ListProviderProps} from './types';

export interface ListComposableProps<T> extends Omit<ListProviderProps<T>, 'children'> {
children?: React.ReactNode;
}

export function ListComposable<T>({children, ...props}: ListComposableProps<T>) {
return <ListProvider {...props}>{children || <ListContainer />}</ListProvider>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';

import type {Meta, StoryFn} from '@storybook/react';

import {Flex} from '../../layout';
import {ListComposable, ListComposableProps} from '../ListComposable';
import {ListContainer} from '../components/ListContainer/ListContainer';
import {ListActionButton} from '../components/ListControls/ListControls';
import {ListFilter} from '../components/ListFilter/ListFilter';

import {ListResetButton} from './ListResetButton';
import {createRandomizedData} from './makeData';

export default {
title: 'ListComposable/BaseExample',
component: ListComposable,
} as Meta;

const data = createRandomizedData(1000);

const DefaultTemplate: StoryFn<ListComposableProps<unknown>> = () => (
<React.StrictMode>
<Flex gap="5" style={{width: '100%', height: '300px'}}>
<ListComposable items={data} size="s" selectable="multiple">
<Flex direction="column" gap="3" width={400}>
<ListFilter />
<ListContainer virtualized />
<Flex gap="2">
<ListResetButton />
<ListActionButton
actionText="Accept"
onActionClick={(...args) =>
alert(args.map((arg) => JSON.stringify(arg, null, 2)).join('\n'))
}
/>
</Flex>
</Flex>
</ListComposable>
</Flex>
</React.StrictMode>
);
export const Examples = DefaultTemplate.bind({});
22 changes: 22 additions & 0 deletions src/components/ListComposable/__stories__/ListResetButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';

import {Button} from '../../Button/Button';
import {useListContext} from '../components/ListContext/ListContext';

interface ListResetButtonProps {}

export const ListResetButton = (_props: ListResetButtonProps) => {
const {setSelected, size, onFilterChange, formatInternalItems} = useListContext();

const handleClick = () => {
setSelected(() => ({}));
onFilterChange('');
formatInternalItems((initialData) => initialData);
};

return (
<Button width="max" onClick={handleClick} size={size}>
Reset
</Button>
);
};
48 changes: 48 additions & 0 deletions src/components/ListComposable/__stories__/makeData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {faker} from '@faker-js/faker/locale/en';

import type {ListItemType} from '../types';

const RANDOM_WORDS = Array(50)
.fill(null)
.map(() => faker.person.fullName());

export function createRandomizedData<T = string>(
num = 1000,
hasDepth = true,
getData?: (title: string) => T,
): ListItemType<T>[] {
const data = [];

for (let i = 0; i < num; i++) {
data.push(createRandomizedItem<T>(hasDepth ? 0 : 3, getData));
}

return data;
}

function base<T>(title: string): T {
return {title} as T;
}

function createRandomizedItem<T>(
depth: number,
getData: (title: string) => T = base,
): ListItemType<T> {
const item: ListItemType<T> = {
data: getData(RANDOM_WORDS[Math.floor(Math.random() * RANDOM_WORDS.length)]),
};

const numChildren = depth < 3 ? Math.floor(Math.random() * 5) : 0;

if (numChildren > 0) {
item.children = [];
}

for (let i = 0; i < numChildren; i++) {
if (item.children) {
item.children.push(createRandomizedItem(depth + 1, getData));
}
}

return item;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React from 'react';

import {bListComposable} from '../../constants';
import type {
ListContainerRenderProps,
ListItemBaseData,
ListItemRendererProps,
RenderListItemViewProps,
} from '../../types';
import {computeItemSize} from '../../utils/computeItemSize';
import {useListContext} from '../ListContext/ListContext';
import {ListItemRenderer} from '../ListItemRenderer/ListItemRenderer';
import {ListItemView} from '../ListItemView/ListItemView';
import {SimpleListContainer} from '../SimpleListContainer/SimpleListContainer';
import {VirtualizedListContainer} from '../VirtualizedListContainer/VirtualizedListContainer';
interface ListContainerProps<T> {
virtualized?: boolean;
prepareCustomData?(props: T): ListItemBaseData;
getItemSize?(index: number): number;
renderItemView?: (props: RenderListItemViewProps) => React.JSX.Element;
renderItem?(props: ListItemRendererProps<T>): React.ReactNode;
renderContainer?(props: ListContainerRenderProps<T>): React.ReactNode;
}

export function ListContainer<T>({
virtualized,
prepareCustomData,
getItemSize: _getItemSize,
renderItem,
renderContainer,
renderItemView = ListItemView,
}: ListContainerProps<T>) {
const {listRef, size, containerRef, handleKeyDown, byId, order} = useListContext<T>();

const items = React.useMemo(() => {
return order.map((id) => (prepareCustomData ? prepareCustomData(byId[id]) : byId[id]));
}, [byId, order, prepareCustomData]);

const Container =
renderContainer || virtualized ? VirtualizedListContainer : SimpleListContainer;

const RenderItem = React.useCallback(
(props: ListItemRendererProps<T>) =>
renderItem ? (
renderItem(props)
) : (
<ListItemRenderer
{...(props as ListItemRendererProps<ListItemBaseData>)}
View={renderItemView}
/>
),
[renderItem, renderItemView],
);

const getItemSize = React.useMemo(() => {
if (_getItemSize) {
return _getItemSize;
}

if (prepareCustomData) {
return (index: number): number =>
computeItemSize(size, prepareCustomData(byId[order[index]]));
}

return () => computeItemSize(size);
}, [_getItemSize, byId, order, prepareCustomData, size]);

return (
<div
className={bListComposable({virtualized}, 'container')}
ref={containerRef}
tabIndex={-1}
role="listbox"
onKeyDown={handleKeyDown}
>
<Container ref={listRef} items={items} getItemSize={getItemSize}>
{RenderItem}
</Container>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';

import type {ListContextType} from '../../types';

export const ListContext = React.createContext<ListContextType<unknown> | null>(null);

export const useListContext = <T,>() => {
const context = React.useContext(ListContext);

if (!context) {
throw new Error(
'Trying to use "ListContext" out of scope. Ensure that you use "useListContext" hook inside "ListProvider"',
);
}

return context as ListContextType<T>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';

import {Button} from '../../../Button/Button';
import type {ListItemId} from '../../types';
import {useListContext} from '../ListContext/ListContext';

interface ListControlsProps<T> {
actionText: string;
onActionClick(selectedItems: T[], selectedIds: Record<ListItemId, boolean>): void;
}

export function ListActionButton<T>({actionText, onActionClick}: ListControlsProps<T>) {
const {size, selected, byId} = useListContext<T>();

const handleActionClick = React.useCallback(() => {
onActionClick(
Object.entries(selected).reduce<T[]>((acc, [itemId, isSelected]) => {
if (isSelected && byId[itemId]) {
acc.push(byId[itemId]);
}

return acc;
}, []),
selected,
);
}, [byId, onActionClick, selected]);

return (
<Button view="action" size={size} onClick={handleActionClick} width="max">
{actionText}
</Button>
);
}
71 changes: 71 additions & 0 deletions src/components/ListComposable/components/ListFilter/ListFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';

import debounce from 'lodash/debounce';

import {TextInput, TextInputProps} from '../../../controls';
import type {ListItemType} from '../../types';
import {defaultFilterItems} from '../../utils/defaultFilterItems';
import {useListContext} from '../ListContext/ListContext';

interface ListFilterProps<T>
extends Omit<TextInputProps, 'ref' | 'onUpdate' | 'onChange' | 'value'> {
/**
* Override default filtration logic
*/
filterItems?(value: string, items: ListItemType<T>[]): ListItemType<T>[];
/**
* Override only logic with item affiliation
*/
filterItem?(value: string, item: T): boolean;
debounceTimeout?: number;
}

const DEFAULT_DEBOUNCE_TIMEOUT = 300;

function defaultFilterFn<T>(value: string, item: T): boolean {
return item && typeof item === 'object' && 'title' in item && typeof item.title === 'string'
? item.title.includes(value)
: true;
}
export function ListFilter<T>({
filterItem,
filterItems,
debounceTimeout = DEFAULT_DEBOUNCE_TIMEOUT,
...props
}: ListFilterProps<T>) {
const {refFilter, filter, onFilterChange, formatInternalItems, size} = useListContext<T>();

const handleUpdate = React.useMemo(() => {
const runFiltration = (nextFilterValue: string) => {
if (filterItems) {
return (items: ListItemType<T>[]) => filterItems(nextFilterValue, items);
}

if (nextFilterValue) {
return (items: ListItemType<T>[]) =>
defaultFilterItems(items, (item) =>
(filterItem || defaultFilterFn)(nextFilterValue, item),
);
}

// reset initial data
return (items: ListItemType<T>[]) => items;
};

return debounce((value) => formatInternalItems(runFiltration(value)), debounceTimeout);
}, [filterItem, filterItems, formatInternalItems, debounceTimeout]);

React.useEffect(() => {
handleUpdate(filter);
}, [filter, handleUpdate]);

return (
<TextInput
{...props}
size={size}
value={filter}
ref={refFilter}
onUpdate={onFilterChange}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* eslint-disable react/display-name */
import React from 'react';

import {ChevronDown, ChevronUp} from '@gravity-ui/icons';

import {Icon} from '../../../Icon';
import {Label} from '../../../Label';
import {Text} from '../../../Text';
import type {
ListItemBaseData,
ListItemRendererProps as ListItemRendererPropsBase,
RenderListItemViewProps,
} from '../../types';
import {createListItemQa} from '../../utils/createListItemQa';
import {useListContext} from '../ListContext/ListContext';

interface ListItemRendererProps extends ListItemRendererPropsBase<ListItemBaseData> {
View: (props: RenderListItemViewProps) => React.JSX.Element;
}

export const ListItemRenderer = ({item, id: index, View}: ListItemRendererProps) => {
const {
activeItem,
order,
groupsState,
size,
expandedState,
onItemClick,
onGroupItemClick,
itemHandlers,
selected,
itemsState,
disabled,
} = useListContext();
const id = order[index];
const indentation = itemsState[id]?.indentation || 0;
const expanded = id in expandedState ? expandedState[id] : true;
const isGroup = id in groupsState;

const {handlers, onClick, qa} = React.useMemo(() => {
let onClick;

if (isGroup) {
onClick = () => onGroupItemClick(id);
} else if (onItemClick) {
onClick = () => onItemClick(id);
}

return {
handlers: itemHandlers(id),
onClick,
qa: createListItemQa(id),
};
}, [itemHandlers, id, isGroup, onItemClick, onGroupItemClick]);

return (
<View
{...handlers}
{...item}
title={
isGroup ? (
<Text
as="div"
ellipsis
variant="subheader-1"
color={disabled[id] ? 'secondary' : undefined}
>
{item.title}
</Text>
) : (
item.title
)
}
isGroup={isGroup}
active={id === activeItem}
selected={Boolean(selected[id])}
onClick={onClick}
key={index}
rightSlot={isGroup ? <Label>{groupsState[id].childrenCount}</Label> : null}
qa={qa}
leftSlot={
isGroup && groupsState[id].childrenCount > 0 ? (
<Icon data={expanded ? ChevronDown : ChevronUp} size={16} />
) : null
}
indentation={indentation}
size={size}
activeOnHover={!isGroup}
disabled={disabled[id]}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@use '../../../variables';

$block: '.#{variables.$ns}list-item-view';

#{$block} {
&__grip {
cursor: grab;
}

&:hover#{$block}_activeOnHover,
&_active#{$block}_activeOnHover,
&_active {
background: var(--yc-color-base-simple-hover);
}

&_clickable {
cursor: pointer;
}

&_selected,
&_selected:hover#{$block}_activeOnHover {
background: var(--yc-color-base-selection);
}

&_hidden {
display: none;
}

&__slot {
width: 16px;
}
}
126 changes: 126 additions & 0 deletions src/components/ListComposable/components/ListItemView/ListItemView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React from 'react';

import {Check, Grip} from '@gravity-ui/icons';
import type {QAProps} from 'src/components/types';

import {Icon} from '../../../Icon';
import {Text, colorText} from '../../../Text';
import {Flex, spacing} from '../../../layout';
import {block} from '../../../utils/cn';
import {modToHeight} from '../../constants';
import type {ListSizeTypes} from '../../types';

import './ListItemView.scss';

const b = block('list-item-view');

export interface ListItemViewProps extends QAProps {
/**
* Ability to override default html tag
*/
as?: keyof JSX.IntrinsicElements;
draggable?: boolean;
/**
* @default `m`
*/
size?: ListSizeTypes;
selected?: boolean;
active?: boolean;
/**
* display: hidden;
*/
hidden?: boolean;
disabled?: boolean;
/**
* By default hovered elements has active styles. You can disable this behavior
*/
activeOnHover?: boolean;
/**
* Build in indentation component to render nested views structure
*/
indentation?: number;
/**
* Show selected icon if selected and reserve space for this icon
*/
selectable?: boolean;
/**
* Note: if passed and `disabled` option is `true` click will not be appear
*/
onClick?(): void;
style?: React.CSSProperties;
title: string | React.ReactNode;
subtitle?: string;
leftSlot?: React.ReactNode;
rightSlot?: React.ReactNode;
}

export const Slot = ({children}: {children?: React.ReactNode}) => {
return <div className={b('slot')}>{children}</div>;
};

export const computeIndentation = (payload = 0) => {
return React.Children.toArray(Array(payload < 0 ? 0 : payload).fill(<Slot />));
};

export const ListItemView = ({
as = 'li',
leftSlot,
rightSlot,
title,
subtitle,
size = 'm',
active,
hidden,
selected,
draggable,
disabled,
activeOnHover = true,
indentation,
selectable = true,
onClick: _onClick,
...rest
}: ListItemViewProps) => {
const onClick = disabled ? undefined : _onClick;

return (
<Flex
onClick={onClick}
alignItems="center"
className={b(
{hidden, active, selected, activeOnHover, clickable: Boolean(onClick)},
spacing({px: 2}),
)}
as={as}
gap="4"
justifyContent="space-between"
style={{height: modToHeight[size][Number(Boolean(subtitle))]}}
{...rest}
>
<Flex gap="2" alignItems="center">
{selectable && (
<Slot>
{selected ? (
<Icon data={Check} size={16} className={colorText({color: 'info'})} />
) : null}
</Slot>
)}

{computeIndentation(indentation)}

{leftSlot}
<Flex direction="column" gap="0.5">
{typeof title === 'string' ? (
<Text color={disabled ? 'hint' : undefined}>{title}</Text>
) : (
title
)}
{subtitle && <Text color={disabled ? 'hint' : 'secondary'}>{subtitle}</Text>}
</Flex>
</Flex>
<Flex gap="2">
{draggable && <Icon data={Grip} size={16} className={b('grip')} />}
{rightSlot}
</Flex>
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import React from 'react';

import type {Meta, StoryFn} from '@storybook/react';

import {UserAvatar} from '../../../../UserAvatar';
import {Flex} from '../../../../layout';
import {ListItemView, ListItemViewProps} from '../ListItemView';

export default {
title: 'ListComposable/ListItemView',
component: ListItemView,
} as Meta;

const title = 'title';
const subtitle = 'subtitle';

const stories: ListItemViewProps[] = [
{
title,
draggable: true,
activeOnHover: false,
subtitle,
disabled: true,
leftSlot: (
<UserAvatar imgUrl="https://avatars.mds.yandex.net/get-yapic/69015/enc-137b8b64288fa6fc5ec58c6b83aea00e7723c8fa5638c078312a1134d8ee32ac/islands-retina-50" />
),
},
{
title,
draggable: true,
subtitle,
activeOnHover: false,
},
{
title,

draggable: true,
subtitle,
selected: true,
leftSlot: (
<UserAvatar imgUrl="https://avatars.mds.yandex.net/get-yapic/69015/enc-137b8b64288fa6fc5ec58c6b83aea00e7723c8fa5638c078312a1134d8ee32ac/islands-retina-50" />
),
},
{
title,
selected: true,
disabled: true,
// draggable: true,
leftSlot: (
<UserAvatar imgUrl="https://avatars.mds.yandex.net/get-yapic/69015/enc-137b8b64288fa6fc5ec58c6b83aea00e7723c8fa5638c078312a1134d8ee32ac/islands-retina-50" />
),
},
{
title,
draggable: true,
},
{
title,

draggable: true,
subtitle,

leftSlot: (
<UserAvatar imgUrl="https://avatars.mds.yandex.net/get-yapic/69015/enc-137b8b64288fa6fc5ec58c6b83aea00e7723c8fa5638c078312a1134d8ee32ac/islands-retina-50" />
),
indentation: 1,
},
{
title: 'Group 1',
},
];

const DefaultTemplate: StoryFn<ListItemViewProps> = () => (
<Flex direction="column" width={300}>
{stories.map((props, i) => (
<ListItemView key={i} {...props} />
))}
</Flex>
);
export const Examples = DefaultTemplate.bind({});
207 changes: 207 additions & 0 deletions src/components/ListComposable/components/ListProvider/ListProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import React from 'react';

import type {
ListItemId,
ListItemType,
ListProviderProps,
SetActiveItem,
SetGroupState,
} from '../../types';
import {findNextIndex} from '../../utils/findNextIndex';
import {scrollToItem} from '../../utils/scrollToItem';
import {ListContext} from '../ListContext/ListContext';

import {useDisabledState} from './useDisabledState';
import {useFilterControlledState} from './useFilterControlledState';
import {useGroupsExpandedState} from './useGroupsExpandedState';
import {useItemsControlledState} from './useItemsControlledState';
import {usePreparedItemsState} from './usePreparedItemsState';
import {useSelectedState} from './useSelectedState';

export function ListProvider<T>({
children,
items: originalItems,
size = 'm',
disabled: outerDisabledState,
expandedState: outerExpandedState,
initialActiveItemId,
onItemClick: _onItemClick,
selected: userSelectedState,
onGroupItemClick: _onGroupItemClick,
selectable,
filter: userControlledFilterValue,
}: ListProviderProps<T>) {
const refFilter = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef(null);

const [disabled, setDisabled] = useDisabledState(outerDisabledState);
const [selected, setSelected] = useSelectedState(userSelectedState);
const [filter, onFilterChange] = useFilterControlledState(userControlledFilterValue);
const [expandedState, setGroupState] = useGroupsExpandedState(outerExpandedState);

const [activeItem, setActiveItem] = React.useState<ListItemId | null>(() => {
if (initialActiveItemId && initialActiveItemId in byId) {
return initialActiveItemId;
}

return null;
});
const [_items, setItems] = useItemsControlledState(originalItems);

const formatInternalItems = React.useCallback(
(formatFn?: (items: ListItemType<T>[]) => ListItemType<T>[]) => {
if (formatFn) {
setItems(formatFn(originalItems));
}
},
[originalItems, setItems],
);

const [{order, byId, groupsState, itemsState}] = usePreparedItemsState<T>(
_items,
expandedState,
);

const itemHandlers = React.useMemo(() => {
return (id: ListItemId) => ({
onMouseEnter: () => setActiveItem(id),
onMouseLeave: () => setActiveItem(null),
});
}, [setActiveItem]);

const _activateItem = React.useCallback(
(index?: number, scrollTo = true) => {
if (typeof index === 'number' && order[index]) {
if (scrollTo) {
scrollToItem(order[index], containerRef.current ?? undefined);
}

setActiveItem(order[index]);
}
},
[order],
);

const handleKeyMove = React.useCallback(
(event: React.KeyboardEvent, step: number, defaultItemIndex = 0) => {
event.preventDefault();
const notSureIndex = order.findIndex((i) => i === activeItem);
const index = (notSureIndex > -1 ? notSureIndex : defaultItemIndex) + step;
_activateItem(
findNextIndex({
list: order,
index,
step: Math.sign(step),
disabledItems: disabled,
}),
);
},
[_activateItem, activeItem, order, disabled],
);

const onGroupItemClick = React.useCallback(
(id: ListItemId, fromKeyboard = false) => {
if (_onGroupItemClick) {
_onGroupItemClick(byId[id], id, fromKeyboard);
} else {
setGroupState((x) => {
return {
...x,
[id]: typeof x[id] === 'undefined' ? false : !x[id],
};
});
}
},
[_onGroupItemClick, byId, setGroupState],
);

const onItemClick = React.useMemo(() => {
if (_onItemClick) {
return (id: ListItemId, fromKeyboard = false) => {
_onItemClick(byId[id], id, fromKeyboard);
};
}

if (selectable) {
return (id: ListItemId, _fromKeyboard = false) => {
setSelected((x) =>
selectable === 'multiple'
? {
...x,
[id]: !x[id],
}
: {[id]: !x[id]},
);
};
}

return undefined;
}, [selectable, _onItemClick, byId, setSelected]);

const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLUListElement | HTMLDivElement>) => {
switch (event.key) {
case 'ArrowDown': {
handleKeyMove(event, 1, -1);
break;
}
case 'ArrowUp': {
handleKeyMove(event, -1);
break;
}
case ' ':
case 'Enter': {
if (activeItem && !disabled[activeItem]) {
event.preventDefault();
// user try to control groups state outside
if (activeItem in groupsState) {
onGroupItemClick(activeItem, true);
} else {
onItemClick?.(activeItem, true);
}
}
break;
}
default: {
if (refFilter.current) {
refFilter.current.focus();
}
}
}
},
[onGroupItemClick, activeItem, groupsState, handleKeyMove, onItemClick, disabled],
);

return (
<ListContext.Provider
value={{
itemHandlers,
activeItem,
size,
handleKeyDown,
containerRef,
listRef,
refFilter,
setActiveItem: setActiveItem as SetActiveItem,
byId,
order,
groupsState,
formatInternalItems,
filter,
onFilterChange,
expandedState,
setSelected,
setGroupState: setGroupState as SetGroupState,
onItemClick,
selected,
onGroupItemClick,
itemsState,
disabled,
setDisabled,
}}
>
{children}
</ListContext.Provider>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';

import type {ListItemId} from '../../types';

export const useDisabledState = (outerDisabledState?: Record<ListItemId, boolean>) => {
const firstRenderRef = React.useRef(true);
const [state, setState] = React.useState(() => outerDisabledState || {});

React.useEffect(() => {
if (firstRenderRef.current) {
firstRenderRef.current = false;
} else {
setState(outerDisabledState || {});
}
}, [outerDisabledState]);

return [state, setState] as const;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';

export const useFilterControlledState = (externalFilterValue?: string) => {
const [filter, setFilter] = React.useState(() => externalFilterValue ?? '');

React.useEffect(() => {
setFilter(externalFilterValue ?? '');
}, [externalFilterValue]);

return [filter, setFilter] as const;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';

export const useGroupsExpandedState = (groupsExpandedState?: Record<string, boolean>) => {
const isFirstRenderRef = React.useRef(true);
const [state, setState] = React.useState(() => groupsExpandedState || {});

React.useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
} else {
setState(groupsExpandedState || {});
}
}, [groupsExpandedState]);

return [state, setState] as const;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';

import type {ListItemType} from '../../types';

export function useItemsControlledState<T>(_items: ListItemType<T>[]) {
const isFirstRenderRef = React.useRef(true);
const [items, setItems] = React.useState(() => _items);

React.useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
} else {
setItems(_items);
}
}, [_items]);

return [items, setItems] as const;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';

import type {ListItemType, ParsedState} from '../../types';
import {flattenItems} from '../../utils/flattenItems';
import {parseDataWithChildren} from '../../utils/parseDataWithChildren';

export function usePreparedItemsState<T>(
items: ListItemType<T>[],
expandedState?: Record<string, boolean>,
) {
const isFirstRenderRef = React.useRef(true);
const [state, setState] = React.useState<ParsedState<T>>(() => parseDataWithChildren<T>(items));
const [order, setOrder] = React.useState(() => flattenItems(items, expandedState));

React.useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
} else {
setState(parseDataWithChildren<T>(items));
setOrder(flattenItems(items, expandedState));
}
}, [items, expandedState]);

return [{order, ...state}];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';

import type {ListItemId} from '../../types';

export const useSelectedState = (_selected?: Record<ListItemId, boolean>) => {
const isFirstRenderRef = React.useRef(true);
const [selected, setSelected] = React.useState(() => _selected || {});

React.useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
} else {
setSelected(_selected || {});
}
}, [_selected]);

return [selected, setSelected] as const;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';

import type {ListContainerRenderProps} from '../../types';

export const SimpleListContainer = React.forwardRef(function <T>(
{items, children, className}: ListContainerRenderProps<T>,
ref: any,
) {
return (
<ul ref={ref} className={className}>
{items.map((item, index) => children({id: index, item}))}
</ul>
);
});

SimpleListContainer.displayName = 'SimpleListContainer';
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';

import AutoSizer, {Size} from 'react-virtualized-auto-sizer';
import {VariableSizeList as List} from 'react-window';

import type {ListContainerRenderProps} from '../../types';

interface RenderRowProps<T> {
index: number;
style: React.CSSProperties;
data: T[];
}

export const VirtualizedListContainer = React.forwardRef(function <T>(
{items, className, getItemSize, children}: ListContainerRenderProps<T>,
ref: any,
) {
const RenderRow = React.useCallback(
({index, style, data: items}: RenderRowProps<T>) => {
return (
<div style={style} key={index}>
{children({
id: index,
item: items[index],
})}
</div>
);
},
[children],
);

return (
<AutoSizer>
{({width, height}: Size) => (
<List
overscanCount={10}
className={className}
ref={ref}
width={width}
height={height}
itemCount={items.length}
itemData={items}
itemSize={getItemSize}
// TODO: implement page size
// onItemsRendered={console.log}
>
{RenderRow}
</List>
)}
</AutoSizer>
);
});

VirtualizedListContainer.displayName = 'VirtualizedListContainer';
14 changes: 14 additions & 0 deletions src/components/ListComposable/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {block} from '../utils/cn';

import './ListComposable.scss';

export const bListComposable = block('list-composable');

export const GROUPED_ID_SEPARATOR = '-';

export const modToHeight = {
s: [24, 44],
m: [28, 48],
l: [36, 52],
xl: [44, 58],
};
148 changes: 148 additions & 0 deletions src/components/ListComposable/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type {QAProps} from '../types';

import type {modToHeight} from './constants';

export type ListItemId = string;
/**
* Default fallback id type.
* The core idea is to store original index and and nested children index
*/
export type GroupedId = string;

export interface ListItemType<T = unknown> {
/**
* Required if you want to control list state from internal place.
* For example to control expanded groups state
*/
id?: string;
data: T;
children?: ListItemType<T>[];
}
export type GroupParsedState = {
childrenCount: number;
};

export type ItemParsedState = {
indentation: number;
};
export type ParsedState<T = unknown> = {
/**
* stored internal meta info about item
* Note: Groups are also items
*/
itemsState: Record<ListItemId, ItemParsedState>;
/**
* Normalized original data
*/
byId: Record<ListItemId, T>;
/**
* Re
*/
groupsState: Record<ListItemId, GroupParsedState>;
};

export type ListSizeTypes = keyof typeof modToHeight;

export interface ListItemBaseData {
title: string;
subtitle?: string;
leftSlot?: React.ReactNode;
rightSlot?: React.ReactNode;
}

export interface ListItemRendererProps<T> {
id: number;
item: T;
}

export interface ListContainerRenderProps<T> {
items: T[];
getItemSize(index: number): number;
className?: string;
children(props: ListItemRendererProps<T>): React.ReactNode;
}

export type OnListItemClick<T> = (
item: ListItemType<T>['data'],
itemId: ListItemId,
fromKeyboard?: boolean,
) => void;

export type SetActiveItem = (id: ListItemId | null) => void;

export type ExpandedState = Record<ListItemId, boolean>;

export type SetGroupState = (value: (currentState: ExpandedState) => ExpandedState) => void;

export interface RenderListItemViewProps extends QAProps {
/**
* @default `m`
*/
size: ListSizeTypes;
isGroup: boolean;
selected: boolean;
active: boolean;
disabled: boolean;
/**
* By default hovered elements has active styles. You can switch off this behavior
*/
activeOnHover: boolean;
indentation: number;
onClick?(): void;
title: string | React.ReactNode;
subtitle?: string;
leftSlot: React.ReactNode;
rightSlot: React.ReactNode;
}

export type ListContextType<T> = ParsedState<T> & {
activeItem: ListItemId | null;
size: ListSizeTypes;
order: ListItemId[];
handleKeyDown(e: React.KeyboardEvent<HTMLUListElement | HTMLDivElement>): void;
containerRef: React.RefObject<HTMLDivElement>;
listRef: React.RefObject<HTMLUListElement>;
refFilter: React.RefObject<HTMLInputElement>;
expandedState: ExpandedState;
setActiveItem: SetActiveItem;
setSelected(fn: (selected: Record<ListItemId, boolean>) => Record<ListItemId, boolean>): void;
onItemClick?(id: ListItemId, fromKeyboard?: boolean): void;
onGroupItemClick(id: ListItemId, fromKeyboard?: boolean): void;
setGroupState: SetGroupState;
itemHandlers(id: ListItemId): {
onMouseEnter: () => void;
onMouseLeave: () => void;
};
selected: Record<ListItemId, boolean>;
formatInternalItems(formatFn?: (originalItems: ListItemType<T>[]) => ListItemType<T>[]): void;
filter: string;
onFilterChange(value: string): void;
disabled: Record<ListItemId, boolean>;
setDisabled(fn: (state: Record<ListItemId, boolean>) => Record<ListItemId, boolean>): void;
};

export interface ListProviderProps<T> {
items: ListItemType<T>[];
size?: ListSizeTypes;
children: React.ReactNode;
onItemClick?: OnListItemClick<T>;
onGroupItemClick?: OnListItemClick<T>;
expandedState?: Record<string, boolean>;
initialActiveItemId?: string;
/**
* Ability to control items selections from outside
*/
selected?: Record<ListItemId, boolean>;
/**
* Needs than you try to use `selected` list state
*/
selectable?: 'single' | 'multiple';
/**
* Ability to control filter input value from outside
*/
filter?: string;
/**
* Ability to control disabled items state from outside
*/
disabled?: Record<ListItemId, boolean>;
}
6 changes: 6 additions & 0 deletions src/components/ListComposable/utils/computeItemSize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {modToHeight} from '../constants';
import type {ListItemBaseData, ListSizeTypes} from '../types';

export const computeItemSize = (size: ListSizeTypes, item?: ListItemBaseData) => {
return modToHeight[size][item ? Number(Boolean(item.subtitle)) : 0];
};
2 changes: 2 additions & 0 deletions src/components/ListComposable/utils/createListItemQa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const createListItemQa = (itemId: string, listId?: string) =>
listId ? `${listId}-${itemId}` : `${itemId}`;
27 changes: 27 additions & 0 deletions src/components/ListComposable/utils/defaultFilterItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type {ListItemType} from '../types';

export function defaultFilterItems<T>(
items: ListItemType<T>[],
filterFn: (data: T) => boolean,
): ListItemType<T>[] {
console.time('defaultFilterItems');
const getChildren = (result: ListItemType<T>[], item: ListItemType<T>) => {
if (item.children) {
const children = item.children.reduce(getChildren, []);

if (children.length) {
result.push({data: item.data, children});
} else if (filterFn(item.data)) {
result.push({data: item.data, children: []});
}
} else if (filterFn(item.data)) {
result.push(item);
}

return result;
};

const res = items.reduce<ListItemType<T>[]>(getChildren, []);
console.timeEnd('defaultFilterItems');
return res;
}
20 changes: 20 additions & 0 deletions src/components/ListComposable/utils/findNextIndex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
interface FindNextItemsProps {
list: string[];
index: number;
step: number;
disabledItems?: Record<string, boolean>;
}

export const findNextIndex = ({list, index, step, disabledItems = {}}: FindNextItemsProps) => {
const dataLength = list.length;
let currentIndex = (index + dataLength) % dataLength;

for (let i = 0; i < dataLength; i += 1) {
if (list[currentIndex] && !disabledItems[currentIndex]) {
return currentIndex;
}
currentIndex = (currentIndex + dataLength + step) % dataLength;
}

return undefined;
};
40 changes: 40 additions & 0 deletions src/components/ListComposable/utils/flattenItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {ListItemId, ListItemType} from '../types';

import {getGroupedId} from './groupedIdUtils';

export function flattenItems<T>(
items: ListItemType<T>[],
groupsExpandedState: Record<ListItemId, boolean> = {},
): ListItemId[] {
console.time('flattenItems');

const getNestedIds = (
order: string[],
item: ListItemType<T>,
index: number,
parentId?: string,
) => {
const groupedId = getGroupedId(index, parentId);
const id = item.id || groupedId;

order.push(id);

if (item.children) {
// remove from order collapsed groups
if (!(id in groupsExpandedState && !groupsExpandedState[id])) {
order.push(
...item.children.reduce<string[]>(
(acc, item, idx) => getNestedIds(acc, item, idx, groupedId),
[],
),
);
}
}

return order;
};

const result = items.reduce<string[]>((acc, item, index) => getNestedIds(acc, item, index), []);
console.timeEnd('flattenItems');
return result;
}
8 changes: 8 additions & 0 deletions src/components/ListComposable/utils/groupedIdUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {GROUPED_ID_SEPARATOR} from '../constants';
import type {GroupedId} from '../types';

export const getGroupedId = (index: string | number, parentId?: string): GroupedId =>
parentId ? `${parentId}${GROUPED_ID_SEPARATOR}${index}` : `${index}`;

export const parseGroupedId = (parentId: GroupedId): string[] =>
parentId.split(GROUPED_ID_SEPARATOR);
39 changes: 39 additions & 0 deletions src/components/ListComposable/utils/parseDataWithChildren.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type {ListItemType, ParsedState} from '../types';

import {getGroupedId, parseGroupedId} from './groupedIdUtils';

export function parseDataWithChildren<T>(items: ListItemType<T>[]): ParsedState<T> {
console.time('parseDataWithChildren');
const result = {
byId: {},
groupsState: {},
itemsState: {},
} as ParsedState<T>;

const traverseItems = (item: ListItemType<T>, index: number, parentId?: string) => {
const groupedId = getGroupedId(index, parentId);
const id = item.id || groupedId;

result.byId[id] = item.data;

if (!result.itemsState[id]) {
result.itemsState[id] = {indentation: 0};
}

if (parentId) {
result.itemsState[id].indentation = parseGroupedId(parentId).length;
}

if (item.children) {
result.groupsState[id] = {
childrenCount: item.children.length,
};

item.children.forEach((item, index) => traverseItems(item, index, groupedId));
}
};

items.forEach((item, index) => traverseItems(item, index));
console.timeEnd('parseDataWithChildren');
return result;
}
17 changes: 17 additions & 0 deletions src/components/ListComposable/utils/scrollToItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type {ListItemId} from '../types';

import {createListItemQa} from './createListItemQa';

export const scrollToItem = (itemId: ListItemId, containerRef?: HTMLDivElement) => {
if (document) {
const element = (containerRef || document).querySelector(
`[data-qa="${createListItemQa(itemId)}"]`,
);

if (element) {
element.scrollIntoView({
block: 'nearest',
});
}
}
};
1 change: 1 addition & 0 deletions src/components/layout/Flex/Flex.tsx
Original file line number Diff line number Diff line change
@@ -83,6 +83,7 @@ export interface FlexProps<T extends React.ElementType = 'div'> extends QAProps
className?: string;
title?: string;
ref?: React.ComponentPropsWithRef<T>['ref'];
onClick?(e: unknown): void;
}

/**

0 comments on commit 868b383

Please sign in to comment.