Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[lab][Masonry] Ability to sort elements from left to right #39904

Merged
merged 7 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/data/material/components/masonry/Sequential.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import Paper from '@mui/material/Paper';
import Masonry from '@mui/lab/Masonry';

const heights = [150, 30, 90, 70, 110, 150, 130, 80, 50, 90, 100, 150, 30, 50, 80];

const Item = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff',
...theme.typography.body2,
padding: theme.spacing(0.5),
textAlign: 'center',
color: theme.palette.text.secondary,
}));

export default function Sequential() {
return (
<Box sx={{ width: 500, minHeight: 393 }}>
<Masonry
columns={4}
spacing={2}
defaultHeight={450}
defaultColumns={4}
defaultSpacing={1}
sequential
>
{heights.map((height, index) => (
<Item key={index} sx={{ height }}>
{index + 1}
</Item>
))}
</Masonry>
</Box>
);
}
36 changes: 36 additions & 0 deletions docs/data/material/components/masonry/Sequential.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import { styled } from '@mui/material/styles';
import Paper from '@mui/material/Paper';
import Masonry from '@mui/lab/Masonry';

const heights = [150, 30, 90, 70, 110, 150, 130, 80, 50, 90, 100, 150, 30, 50, 80];

const Item = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.mode === 'dark' ? '#1A2027' : '#fff',
...theme.typography.body2,
padding: theme.spacing(0.5),
textAlign: 'center',
color: theme.palette.text.secondary,
}));

export default function Sequential() {
return (
<Box sx={{ width: 500, minHeight: 393 }}>
<Masonry
columns={4}
spacing={2}
defaultHeight={450}
defaultColumns={4}
defaultSpacing={1}
sequential
>
{heights.map((height, index) => (
<Item key={index} sx={{ height }}>
{index + 1}
</Item>
))}
</Masonry>
</Box>
);
}
14 changes: 14 additions & 0 deletions docs/data/material/components/masonry/Sequential.tsx.preview
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Masonry
columns={4}
spacing={2}
defaultHeight={450}
defaultColumns={4}
defaultSpacing={1}
sequential
>
{heights.map((height, index) => (
<Item key={index} sx={{ height }}>
{index + 1}
</Item>
))}
</Masonry>
7 changes: 7 additions & 0 deletions docs/data/material/components/masonry/masonry.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ It is important to note that the value provided to the `spacing` prop is multipl

{{"demo": "ResponsiveSpacing.js", "bg": true}}

## Sequential

This example demonstrates the use of the `sequential` to configure the sequential order.
With `sequential` enabled, items are added in order from left to right rather than adding to the shortest column.

{{"demo": "Sequential.js", "bg": true}}

## Server-side rendering

This example demonstrates the use of the `defaultHeight`, `defaultColumns` and `defaultSpacing`, which are used to
Expand Down
1 change: 1 addition & 0 deletions docs/pages/material-ui/api/masonry.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"defaultColumns": { "type": { "name": "number" } },
"defaultHeight": { "type": { "name": "number" } },
"defaultSpacing": { "type": { "name": "number" } },
"sequential": { "type": { "name": "bool" }, "default": "false" },
"spacing": {
"type": {
"name": "union",
Expand Down
3 changes: 3 additions & 0 deletions docs/translations/api-docs/masonry/masonry.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
"defaultSpacing": {
"description": "The default spacing of the component. Like <code>spacing</code>, it is a factor of the theme&#39;s spacing. This is provided for server-side rendering."
},
"sequential": {
"description": "Allows using sequential order rather than adding to shortest column"
},
"spacing": {
"description": "Defines the space between children. It is a factor of the theme&#39;s spacing."
},
Expand Down
5 changes: 5 additions & 0 deletions packages/mui-lab/src/Masonry/Masonry.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export interface MasonryOwnProps {
* @default 1
*/
spacing?: ResponsiveStyleValue<number | string>;
/**
* Allows using sequential order rather than adding to shortest column
* @default false
*/
sequential?: boolean;
/**
* Allows defining system overrides as well as additional CSS styles.
*/
Expand Down
132 changes: 74 additions & 58 deletions packages/mui-lab/src/Masonry/Masonry.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ const Masonry = React.forwardRef(function Masonry(inProps, ref) {
component = 'div',
columns = 4,
spacing = 1,
sequential = false,
defaultColumns,
defaultHeight,
defaultSpacing,
Expand Down Expand Up @@ -211,73 +212,83 @@ const Masonry = React.forwardRef(function Masonry(inProps, ref) {

const classes = useUtilityClasses(ownerState);

const handleResize = (masonryChildren) => {
if (!masonryRef.current || !masonryChildren || masonryChildren.length === 0) {
return;
}
useEnhancedEffect(() => {
const handleResize = (masonryChildren) => {
Copy link
Member

@DiegoAndai DiegoAndai Jan 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to move handleResize inside the effect? I would rather keep it out, if possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to move it inside because of a eslint rule. I'll see if I can get it working without moving it in and not having to wrap handleResize in a useCallback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be good now, let me know what you think.

if (!masonryRef.current || !masonryChildren || masonryChildren.length === 0) {
return;
}

const masonry = masonryRef.current;
const masonryFirstChild = masonryRef.current.firstChild;
const parentWidth = masonry.clientWidth;
const firstChildWidth = masonryFirstChild.clientWidth;
const masonry = masonryRef.current;
const masonryFirstChild = masonryRef.current.firstChild;
const parentWidth = masonry.clientWidth;
const firstChildWidth = masonryFirstChild.clientWidth;

if (parentWidth === 0 || firstChildWidth === 0) {
return;
}
if (parentWidth === 0 || firstChildWidth === 0) {
return;
}

const firstChildComputedStyle = window.getComputedStyle(masonryFirstChild);
const firstChildMarginLeft = parseToNumber(firstChildComputedStyle.marginLeft);
const firstChildMarginRight = parseToNumber(firstChildComputedStyle.marginRight);
const firstChildComputedStyle = window.getComputedStyle(masonryFirstChild);
const firstChildMarginLeft = parseToNumber(firstChildComputedStyle.marginLeft);
const firstChildMarginRight = parseToNumber(firstChildComputedStyle.marginRight);

const currentNumberOfColumns = Math.round(
parentWidth / (firstChildWidth + firstChildMarginLeft + firstChildMarginRight),
);
const currentNumberOfColumns = Math.round(
parentWidth / (firstChildWidth + firstChildMarginLeft + firstChildMarginRight),
);

const columnHeights = new Array(currentNumberOfColumns).fill(0);
let skip = false;
masonry.childNodes.forEach((child) => {
if (child.nodeType !== Node.ELEMENT_NODE || child.dataset.class === 'line-break' || skip) {
return;
}
const childComputedStyle = window.getComputedStyle(child);
const childMarginTop = parseToNumber(childComputedStyle.marginTop);
const childMarginBottom = parseToNumber(childComputedStyle.marginBottom);
// if any one of children isn't rendered yet, masonry's height shouldn't be computed yet
const childHeight = parseToNumber(childComputedStyle.height)
? Math.ceil(parseToNumber(childComputedStyle.height)) + childMarginTop + childMarginBottom
: 0;
if (childHeight === 0) {
skip = true;
return;
}
// if there is a nested image that isn't rendered yet, masonry's height shouldn't be computed yet
for (let i = 0; i < child.childNodes.length; i += 1) {
const nestedChild = child.childNodes[i];
if (nestedChild.tagName === 'IMG' && nestedChild.clientHeight === 0) {
const columnHeights = new Array(currentNumberOfColumns).fill(0);
let skip = false;
let nextOrder = 1;
masonry.childNodes.forEach((child) => {
if (child.nodeType !== Node.ELEMENT_NODE || child.dataset.class === 'line-break' || skip) {
return;
}
const childComputedStyle = window.getComputedStyle(child);
const childMarginTop = parseToNumber(childComputedStyle.marginTop);
const childMarginBottom = parseToNumber(childComputedStyle.marginBottom);
// if any one of children isn't rendered yet, masonry's height shouldn't be computed yet
const childHeight = parseToNumber(childComputedStyle.height)
? Math.ceil(parseToNumber(childComputedStyle.height)) + childMarginTop + childMarginBottom
: 0;
if (childHeight === 0) {
skip = true;
break;
return;
}
}
// if there is a nested image that isn't rendered yet, masonry's height shouldn't be computed yet
for (let i = 0; i < child.childNodes.length; i += 1) {
const nestedChild = child.childNodes[i];
if (nestedChild.tagName === 'IMG' && nestedChild.clientHeight === 0) {
skip = true;
break;
}
}
if (!skip) {
if (sequential) {
columnHeights[nextOrder - 1] += childHeight;
child.style.order = nextOrder;
nextOrder += 1;
if (nextOrder > currentNumberOfColumns) {
nextOrder = 1;
}
} else {
// find the current shortest column (where the current item will be placed)
const currentMinColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
columnHeights[currentMinColumnIndex] += childHeight;
const order = currentMinColumnIndex + 1;
child.style.order = order;
}
}
});
if (!skip) {
// find the current shortest column (where the current item will be placed)
const currentMinColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
columnHeights[currentMinColumnIndex] += childHeight;
const order = currentMinColumnIndex + 1;
child.style.order = order;
// In React 18, state updates in a ResizeObserver's callback are happening after the paint which causes flickering
// when doing some visual updates in it. Using flushSync ensures that the dom will be painted after the states updates happen
// Related issue - https://github.com/facebook/react/issues/24331
ReactDOM.flushSync(() => {
setMaxColumnHeight(Math.max(...columnHeights));
setNumberOfLineBreaks(currentNumberOfColumns > 0 ? currentNumberOfColumns - 1 : 0);
});
}
});
if (!skip) {
// In React 18, state updates in a ResizeObserver's callback are happening after the paint which causes flickering
// when doing some visual updates in it. Using flushSync ensures that the dom will be painted after the states updates happen
// Related issue - https://github.com/facebook/react/issues/24331
ReactDOM.flushSync(() => {
setMaxColumnHeight(Math.max(...columnHeights));
setNumberOfLineBreaks(currentNumberOfColumns > 0 ? currentNumberOfColumns - 1 : 0);
});
}
};
};

useEnhancedEffect(() => {
// IE and old browsers are not supported
if (typeof ResizeObserver === 'undefined') {
return undefined;
Expand All @@ -304,7 +315,7 @@ const Masonry = React.forwardRef(function Masonry(inProps, ref) {
resizeObserver.disconnect();
}
};
}, [columns, spacing, children]);
}, [columns, spacing, children, sequential]);

const handleRef = useForkRef(ref, masonryRef);

Expand Down Expand Up @@ -374,6 +385,11 @@ Masonry.propTypes /* remove-proptypes */ = {
* The default spacing of the component. Like `spacing`, it is a factor of the theme's spacing. This is provided for server-side rendering.
*/
defaultSpacing: PropTypes.number,
/**
* Allows using sequential order rather than adding to shortest column
* @default false
*/
sequential: PropTypes.bool,
/**
* Defines the space between children. It is a factor of the theme's spacing.
* @default 1
Expand Down
31 changes: 31 additions & 0 deletions packages/mui-lab/src/Masonry/Masonry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -369,4 +369,35 @@ describe('<Masonry />', () => {
});
});
});

describe('prop: sequential', () => {
const pause = (timeout) =>
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, timeout);
});

it('should place children in sequential order', async function test() {
if (/jsdom/.test(window.navigator.userAgent)) {
// only run on browser
this.skip();
}

const { getByTestId } = render(
<Masonry columns={2} spacing={1} sequential>
<div style={{ height: `20px` }} data-testid="child1" />
<div style={{ height: `10px` }} data-testid="child2" />
<div style={{ height: `10px` }} data-testid="child3" />
</Masonry>,
);
await pause(400); // Masonry elements aren't ordered immediately, and so we need the pause to wait for them to be ordered
const child1 = getByTestId('child1');
const child2 = getByTestId('child2');
const child3 = getByTestId('child3');
expect(window.getComputedStyle(child1).order).to.equal(`1`);
expect(window.getComputedStyle(child2).order).to.equal(`2`);
expect(window.getComputedStyle(child3).order).to.equal(`1`);
});
});
});