feat: masonry grid

This commit is contained in:
Morten Olsen
2023-06-16 14:08:38 +02:00
parent bc0d501d98
commit c11d5248ff
9 changed files with 207 additions and 27 deletions

View File

@@ -42,12 +42,14 @@
},
"types": "./dist/cjs/types/index.d.ts",
"dependencies": {
"@refocus/sdk": "workspace:^",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-tabs": "^1.0.4",
"@refocus/sdk": "workspace:^",
"framer-motion": "^10.12.16",
"react-icons": "^4.9.0",
"react-markdown": "^6.0.3",
"styled-components": "6.0.0-rc.3"
"styled-components": "6.0.0-rc.3",
"usehooks-ts": "^2.9.1"
}
}

View File

@@ -7,3 +7,4 @@ export * from './dialog';
export * from './list';
export * from './dropdown';
export * from './button';
export * from './masonry';

View File

@@ -1,3 +1,4 @@
import { AnimatePresence, motion } from 'framer-motion';
import { View } from '../view';
type ListProps = {
@@ -7,7 +8,15 @@ type ListProps = {
const List = ({ children }: ListProps) => {
return (
<View $fc $gap="sm" $p="sm">
{children}
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 10, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
{children}
</motion.div>
</AnimatePresence>
</View>
);
};

View File

@@ -0,0 +1,115 @@
import {
Children,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { motion } from 'framer-motion';
import { MasonryItem } from './item';
import { styled } from 'styled-components';
import { useDebounce } from 'usehooks-ts';
type Props = {
children: React.ReactNode;
};
const maxColumnWidth = 400;
const gutter = 16;
const ItemWrapper = styled(motion.div)<{}>`
position: absolute;
`;
const Masonry = ({ children }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState<number>(1);
const [columnWidth, setColumnWidth] = useState<number>(0);
const [heights, setHeights] = useState<number[]>([]);
const setHeight = useCallback((index: number, height: number) => {
setHeights((current) => {
if (current[index] === height) {
return current;
}
const next = [...current];
next[index] = height;
return next;
});
}, []);
const elements = useMemo(() => {
return Children.toArray(children).map((child, index) => {
const setItemHeight = (height: number) => {
setHeight(index, height);
};
return (
<MasonryItem key={index} setHeight={setItemHeight}>
{child}
</MasonryItem>
);
});
}, [children, setHeight]);
const layout = useMemo(() => {
const columnHeights = Array.from({ length: columns }).map(() => 0);
return heights.map((height) => {
const lowestColumn = columnHeights.indexOf(Math.min(...columnHeights));
const currentHeight = columnHeights[lowestColumn];
columnHeights[lowestColumn] += height + gutter;
const y = currentHeight;
const margin = gutter / 2;
const marginLeft = lowestColumn === 0 ? 0 : margin;
const marginRight = lowestColumn === columns - 1 ? 0 : margin;
const x = lowestColumn * columnWidth + marginLeft;
const width = columnWidth - marginLeft - marginRight;
return { x, y, width };
});
}, [heights, columns, columnWidth]);
const updateSize = useCallback(() => {
if (!ref.current) {
return;
}
const nextWidth = ref.current.getBoundingClientRect().width;
const nextColumns = Math.max(Math.floor(nextWidth / maxColumnWidth), 1);
const nextColumnWidth = nextWidth / nextColumns;
setColumns(nextColumns);
setColumnWidth(nextColumnWidth);
}, []);
useLayoutEffect(() => {
if (!ref.current) {
return;
}
const observer = new ResizeObserver(() => {
updateSize();
});
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [updateSize]);
const debouncedLayout = useDebounce(layout, 10);
return (
<div ref={ref}>
{elements.map((element, index) => (
<ItemWrapper
animate={{
animationDelay: `${index * 0.05}s`,
animationDuration: '0.1s',
transform: `translate(${debouncedLayout[index]?.x}px, ${debouncedLayout[index]?.y}px)`,
width: debouncedLayout[index]?.width,
height: heights[index],
}}
key={index}
>
{element}
</ItemWrapper>
))}
</div>
);
};
export { Masonry };

View File

@@ -0,0 +1,29 @@
import { useLayoutEffect, useRef } from 'react';
type Props = {
children: React.ReactNode;
setHeight: (height: number) => void;
};
const MasonryItem: React.FC<Props> = ({ children, setHeight }) => {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (!ref.current) {
return;
}
const height = ref.current.getBoundingClientRect().height;
setHeight(height);
const observer = new ResizeObserver((entries) => {
setHeight(entries[0].contentRect.height);
});
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [setHeight]);
return <div ref={ref}>{children}</div>;
};
export { MasonryItem };

View File

@@ -5,7 +5,7 @@ import {
useUpdateWidget,
} from '@refocus/sdk';
import { IoAddCircleOutline } from 'react-icons/io5';
import { View } from '../../base';
import { Masonry, View } from '../../base';
import { Widget } from '../widget';
import { AddWidgetFromUrl } from '../add-from-url';
import { styled } from 'styled-components';
@@ -15,14 +15,12 @@ type BoardProps = {
id: string;
};
const Wrapper = styled(View)`
flex-wrap: wrap;
`;
const ItemWrapper = styled(View)`
max-width: 400px;
overflow-y: auto;
max-height: 500px;
max-width: 100%;
box-shadow: 0 0 4px 0px ${({ theme }) => theme.colors.bg.highlight};
border-radius: ${({ theme }) => theme.radii.md}px;
`;
const Board: React.FC<BoardProps> = ({ board, id }) => {
@@ -42,19 +40,21 @@ const Board: React.FC<BoardProps> = ({ board, id }) => {
</AddWidgetFromUrl.Trigger>
</AddWidgetFromUrl>
</View>
<Wrapper $fr>
{Object.entries(board.widgets).map(([widgetId, widget]) => (
<ItemWrapper key={widgetId}>
<Widget
key={widgetId}
id={widget.type}
data={widget.data}
setData={(data) => setWidgetData(id, widgetId, data)}
onRemove={() => removeWidget(id, widgetId)}
/>
</ItemWrapper>
))}
</Wrapper>
<View $p="md">
<Masonry>
{Object.entries(board.widgets).map(([widgetId, widget]) => (
<ItemWrapper key={widgetId}>
<Widget
key={widgetId}
id={widget.type}
data={widget.data}
setData={(data) => setWidgetData(id, widgetId, data)}
onRemove={() => removeWidget(id, widgetId)}
/>
</ItemWrapper>
))}
</Masonry>
</View>
</View>
);
};

View File

@@ -23,6 +23,12 @@ const Wrapper = styled(View)`
background: ${({ theme }) => theme.colors.bg.base};
`;
const WidgetWrapper = styled(View)`
flex-grow: 0;
overflow: hidden;
flex: 1;
`;
const Widget: React.FC<WidgetProps> = ({
id,
data,
@@ -47,9 +53,9 @@ const Widget: React.FC<WidgetProps> = ({
return (
<WidgetProvider id={id} data={data} setData={setData}>
<Wrapper className={className} $fr>
<View $f={1}>
<WidgetWrapper $f={1}>
<WidgetView />
</View>
</WidgetWrapper>
<View $fc>
{hasMenu && (
<DropdownMenu>

View File

@@ -33,6 +33,7 @@ const styles = {
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
break-word: break-all;
`,
} satisfies Record<string, ReturnType<typeof css>>;

23
pnpm-lock.yaml generated
View File

@@ -147,9 +147,6 @@ importers:
packages/ui:
dependencies:
'@refocus/sdk':
specifier: workspace:^
version: link:../sdk
'@radix-ui/react-dialog':
specifier: ^1.0.4
version: 1.0.4(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0)
@@ -159,6 +156,12 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.0.4
version: 1.0.4(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0)
'@refocus/sdk':
specifier: workspace:^
version: link:../sdk
framer-motion:
specifier: ^10.12.16
version: 10.12.16(react-dom@18.2.0)(react@18.2.0)
react-icons:
specifier: ^4.9.0
version: 4.9.0(react@18.2.0)
@@ -168,6 +171,9 @@ importers:
styled-components:
specifier: 6.0.0-rc.3
version: 6.0.0-rc.3(react-dom@18.2.0)(react@18.2.0)
usehooks-ts:
specifier: ^2.9.1
version: 2.9.1(react-dom@18.2.0)(react@18.2.0)
devDependencies:
'@refocus/config':
specifier: workspace:^
@@ -10524,6 +10530,17 @@ packages:
tslib: 2.5.3
dev: false
/usehooks-ts@2.9.1(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2FAuSIGHlY+apM9FVlj8/oNhd+1y+Uwv5QNkMQz1oSfdHk4PXo1qoCw9I5M7j0vpH8CSWFJwXbVPeYDjLCx9PA==}
engines: {node: '>=16.15.0', npm: '>=8'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true