diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2021-11-19 15:37:38 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2021-11-23 15:38:23 +0100 |
| commit | 2a45478095df7fae4dcebed362dfed66af17f1b7 (patch) | |
| tree | fd4da12d63f662b97a31171acfcca840c26d0530 /gui/src/renderer | |
| parent | 78a44ff427c5277b13ec2c19171dc4d20ccbca0e (diff) | |
| download | mullvadvpn-2a45478095df7fae4dcebed362dfed66af17f1b7.tar.xz mullvadvpn-2a45478095df7fae4dcebed362dfed66af17f1b7.zip | |
Add component for rendering lists
Diffstat (limited to 'gui/src/renderer')
| -rw-r--r-- | gui/src/renderer/components/List.tsx | 148 |
1 files changed, 148 insertions, 0 deletions
diff --git a/gui/src/renderer/components/List.tsx b/gui/src/renderer/components/List.tsx new file mode 100644 index 0000000000..1d15752f4f --- /dev/null +++ b/gui/src/renderer/components/List.tsx @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import Accordion from './Accordion'; + +export const stringValueAsKey = (value: string): string => value; + +interface ListProps<T> { + items: Array<T>; + getKey: (data: T) => string; + children: (data: T) => React.ReactNode; + skipAddTransition?: boolean; + skipInitialAddTransition?: boolean; + skipRemoveTransition?: boolean; +} + +interface RowData<T> { + key: string; + data: T; +} + +interface RowDisplayData<T> extends RowData<T> { + removing: boolean; +} + +export default function List<T>(props: ListProps<T>) { + const [displayItems, setDisplayItems] = useState(() => + convertToRowDisplayData(props.items, props.getKey), + ); + // Skip add transition on first render when initial items are added. + const skipAddTransition = useRef(props.skipInitialAddTransition ?? false); + + useEffect(() => { + setDisplayItems((prevItems) => { + if (props.skipRemoveTransition) { + return convertToRowDisplayData(props.items, props.getKey); + } else { + const nextItems = convertToRowData(props.items, props.getKey); + return calculateItemList(prevItems, nextItems); + } + }); + }, [props.items, props.getKey]); + + useEffect(() => { + // Set to animate accordion for added items after first render unless + // props.skipAddTransition === true. + skipAddTransition.current = props.skipAddTransition ?? false; + }, []); + + const onRemoved = useCallback((key: string) => { + setDisplayItems((items) => items.filter((item) => item.key !== key)); + }, []); + + return ( + <> + {displayItems.map((displayItem) => ( + <ListItem + key={displayItem.key} + data={displayItem} + onRemoved={onRemoved} + render={props.children} + skipAddTransition={skipAddTransition.current} + /> + ))} + </> + ); +} + +interface ListItemProps<T> { + data: RowDisplayData<T>; + onRemoved: (key: string) => void; + render: (data: T) => React.ReactNode; + skipAddTransition: boolean; +} + +function ListItem<T>(props: ListItemProps<T>) { + // If skipAddTransition is true then the item is expanded from the beginning. + const [expanded, setExpanded] = useState(props.skipAddTransition); + + const onTransitionEnd = useCallback(() => { + if (props.data.removing) { + props.onRemoved(props.data.key); + } + }, [props.onRemoved, props.data.key, props.data.removing]); + + // Expands after initial render and collapses when item is set to being removed. + useEffect(() => setExpanded(!props.data.removing), [props.data.removing]); + + return ( + <Accordion expanded={expanded} onTransitionEnd={onTransitionEnd}> + {props.render(props.data.data)} + </Accordion> + ); +} + +function convertToRowData<T>(items: Array<T>, getKey: (data: T) => string): Array<RowData<T>> { + return items.map((item) => ({ key: getKey(item), data: item })); +} + +function convertToRowDisplayData<T>( + items: Array<T>, + getKey: (data: T) => string, + removing = false, +): Array<RowDisplayData<T>> { + return convertToRowData(items, getKey).map((item) => ({ ...item, removing })); +} + +export function calculateItemList<T>( + prevItemsList: Array<RowDisplayData<T>>, + nextItemsList: Array<RowData<T>>, +): Array<RowDisplayData<T>> { + const prevItems = [...prevItemsList]; + const nextItems = [...nextItemsList]; + + if ( + prevItems.length !== nextItems.length || + !prevItems.every((prevItem, i) => prevItem.key === nextItems[i].key) + ) { + // If the nextItems contains changes from prevItems we want to calculate the next state. + const combinedItems: Array<RowDisplayData<T>> = []; + + while (prevItems.length > 0 || nextItems.length > 0) { + const prevItem = prevItems[0]; + const nextItem = nextItems[0]; + + // Either prevItem or nextItem must have a value since at least one of the lists isn't + // empty. + if (prevItem?.key === nextItem?.key) { + combinedItems.push({ ...prevItem, removing: false }); + prevItems.shift(); + nextItems.shift(); + } else if ( + prevItem === undefined || + nextItems.find((item) => item.key === prevItem.key) !== undefined + ) { + // An item has been added if there are no more previous items or if the current prevItem + // exists later in nextItems. + combinedItems.push({ ...nextItem, removing: false }); + nextItems.shift(); + } else { + combinedItems.push({ ...prevItem, removing: true }); + prevItems.shift(); + } + } + + return combinedItems; + } else { + return prevItemsList; + } +} |
