Skip to content

Commit

Permalink
fix: [useCollapse] cleanup resize observer, overflow css
Browse files Browse the repository at this point in the history
  • Loading branch information
renrizzolo committed Mar 9, 2023
1 parent 4709984 commit 8edfac7
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 51 deletions.
11 changes: 8 additions & 3 deletions src/components/Panel/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,24 @@ const Panel = ({ onClick, className, children, dts, icon, id, isCollapsed, title
onClick(id);
};

const { collapsed, height, containerRef } = useCollapse({
const { collapsed, height, containerRef, transitionState } = useCollapse({
collapsed: isCollapsed,
});

const classesCombined = classnames(['panel-component', { collapsed }, className]);
const classesCombined = classnames(['panel-component', { collapsed, [transitionState]: transitionState }, className]);

return (
<div data-testid="panel-wrapper" className={classesCombined} data-test-selector={dts}>
<div data-testid="panel-header" className="panel-component-header clearfix" onClick={onHeaderClick}>
{icon}
{title}
</div>
<div style={{ height }} className={classnames('panel-component-content-wrapper', { animate })}>
<div
style={{ height }}
className={classnames('panel-component-content-wrapper', {
animate,
})}
>
<div ref={containerRef} data-testid="panel-content" className="panel-component-content">
{children}
</div>
Expand Down
21 changes: 13 additions & 8 deletions src/components/Panel/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
.panel-component {
background-color: $color-white;
transition: background-color 250ms 75ms ease-out;

&.is-expanding,
&.is-collapsing {
overflow: hidden;
}
}

.panel-component ~ .panel-component {
Expand Down Expand Up @@ -38,8 +43,6 @@
}

.panel-component-content-wrapper {
overflow: hidden;

&.animate {
transition: all 250ms ease;
}
Expand All @@ -54,16 +57,18 @@
background-color: $color-grey-100;
}

.panel-component.collapsed .panel-component-header {
border-bottom: 0;
}

.panel-component.collapsed .panel-component-header::before {
transform: rotate(0);
}

.panel-component.collapsed .panel-component-content {
display: none;
.panel-component.collapsed:not(.is-expanding, .is-collapsing) {
.panel-component-header {
border-bottom: 0;
}

.panel-component-content {
display: none;
}
}

.panel-component hr {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Paragraph/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface ParagraphProps {
* A fallback maximum height for the brief content.
* This height won't be exceeded, even if props.briefCharCount isn't reached
* (e.g due to new lines in HTML)
* @default 100
* @default 200
*/
briefMaxHeight?: number;
/**
Expand Down
2 changes: 1 addition & 1 deletion src/components/Paragraph/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Paragraph.propTypes = {
* A fallback maximum height for the brief content.
* This height won't be exceeded, even if props.briefCharCount isn't reached
* (e.g due to new lines in HTML)
* @default 100
* @default 200
*/
briefMaxHeight: PropTypes.number,
/**
Expand Down
9 changes: 9 additions & 0 deletions src/components/Paragraph/index.spec.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { render, cleanup, fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import Paragraph from '.';

beforeEach(() => {
jest.useFakeTimers();
});
afterEach(cleanup);

describe('<Paragraph />', () => {
Expand Down Expand Up @@ -70,6 +73,7 @@ describe('<Paragraph />', () => {
},
},
]);
jest.runAllTimers();
});

expect(queryByTestId('paragraph-wrapper')).toBeInTheDocument();
Expand Down Expand Up @@ -116,6 +120,7 @@ describe('<Paragraph />', () => {
},
},
]);
jest.runAllTimers();
});

expect(queryByTestId('paragraph-wrapper')).toBeInTheDocument();
Expand All @@ -142,6 +147,7 @@ describe('<Paragraph />', () => {
},
},
]);
jest.runAllTimers();
});

expect(queryByTestId('paragraph-wrapper')).toBeInTheDocument();
Expand All @@ -161,6 +167,7 @@ describe('<Paragraph />', () => {
},
},
]);
jest.runAllTimers();
});

expect(queryByTestId('paragraph-wrapper')).toBeInTheDocument();
Expand All @@ -175,6 +182,7 @@ describe('<Paragraph />', () => {
},
},
]);
jest.runAllTimers();
});
expect(getByTestId('expandable-content')).toHaveStyle(`height: 200px`);

Expand All @@ -186,6 +194,7 @@ describe('<Paragraph />', () => {
},
},
]);
jest.runAllTimers();
});

fireEvent.click(getByTestId('paragraph-read-more-button'));
Expand Down
93 changes: 65 additions & 28 deletions src/hooks/useCollapse.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,57 +6,94 @@ import React from 'react';
* @param {number} useCollapse.collapsedHeight height to collapse to (default 0)
* @param {number} useCollapse.collapsed optionally control collapsed state
*/
export const useCollapse = ({ collapsedHeight = 0, collapsed: collapsedProp = false }) => {
export const useCollapse = ({ collapsedHeight = 0, collapsed: collapsedProp = false, transitionMs = 250 }) => {
const containerRef = React.useRef();
const elHeightRef = React.useRef();
const collapsedHeightRef = React.useRef();
const animationFrameRef = React.useRef();
const transitionTimerRef = React.useRef();

const [height, _setHeight] = React.useState();
const [collapsed, setCollapsed] = React.useState(collapsedProp);
const [elHeight, setElHeight] = React.useState();

const setHeight = React.useCallback(
(elHeight) => {
if (!containerRef.current) return;
collapsedHeightRef.current = _.isNumber(collapsedHeight) ? Math.min(collapsedHeight, elHeight) : elHeight;
if (!collapsed) {
_setHeight(elHeight);
} else {
_setHeight(collapsedHeightRef.current);
const [animationState, setAnimationState] = React.useState(0);

const transitionType = collapsed ? 'is-collapsing' : 'is-expanding';
const transitionState = animationState ? transitionType : '';
const collapsedRef = React.useRef(collapsed);

React.useEffect(() => {
if (collapsed !== collapsedRef.current) {
setAnimationState(1);
transitionTimerRef.current = setTimeout(() => {
setAnimationState(0);
}, transitionMs);
}
collapsedRef.current = collapsed;
}, [collapsed, transitionMs]);

React.useEffect(() => {
return () => {
if (transitionTimerRef.current) {
window.clearTimeout(transitionTimerRef.current);
}
},
[collapsedHeight, collapsed]
);
};
}, []);

React.useLayoutEffect(() => {
const setHeight = React.useCallback(() => {
collapsedHeightRef.current = _.isNumber(collapsedHeight) ? Math.min(collapsedHeight, elHeight) : elHeight;
if (!collapsed) {
_setHeight(elHeight);
} else {
_setHeight(collapsedHeightRef.current);
}
}, [collapsedHeight, elHeight, collapsed]);

React.useEffect(() => {
setCollapsed(collapsedProp);
}, [collapsedProp]);

// set the height on mount, as resize observer's animation frame hasn't fired yet
React.useLayoutEffect(() => {
if (containerRef.current) {
setElHeight(containerRef.current.getBoundingClientRect().height);
}
}, []);

const resizeObserverRef = React.useRef();

React.useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// borderBoxSize is not an array in old versions of Firefox
elHeightRef.current = _.castArray(entry.borderBoxSize)[0]?.blockSize ?? entry.contentRect.height;
if (!resizeObserverRef.current) {
resizeObserverRef.current = new ResizeObserver((entries) => {
// see https://github.com/WICG/resize-observer/issues/38
animationFrameRef.current = window.requestAnimationFrame(() => {
for (const entry of entries) {
// borderBoxSize is not an array in old versions of Firefox
setElHeight(_.castArray(entry.borderBoxSize)[0]?.blockSize ?? entry.contentRect.height);
}
});
});
}

resizeObserverRef.current.observe(containerRef.current);

setHeight(elHeightRef.current);
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
animationFrameRef.current && window.cancelAnimationFrame(animationFrameRef.current);
resizeObserverRef.current && resizeObserverRef.current.disconnect();
};
}, [setHeight]);
}, []);

const collapsedHeightExceeded = _.isNil(collapsedHeight) || collapsedHeightRef.current === collapsedHeight;

React.useLayoutEffect(() => {
if (!containerRef.current || !elHeightRef.current) return;
setHeight(elHeightRef.current);
}, [setHeight]);
if (!containerRef.current || !elHeight) return;
setHeight(elHeight);
}, [setHeight, elHeight]);

const toggleCollapsed = () => {
setCollapsed(!collapsed);
};

return { collapsed, toggleCollapsed, height, collapsedHeightExceeded, containerRef };
return { collapsed, toggleCollapsed, height, collapsedHeightExceeded, containerRef, transitionState };
};
25 changes: 22 additions & 3 deletions src/hooks/useCollapse.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,29 @@ import React from 'react';
import { render, cleanup, fireEvent, act } from '@testing-library/react';
import { useCollapse } from './useCollapse';

beforeEach(() => {
jest.useFakeTimers();
});

afterEach(cleanup);

describe('useCollapse()', () => {
const Component = ({ collapsedHeight, collapsed: collapsedProp, noRef, children }) => {
const { collapsed, toggleCollapsed, height, containerRef } = useCollapse({
const { collapsed, toggleCollapsed, height, transitionState, containerRef } = useCollapse({
collapsedHeight,
collapsed: collapsedProp,
});

return (
<div data-testid="wrapper" style={{ height }}>
<div data-testid="wrapper" className={transitionState} style={{ height }}>
<span ref={noRef ? null : containerRef}>{children}</span>
<button onClick={toggleCollapsed}>{collapsed ? 'expand' : 'collapse'}</button>
</div>
);
};

let resizeListener;
let observeMockFn;

beforeEach(() => {
global.ResizeObserver = class ResizeObserver {
Expand All @@ -28,11 +33,12 @@ describe('useCollapse()', () => {
disconnect = jest.fn();
constructor(ls) {
resizeListener = ls;
observeMockFn = this.observe;
}
};
});

it('should set height', () => {
it('should set height and transition state', () => {
const { getByTestId, getByRole } = render(
<Component>
<div style={{ height: 1000 }} />
Expand All @@ -47,13 +53,23 @@ describe('useCollapse()', () => {
},
},
]);
jest.runAllTimers();
});
expect(getByTestId('wrapper')).toHaveStyle({ height: '1000px' });

expect(getByRole('button')).toHaveAccessibleName('collapse');
fireEvent.click(getByRole('button'));
expect(getByTestId('wrapper')).toHaveClass('collapsing');

act(() => {
jest.runAllTimers();
});

expect(getByTestId('wrapper')).not.toHaveClass('collapsing');

expect(getByRole('button')).toHaveAccessibleName('expand');
expect(getByTestId('wrapper')).toHaveStyle({ height: 0 });
expect(observeMockFn).toBeCalledTimes(1);
});

it('should be controllable', () => {
Expand All @@ -71,6 +87,7 @@ describe('useCollapse()', () => {
},
},
]);
jest.runAllTimers();
});

expect(getByTestId('wrapper')).toHaveStyle({ height: '25px' });
Expand All @@ -92,6 +109,7 @@ describe('useCollapse()', () => {
},
},
]);
jest.runAllTimers();
});

expect(getByTestId('wrapper')).toHaveStyle({ height: undefined });
Expand All @@ -114,6 +132,7 @@ describe('useCollapse()', () => {
},
},
]);
jest.runAllTimers();
});

expect(getByTestId('wrapper')).toHaveStyle({ height: '1000px' });
Expand Down
Loading

0 comments on commit 8edfac7

Please sign in to comment.