mirror of
https://github.com/morten-olsen/refocus.dev.git
synced 2026-02-08 00:46:25 +01:00
feat: masonry grid
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './dialog';
|
||||
export * from './list';
|
||||
export * from './dropdown';
|
||||
export * from './button';
|
||||
export * from './masonry';
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
115
packages/ui/src/base/masonry/index.tsx
Normal file
115
packages/ui/src/base/masonry/index.tsx
Normal 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 };
|
||||
29
packages/ui/src/base/masonry/item.tsx
Normal file
29
packages/ui/src/base/masonry/item.tsx
Normal 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 };
|
||||
@@ -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,7 +40,8 @@ const Board: React.FC<BoardProps> = ({ board, id }) => {
|
||||
</AddWidgetFromUrl.Trigger>
|
||||
</AddWidgetFromUrl>
|
||||
</View>
|
||||
<Wrapper $fr>
|
||||
<View $p="md">
|
||||
<Masonry>
|
||||
{Object.entries(board.widgets).map(([widgetId, widget]) => (
|
||||
<ItemWrapper key={widgetId}>
|
||||
<Widget
|
||||
@@ -54,7 +53,8 @@ const Board: React.FC<BoardProps> = ({ board, id }) => {
|
||||
/>
|
||||
</ItemWrapper>
|
||||
))}
|
||||
</Wrapper>
|
||||
</Masonry>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user