}
const noop = () => undefined;
+const SCROLL_INTERVAL_DELAY = 10;
const SCROLL_DELAY = 100;
+const EXPAND_ANIMATION_SPEED = 200;
+
const TOP_OFFSET = 100; // 5 lines above
const BOTTOM_OFFSET = 28; // 1 line below + margin
};
}
+function scrollToFollowAnimation(
+ scrollableRef: React.RefObject<HTMLDivElement>,
+ targetHeight: number
+) {
+ const scrollable = scrollableRef.current;
+ if (scrollable) {
+ const handler = setInterval(() => {
+ scrollable.scrollTo({ top: targetHeight, behavior: 'smooth' });
+ }, SCROLL_INTERVAL_DELAY);
+ setTimeout(() => {
+ clearInterval(handler);
+ }, EXPAND_ANIMATION_SPEED);
+ }
+}
+
+/* Exported for testing */
+export async function animateExpansion(
+ scrollableRef: React.RefObject<HTMLDivElement>,
+ expandBlock: (direction: ExpandDirection) => Promise<void>,
+ direction: ExpandDirection
+) {
+ const wrapper = scrollableRef.current?.querySelector<HTMLElement>('.snippet');
+ const table = wrapper?.firstChild as HTMLElement;
+
+ if (!wrapper || !table) {
+ return;
+ }
+
+ // lock the wrapper's height before adding the additional rows
+ const startHeight = table.getBoundingClientRect().height;
+ wrapper.style.maxHeight = `${startHeight}px`;
+
+ await expandBlock(direction);
+
+ const targetHeight = table.getBoundingClientRect().height;
+
+ if (direction === 'up') {
+ /*
+ * Add a negative margin to keep the original alignment
+ * Remove the transition to do so instantaneously
+ */
+ table.style.transition = 'none';
+ table.style.marginTop = `${startHeight - targetHeight}px`;
+
+ setTimeout(() => {
+ /*
+ * Reset the transition to the default
+ * transition the margin back to 0 at the same time as the maxheight
+ */
+ table.style.transition = '';
+ table.style.marginTop = '0px';
+ wrapper.style.maxHeight = `${targetHeight}px`;
+ }, 0);
+ } else {
+ // False positive:
+ // eslint-disable-next-line require-atomic-updates
+ wrapper.style.maxHeight = `${targetHeight}px`;
+ scrollToFollowAnimation(scrollableRef, targetHeight);
+ }
+
+ // after the animation is done, clear the applied styles
+ setTimeout(() => {
+ table.style.marginTop = '';
+ wrapper.style.maxHeight = '';
+ }, EXPAND_ANIMATION_SPEED);
+}
+
export default function HotspotSnippetContainerRenderer(
props: HotspotSnippetContainerRendererProps
) {
component={sourceViewerFile}
displayLineNumberOptions={false}
displaySCM={false}
- expandBlock={(_i, direction) => props.onExpandBlock(direction)}
+ expandBlock={(_i, direction) =>
+ animateExpansion(scrollableRef, props.onExpandBlock, direction)
+ }
handleCloseIssues={noop}
handleOpenIssues={noop}
handleSymbolClick={props.onSymbolClick}
renderAdditionalChildInLine={renderHotspotBoxInLine}
renderDuplicationPopup={noop}
snippet={sourceLines}
- scroll={getScrollHandler(scrollableRef)}
/>
)}
</DeferredSpinner>
import { mockSourceLine, mockSourceViewerFile } from '../../../../helpers/testMocks';
import SnippetViewer from '../../../issues/crossComponentSourceViewer/SnippetViewer';
import HotspotSnippetContainerRenderer, {
+ animateExpansion,
getScrollHandler,
HotspotSnippetContainerRendererProps
} from '../HotspotSnippetContainerRenderer';
});
});
+describe('expand', () => {
+ it('should work as expected', async () => {
+ jest.useFakeTimers();
+ const onExpandBlock = jest.fn().mockResolvedValue({});
+
+ const scrollableNode = document.createElement('div');
+ scrollableNode.scrollTo = jest.fn();
+ const ref: RefObject<HTMLDivElement> = {
+ current: scrollableNode
+ };
+
+ jest.spyOn(React, 'useRef').mockReturnValue(ref);
+
+ const snippet = document.createElement('div');
+ const table = document.createElement('table');
+ snippet.appendChild(table);
+ scrollableNode.querySelector = jest.fn().mockReturnValue(snippet);
+
+ jest
+ .spyOn(table, 'getBoundingClientRect')
+ .mockReturnValueOnce({ height: 42 } as DOMRect)
+ .mockReturnValueOnce({ height: 99 } as DOMRect)
+ .mockReturnValueOnce({ height: 99 } as DOMRect)
+ .mockReturnValueOnce({ height: 112 } as DOMRect);
+
+ await animateExpansion(ref, onExpandBlock, 'up');
+ expect(onExpandBlock).toBeCalledWith('up');
+
+ expect(snippet.style.maxHeight).toBe('42px');
+ expect(table.style.marginTop).toBe('-57px');
+
+ jest.advanceTimersByTime(100);
+
+ expect(snippet.style.maxHeight).toBe('99px');
+ expect(table.style.marginTop).toBe('0px');
+
+ expect(scrollableNode.scrollTo).not.toBeCalled();
+
+ jest.runAllTimers();
+
+ await animateExpansion(ref, onExpandBlock, 'down');
+ expect(onExpandBlock).toBeCalledWith('down');
+ expect(snippet.style.maxHeight).toBe('112px');
+
+ jest.advanceTimersByTime(250);
+ expect(scrollableNode.scrollTo).toBeCalled();
+
+ jest.useRealTimers();
+ });
+});
+
function shallowRender(props?: Partial<HotspotSnippetContainerRendererProps>) {
return shallow(
<HotspotSnippetContainerRenderer