Skip to content

Commit

Permalink
feat(ListView): add a new component
Browse files Browse the repository at this point in the history
  • Loading branch information
LamaEats committed Aug 24, 2023
1 parent 748eaa7 commit 104a8e6
Show file tree
Hide file tree
Showing 2 changed files with 195 additions and 0 deletions.
139 changes: 139 additions & 0 deletions src/components/ListView.tsx
Original file line number Diff line number Diff line change
@@ -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?: <T>(value: T) => void;
onKeyboardClick?: <T>(value: T) => void;
children: React.ReactNode;
}

interface ListViewContext {
registerItem: (value: unknown) => number;
items: RefObject<unknown[]>;
cursor?: number;
hovered?: number;
setHovered?: Dispatch<SetStateAction<undefined | number>>;
}
const listViewContext = createContext<ListViewContext | undefined>(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<ListViewProps> = ({ children, onKeyboardClick }) => {
const downPress = useKeyPress('ArrowDown');
const upPress = useKeyPress('ArrowUp');
const spacePress = useKeyPress(' ');
const enterPress = useKeyPress('Enter');
const [cursor, setCursor] = useState<number | undefined>();
const [hovered, setHovered] = useState<number | undefined>();
const items = useRef<unknown[]>([]);

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 (
<listViewContext.Provider value={{ items, cursor, hovered, registerItem, setHovered }}>
{children}
</listViewContext.Provider>
);
};

interface ListViewItemChildProps {
active?: boolean;
hovered?: boolean;
onMouseMove: React.MouseEventHandler<HTMLElement>;
onMouseLeave: React.MouseEventHandler<HTMLElement>;
}

interface ListViewItemProps {
renderItem: (props: ListViewItemChildProps) => React.ReactNode;
value?: unknown;
}

export const ListViewItem: React.FC<ListViewItemProps> = memo(({ renderItem, value }) => {
const { cursor, registerItem, setHovered, hovered } = useListViewContext();
const mounted = useMounted();
const id = useRef<number | undefined>(undefined);

useEffect(() => {
if (mounted) {
id.current = registerItem(value);
}
}, [mounted, registerItem, value]);

const onMouseLeave = useCallback<React.MouseEventHandler<HTMLElement>>(() => {
setHovered?.(undefined);
}, [setHovered]);

const onMouseMove = useCallback<React.MouseEventHandler<HTMLElement>>(() => {
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,
});
});
56 changes: 56 additions & 0 deletions src/stories/ListItem.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ListView>;

const data = Array.from({ length: 10 }, (_, i) => ({
id: String(i + 1),
title: `Title for ${i + 1} record`,
}));

export const Default: StoryFn = () => {
return (
<ListView>
<Table width={200} gap={10}>
{data.slice(0, 5).map((item) => (
<ListViewItem
key={item.title}
renderItem={({ hovered, active, ...callbacks }) => (
<TableRow interactive focused={hovered || active} gap={10} {...callbacks}>
<TableCell width="2ch" justify="end">
{item.id}
</TableCell>
<TableCell>{item.title}</TableCell>
</TableRow>
)}
/>
))}
</Table>
<br />
<Table width={200} gap={10}>
{data
.slice(5)
.reverse()
.map((item) => (
<ListViewItem
key={item.title}
renderItem={({ hovered, active, ...callbacks }) => (
<TableRow interactive focused={hovered || active} gap={10} {...callbacks}>
<TableCell width="2ch" justify="end">
{item.id}
</TableCell>
<TableCell>{item.title}</TableCell>
</TableRow>
)}
/>
))}
</Table>
</ListView>
);
};

0 comments on commit 104a8e6

Please sign in to comment.