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",
|
"types": "./dist/cjs/types/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@refocus/sdk": "workspace:^",
|
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@refocus/sdk": "workspace:^",
|
||||||
|
"framer-motion": "^10.12.16",
|
||||||
"react-icons": "^4.9.0",
|
"react-icons": "^4.9.0",
|
||||||
"react-markdown": "^6.0.3",
|
"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 './list';
|
||||||
export * from './dropdown';
|
export * from './dropdown';
|
||||||
export * from './button';
|
export * from './button';
|
||||||
|
export * from './masonry';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { View } from '../view';
|
import { View } from '../view';
|
||||||
|
|
||||||
type ListProps = {
|
type ListProps = {
|
||||||
@@ -7,7 +8,15 @@ type ListProps = {
|
|||||||
const List = ({ children }: ListProps) => {
|
const List = ({ children }: ListProps) => {
|
||||||
return (
|
return (
|
||||||
<View $fc $gap="sm" $p="sm">
|
<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}
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</View>
|
</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,
|
useUpdateWidget,
|
||||||
} from '@refocus/sdk';
|
} from '@refocus/sdk';
|
||||||
import { IoAddCircleOutline } from 'react-icons/io5';
|
import { IoAddCircleOutline } from 'react-icons/io5';
|
||||||
import { View } from '../../base';
|
import { Masonry, View } from '../../base';
|
||||||
import { Widget } from '../widget';
|
import { Widget } from '../widget';
|
||||||
import { AddWidgetFromUrl } from '../add-from-url';
|
import { AddWidgetFromUrl } from '../add-from-url';
|
||||||
import { styled } from 'styled-components';
|
import { styled } from 'styled-components';
|
||||||
@@ -15,14 +15,12 @@ type BoardProps = {
|
|||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Wrapper = styled(View)`
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ItemWrapper = styled(View)`
|
const ItemWrapper = styled(View)`
|
||||||
max-width: 400px;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 500px;
|
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 }) => {
|
const Board: React.FC<BoardProps> = ({ board, id }) => {
|
||||||
@@ -42,7 +40,8 @@ const Board: React.FC<BoardProps> = ({ board, id }) => {
|
|||||||
</AddWidgetFromUrl.Trigger>
|
</AddWidgetFromUrl.Trigger>
|
||||||
</AddWidgetFromUrl>
|
</AddWidgetFromUrl>
|
||||||
</View>
|
</View>
|
||||||
<Wrapper $fr>
|
<View $p="md">
|
||||||
|
<Masonry>
|
||||||
{Object.entries(board.widgets).map(([widgetId, widget]) => (
|
{Object.entries(board.widgets).map(([widgetId, widget]) => (
|
||||||
<ItemWrapper key={widgetId}>
|
<ItemWrapper key={widgetId}>
|
||||||
<Widget
|
<Widget
|
||||||
@@ -54,7 +53,8 @@ const Board: React.FC<BoardProps> = ({ board, id }) => {
|
|||||||
/>
|
/>
|
||||||
</ItemWrapper>
|
</ItemWrapper>
|
||||||
))}
|
))}
|
||||||
</Wrapper>
|
</Masonry>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ const Wrapper = styled(View)`
|
|||||||
background: ${({ theme }) => theme.colors.bg.base};
|
background: ${({ theme }) => theme.colors.bg.base};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const WidgetWrapper = styled(View)`
|
||||||
|
flex-grow: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
const Widget: React.FC<WidgetProps> = ({
|
const Widget: React.FC<WidgetProps> = ({
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
||||||
@@ -47,9 +53,9 @@ const Widget: React.FC<WidgetProps> = ({
|
|||||||
return (
|
return (
|
||||||
<WidgetProvider id={id} data={data} setData={setData}>
|
<WidgetProvider id={id} data={data} setData={setData}>
|
||||||
<Wrapper className={className} $fr>
|
<Wrapper className={className} $fr>
|
||||||
<View $f={1}>
|
<WidgetWrapper $f={1}>
|
||||||
<WidgetView />
|
<WidgetView />
|
||||||
</View>
|
</WidgetWrapper>
|
||||||
<View $fc>
|
<View $fc>
|
||||||
{hasMenu && (
|
{hasMenu && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const styles = {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
break-word: break-all;
|
||||||
`,
|
`,
|
||||||
} satisfies Record<string, ReturnType<typeof css>>;
|
} satisfies Record<string, ReturnType<typeof css>>;
|
||||||
|
|
||||||
|
|||||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -147,9 +147,6 @@ importers:
|
|||||||
|
|
||||||
packages/ui:
|
packages/ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@refocus/sdk':
|
|
||||||
specifier: workspace:^
|
|
||||||
version: link:../sdk
|
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.0.4
|
specifier: ^1.0.4
|
||||||
version: 1.0.4(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0)
|
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':
|
'@radix-ui/react-tabs':
|
||||||
specifier: ^1.0.4
|
specifier: ^1.0.4
|
||||||
version: 1.0.4(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0)
|
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:
|
react-icons:
|
||||||
specifier: ^4.9.0
|
specifier: ^4.9.0
|
||||||
version: 4.9.0(react@18.2.0)
|
version: 4.9.0(react@18.2.0)
|
||||||
@@ -168,6 +171,9 @@ importers:
|
|||||||
styled-components:
|
styled-components:
|
||||||
specifier: 6.0.0-rc.3
|
specifier: 6.0.0-rc.3
|
||||||
version: 6.0.0-rc.3(react-dom@18.2.0)(react@18.2.0)
|
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:
|
devDependencies:
|
||||||
'@refocus/config':
|
'@refocus/config':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
@@ -10524,6 +10530,17 @@ packages:
|
|||||||
tslib: 2.5.3
|
tslib: 2.5.3
|
||||||
dev: false
|
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:
|
/util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
Reference in New Issue
Block a user