summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2021-11-19 15:37:38 +0100
committerOskar Nyberg <oskar@mullvad.net>2021-11-23 15:38:23 +0100
commit2a45478095df7fae4dcebed362dfed66af17f1b7 (patch)
treefd4da12d63f662b97a31171acfcca840c26d0530 /gui/src/renderer
parent78a44ff427c5277b13ec2c19171dc4d20ccbca0e (diff)
downloadmullvadvpn-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.tsx148
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;
+ }
+}