-
Notifications
You must be signed in to change notification settings - Fork 99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: new list proposal #1039
feat: new list proposal #1039
Changes from 1 commit
868b383
f3939e3
4c03a11
f9f1d58
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
} | ||
} | ||
} |
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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. storybook has option for this framework.options.strictMode = true |
||
<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 /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Custom component example |
||
<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({}); |
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> | ||
); | ||
}; |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe |
||
} | ||
|
||
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} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does |
||
</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> | ||
); | ||
} |
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} | ||
/> | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--yc-list-height
- >--g-list-height
?