Files
Homarr/src/components/Dashboard/Tiles/Widgets/DraggableList.tsx

136 lines
3.9 KiB
TypeScript
Raw Normal View History

2023-02-14 13:10:52 +01:00
import { Collapse, createStyles, Flex, Stack, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconChevronDown, IconGripVertical } from '@tabler/icons';
import { Reorder, useDragControls } from 'framer-motion';
2023-02-14 13:10:52 +01:00
import { FC, ReactNode, useEffect, useRef } from 'react';
import { IDraggableListInputValue } from '../../../../widgets/widgets';
const useStyles = createStyles((theme) => ({
container: {
display: 'flex',
flexDirection: 'column',
borderRadius: theme.radius.md,
border: `1px solid ${
theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]
}`,
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.white,
marginBottom: theme.spacing.xs,
},
row: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
gap: theme.spacing.sm,
},
middle: {
flexGrow: 1,
},
symbol: {
fontSize: 16,
},
clickableIcons: {
color: theme.colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.gray[6],
cursor: 'pointer',
userSelect: 'none',
transition: 'transform .3s ease-in-out',
},
rotate: {
transform: 'rotate(180deg)',
},
collapseContent: {
padding: '12px 16px',
},
}));
type DraggableListParams = {
value: IDraggableListInputValue['defaultValue'];
onChange: (value: IDraggableListInputValue['defaultValue']) => void;
labels: Record<string, string>;
children?: Record<string, ReactNode>;
};
export const DraggableList: FC<DraggableListParams> = (props) => {
const keys = props.value.map((v) => v.key);
return (
<div>
<Reorder.Group
axis="y"
values={keys}
onReorder={(order) =>
props.onChange(order.map((key) => props.value.find((v) => v.key === key)!))
}
as="div"
>
{props.value.map((item) => (
<ListItem key={item.key} item={item} label={props.labels[item.key]}>
{props.children?.[item.key]}
</ListItem>
))}
</Reorder.Group>
</div>
);
};
const ListItem: FC<{
item: IDraggableListInputValue['defaultValue'][number];
label: string;
}> = (props) => {
const { classes, cx } = useStyles();
const controls = useDragControls();
const [opened, handlers] = useDisclosure(false);
const hasContent = props.children != null && Object.keys(props.children).length !== 0;
2023-02-14 13:10:52 +01:00
// Workaround for mobile drag controls not working
// https://github.com/framer/motion/issues/1597#issuecomment-1235026724
const dragRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const touchHandler: EventListener = (e) => e.preventDefault();
const dragItem = dragRef.current;
if (dragItem) {
dragItem.addEventListener('touchstart', touchHandler, { passive: false });
return () => {
dragItem.removeEventListener('touchstart', touchHandler);
};
}
return undefined;
}, [dragRef]);
return (
<Reorder.Item value={props.item.key} dragListener={false} dragControls={controls} as="div">
<div className={classes.container}>
<div className={classes.row}>
2023-02-14 13:10:52 +01:00
<Flex ref={dragRef} onPointerDown={(e) => controls.start(e)}>
<IconGripVertical className={classes.clickableIcons} size={18} stroke={1.5} />
</Flex>
<div className={classes.middle}>
<Text className={classes.symbol}>{props.label}</Text>
</div>
{hasContent && (
<IconChevronDown
className={cx(classes.clickableIcons, { [classes.rotate]: opened })}
onClick={() => handlers.toggle()}
size={18}
stroke={1.5}
/>
)}
</div>
{hasContent && (
<Collapse in={opened}>
<Stack className={classes.collapseContent}>{props.children}</Stack>
</Collapse>
)}
</div>
</Reorder.Item>
);
};