diff --git a/src/components/ListView.tsx b/src/components/ListView.tsx new file mode 100644 index 00000000..32b44ab2 --- /dev/null +++ b/src/components/ListView.tsx @@ -0,0 +1,139 @@ +import React, { + Dispatch, + SetStateAction, + createContext, + memo, + useCallback, + useContext, + useEffect, + useRef, + useState, + RefObject, +} from 'react'; + +import { useMounted } from '../hooks/useMounted'; +import { useKeyPress } from '../hooks'; + +interface ListViewProps { + onClick?: (value: T) => void; + onKeyboardClick?: (value: T) => void; + children: React.ReactNode; +} + +interface ListViewContext { + registerItem: (value: unknown) => number; + items: RefObject; + cursor?: number; + hovered?: number; + setHovered?: Dispatch>; +} +const listViewContext = createContext(undefined); + +const useListViewContext = () => { + const ctx = useContext(listViewContext); + + if (!ctx) { + throw new Error('Dont use element outside of `ListView` component'); + } + + return ctx; +}; + +export const ListView: React.FC = ({ children, onKeyboardClick }) => { + const downPress = useKeyPress('ArrowDown'); + const upPress = useKeyPress('ArrowUp'); + const spacePress = useKeyPress(' '); + const enterPress = useKeyPress('Enter'); + const [cursor, setCursor] = useState(); + const [hovered, setHovered] = useState(); + const items = useRef([]); + + const registerItem = useCallback((value: unknown) => items.current.push(value) - 1, [items]); + + useEffect(() => { + if (downPress) { + setCursor((prevState) => { + if (prevState === undefined) return 0; + if (prevState < items.current.length - 1) return prevState + 1; + if (prevState === items.current.length - 1) return 0; + + return prevState; + }); + + setHovered(undefined); + } + }, [downPress]); + + useEffect(() => { + if (upPress) { + setCursor((prevState) => { + if (prevState === undefined) return items.current.length - 1; + if (prevState > 0) return prevState - 1; + if (prevState === 0) return items.current.length - 1; + + return prevState; + }); + + setHovered(undefined); + } + }, [upPress]); + + useEffect(() => { + if (cursor !== undefined && (spacePress || enterPress)) { + onKeyboardClick?.(items.current[cursor]); + } + }, [cursor, spacePress, enterPress, items, onKeyboardClick]); + + useEffect(() => { + if (hovered !== undefined && cursor !== undefined) { + setCursor(hovered); + } + }, [items, hovered, cursor]); + + return ( + + {children} + + ); +}; + +interface ListViewItemChildProps { + active?: boolean; + hovered?: boolean; + onMouseMove: React.MouseEventHandler; + onMouseLeave: React.MouseEventHandler; +} + +interface ListViewItemProps { + renderItem: (props: ListViewItemChildProps) => React.ReactNode; + value?: unknown; +} + +export const ListViewItem: React.FC = memo(({ renderItem, value }) => { + const { cursor, registerItem, setHovered, hovered } = useListViewContext(); + const mounted = useMounted(); + const id = useRef(undefined); + + useEffect(() => { + if (mounted) { + id.current = registerItem(value); + } + }, [mounted, registerItem, value]); + + const onMouseLeave = useCallback>(() => { + setHovered?.(undefined); + }, [setHovered]); + + const onMouseMove = useCallback>(() => { + if (hovered !== id.current) { + setHovered?.(id.current); + } + }, [setHovered, hovered]); + + return renderItem({ + active: cursor !== undefined && id.current === cursor, + hovered: hovered !== undefined && id.current === hovered, + onMouseLeave, + onMouseMove, + }); +}); diff --git a/src/stories/ListItem.stories.tsx b/src/stories/ListItem.stories.tsx new file mode 100644 index 00000000..35c528c2 --- /dev/null +++ b/src/stories/ListItem.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; + +import { ListView, ListViewItem } from '../components/ListView'; +import { Table, TableRow, TableCell } from '../components/Table'; + +export default { + title: 'ListView', + component: ListView, +} as Meta; + +const data = Array.from({ length: 10 }, (_, i) => ({ + id: String(i + 1), + title: `Title for ${i + 1} record`, +})); + +export const Default: StoryFn = () => { + return ( + + + {data.slice(0, 5).map((item) => ( + ( + + + {item.id} + + {item.title} + + )} + /> + ))} +
+
+ + {data + .slice(5) + .reverse() + .map((item) => ( + ( + + + {item.id} + + {item.title} + + )} + /> + ))} +
+
+ ); +};