import { ComponentContextShape } from '../../../types/component';
import { MetricKey } from '../../../types/metrics';
import SecurityHotspotsApp from '../SecurityHotspotsApp';
-import useScrollDownCompress from '../hooks/useScrollDownCompress';
+import useStickyDetection from '../hooks/useStickyDetection';
jest.mock('../../../api/measures');
jest.mock('../../../api/security-hotspots');
jest.mock('../../../api/rules');
jest.mock('../../../api/quality-profiles');
jest.mock('../../../api/issues');
-jest.mock('../hooks/useScrollDownCompress');
+jest.mock('../hooks/useStickyDetection');
jest.mock('../../../helpers/sonarlint', () => ({
openHotspot: jest.fn().mockResolvedValue(null),
probeSonarLintServers: jest.fn().mockResolvedValue([
});
beforeEach(() => {
- jest.mocked(useScrollDownCompress).mockImplementation(() => ({
- isScrolled: false,
- isCompressed: false,
- resetScrollDownCompress: jest.fn(),
- }));
+ jest.mocked(useStickyDetection).mockImplementation(() => false);
});
afterEach(() => {
});
it('should render hotspot header in sticky mode', async () => {
- jest.mocked(useScrollDownCompress).mockImplementation(() => ({
- isScrolled: true,
- isCompressed: true,
- resetScrollDownCompress: jest.fn(),
- }));
+ jest.mocked(useStickyDetection).mockImplementation(() => true);
renderSecurityHotspotsApp();
- expect(await ui.reviewButton.find()).toBeInTheDocument();
- expect(ui.activeAssignee.query()).not.toBeInTheDocument();
+ expect(await ui.reviewButton.findAll()).toHaveLength(2);
});
});
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { withTheme } from '@emotion/react';
-import styled from '@emotion/styled';
import {
ClipboardIconButton,
IssueMessageHighlighting,
- LAYOUT_GLOBAL_NAV_HEIGHT,
- LAYOUT_PROJECT_NAV_HEIGHT,
LightLabel,
LightPrimary,
Link,
LinkIcon,
StyledPageTitle,
- Theme,
- themeColor,
- themeShadow,
} from 'design-system';
import React from 'react';
import { getBranchLikeQuery } from '../../../helpers/branch-like';
import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
import { Component } from '../../../types/types';
import HotspotHeaderRightSection from './HotspotHeaderRightSection';
-import HotspotSnippetHeader from './HotspotSnippetHeader';
import Status from './status/Status';
-import StatusReviewButton from './status/StatusReviewButton';
export interface HotspotHeaderProps {
hotspot: Hotspot;
component: Component;
branchLike?: BranchLike;
- isCodeTab?: boolean;
standards?: Standards;
onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
- tabs: React.ReactNode;
- isScrolled: boolean;
- isCompressed: boolean;
-}
-
-interface StyledHeaderProps {
- isScrolled: boolean;
- theme: Theme;
}
export function HotspotHeader(props: HotspotHeaderProps) {
- const { branchLike, component, hotspot, isCodeTab, isCompressed, isScrolled, standards, tabs } =
- props;
+ const { branchLike, component, hotspot, standards } = props;
const { message, messageFormattings, rule, key } = hotspot;
const refrechBranchStatus = useRefreshBranchStatus();
refrechBranchStatus();
};
- const content = isCompressed ? (
- <span>
- <div className="sw-flex sw-justify-between">
- {tabs}
-
- <StatusReviewButton hotspot={hotspot} onStatusChange={handleStatusChange} />
- </div>
-
- {isCodeTab && (
- <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
- )}
- </span>
- ) : (
- <>
- <div className="sw-flex sw-justify-between sw-gap-8 sw-mb-4">
+ return (
+ <div>
+ <div className="sw-flex sw-justify-between sw-gap-8 hotspot-header">
<div className="sw-flex-1">
<StyledPageTitle as="h2" className="sw-whitespace-normal sw-overflow-visible">
<LightPrimary>
/>
</div>
</div>
- {tabs}
-
- {isCodeTab && (
- <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
- )}
- </>
- );
-
- return (
- <Header
- className="sw-sticky sw--mx-6 sw--mt-6 sw-px-6 sw-pt-6 sw-pb-4 sw-z-filterbar-header"
- isScrolled={isScrolled}
- >
- {content}
- </Header>
+ </div>
);
}
-
-const Header = withTheme(styled.div<StyledHeaderProps>`
- background-color: ${themeColor('pageBlock')};
- box-shadow: ${({ isScrolled }: StyledHeaderProps) => (isScrolled ? themeShadow('sm') : 'none')};
- top: ${LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT}px;
-`);
import { Standards } from '../../../types/security';
import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
import { Component } from '../../../types/types';
+import { HotspotHeader } from './HotspotHeader';
+
import { CurrentUser } from '../../../types/users';
import { RuleDescriptionSection } from '../../coding-rules/rule';
import HotspotReviewHistoryAndComments from './HotspotReviewHistoryAndComments';
{hotspot && (
<div className="sw-box-border sw-p-6">
+ <HotspotHeader
+ branchLike={branchLike}
+ component={component}
+ hotspot={hotspot}
+ onUpdateHotspot={props.onUpdateHotspot}
+ standards={standards}
+ />
<HotspotViewerTabs
activityTabContent={
<HotspotReviewHistoryAndComments
/>
}
branchLike={branchLike}
+ component={component}
codeTabContent={
<HotspotSnippetContainer
branchLike={branchLike}
selectedHotspotLocation={selectedHotspotLocation}
/>
}
- component={component}
hotspot={hotspot}
onUpdateHotspot={props.onUpdateHotspot}
ruleDescriptionSections={ruleDescriptionSections}
ruleLanguage={ruleLanguage}
- standards={standards}
/>
</div>
)}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { ToggleButton, getTabId, getTabPanelId } from 'design-system';
+import styled from '@emotion/styled';
+import {
+ LAYOUT_GLOBAL_NAV_HEIGHT,
+ LAYOUT_PROJECT_NAV_HEIGHT,
+ ToggleButton,
+ getTabId,
+ getTabPanelId,
+ themeColor,
+ themeShadow,
+} from 'design-system';
import { groupBy, omit } from 'lodash';
import * as React from 'react';
import RuleDescription from '../../../components/rules/RuleDescription';
import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers';
import { KeyboardKeys } from '../../../helpers/keycodes';
import { translate } from '../../../helpers/l10n';
+import { useRefreshBranchStatus } from '../../../queries/branch';
import { BranchLike } from '../../../types/branch-like';
-import { Standards } from '../../../types/security';
import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
import { Component } from '../../../types/types';
import { RuleDescriptionSection, RuleDescriptionSections } from '../../coding-rules/rule';
-import useScrollDownCompress from '../hooks/useScrollDownCompress';
-import { HotspotHeader } from './HotspotHeader';
+import useStickyDetection from '../hooks/useStickyDetection';
+import HotspotSnippetHeader from './HotspotSnippetHeader';
+import StatusReviewButton from './status/StatusReviewButton';
interface Props {
activityTabContent: React.ReactNode;
- branchLike?: BranchLike;
codeTabContent: React.ReactNode;
- component: Component;
hotspot: Hotspot;
onUpdateHotspot: (statusUpdate?: boolean, statusOption?: HotspotStatusOption) => Promise<void>;
ruleDescriptionSections?: RuleDescriptionSection[];
ruleLanguage?: string;
- standards?: Standards;
+ component: Component;
+ branchLike?: BranchLike;
}
interface Tab {
Activity = 'activity',
}
-const STICKY_HEADER_SHADOW_OFFSET = 24;
-const STICKY_HEADER_COMPRESS_THRESHOLD = 200;
+const TABS_OFFSET = LAYOUT_GLOBAL_NAV_HEIGHT + LAYOUT_PROJECT_NAV_HEIGHT;
export default function HotspotViewerTabs(props: Props) {
const {
activityTabContent,
- branchLike,
codeTabContent,
- component,
hotspot,
ruleDescriptionSections,
ruleLanguage,
- standards,
+ component,
+ branchLike,
} = props;
- const { isScrolled, isCompressed, resetScrollDownCompress } = useScrollDownCompress(
- STICKY_HEADER_COMPRESS_THRESHOLD,
- STICKY_HEADER_SHADOW_OFFSET,
- );
+ const refrechBranchStatus = useRefreshBranchStatus();
+ const isSticky = useStickyDetection('.hotspot-tabs', {
+ offset: TABS_OFFSET,
+ });
const tabs = React.useMemo(() => {
const descriptionSectionsByKey = groupBy(ruleDescriptionSections, (section) => section.key);
- const labelSuffix = isCompressed ? '.short' : '';
+ const labelSuffix = isSticky ? '.short' : '';
return [
{
]
.filter((tab) => tab.show)
.map((tab) => omit(tab, 'show'));
- }, [isCompressed, ruleDescriptionSections, hotspot.comment]);
+ }, [isSticky, ruleDescriptionSections, hotspot.comment]);
const [currentTab, setCurrentTab] = React.useState<Tab>(tabs[0]);
}
};
+ const handleStatusChange = async (statusOption: HotspotStatusOption) => {
+ await props.onUpdateHotspot(true, statusOption);
+ refrechBranchStatus();
+ };
+
React.useEffect(() => {
document.addEventListener('keydown', handleKeyboardNavigation);
if (currentTab.value !== TabKeys.Code) {
window.scrollTo({ top: 0 });
}
- resetScrollDownCompress();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTab]);
return (
<>
- <HotspotHeader
- branchLike={branchLike}
- component={component}
- hotspot={hotspot}
- isCodeTab={currentTab.value === TabKeys.Code}
- isCompressed={isCompressed}
- isScrolled={isScrolled}
- onUpdateHotspot={props.onUpdateHotspot}
- standards={standards}
- tabs={
+ <StickyTabs
+ isSticky={isSticky}
+ top={TABS_OFFSET}
+ className="sw-sticky sw-py-4 sw--mx-6 sw-px-6 sw-z-filterbar-header hotspot-tabs"
+ >
+ <div className="sw-flex sw-justify-between">
<ToggleButton
role="tablist"
value={currentTab.value}
options={tabs}
onChange={handleSelectTabs}
/>
- }
- />
+ {isSticky && <StatusReviewButton hotspot={hotspot} onStatusChange={handleStatusChange} />}
+ </div>
+ {currentTab.value === TabKeys.Code && codeTabContent && (
+ <HotspotSnippetHeader hotspot={hotspot} component={component} branchLike={branchLike} />
+ )}
+ </StickyTabs>
<div
aria-labelledby={getTabId(currentTab.value)}
className="sw-mt-2"
</>
);
}
+
+const StickyTabs = styled.div<{ top: number; isSticky: boolean }>`
+ background-color: ${themeColor('pageBlock')};
+ box-shadow: ${({ isSticky }) => (isSticky ? themeShadow('sm') : 'none')};
+ top: ${({ top }) => top}px;
+`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { fireEvent, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import React from 'react';
-import { renderComponent } from '../../../../helpers/testReactTestingUtils';
-import useScrollDownCompress from '../useScrollDownCompress';
-
-beforeEach(() => {
- Object.defineProperties(window.document.documentElement, {
- clientHeight: { value: 500, configurable: true },
- scrollHeight: { value: 1000, configurable: true },
- scrollTop: { value: 0, configurable: true },
- });
-});
-
-it('set isScrolled and isCompressed to true when scrolling down', async () => {
- renderComponent(<X />);
-
- expect(screen.getByText('isScrolled: false')).toBeVisible();
- expect(screen.getByText('isCompressed: false')).toBeVisible();
-
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 200, configurable: true },
- });
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: false')).toBeVisible();
-
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 250, configurable: true },
- });
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: true')).toBeVisible();
-
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 260, configurable: true },
- scrollHeight: { value: 800, configurable: true },
- });
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: true')).toBeVisible();
-
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 5, configurable: true },
- });
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: false')).toBeVisible();
-
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 5, configurable: true },
- scrollHeight: { value: 1000, configurable: true },
- });
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: false')).toBeVisible();
- expect(await screen.findByText('isCompressed: false')).toBeVisible();
-});
-
-it('reset the scroll state', async () => {
- const user = userEvent.setup();
- renderComponent(<X />);
-
- expect(screen.getByText('isScrolled: false')).toBeVisible();
- expect(screen.getByText('isCompressed: false')).toBeVisible();
-
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 200, configurable: true },
- });
- fireEvent.scroll(window.document);
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 250, configurable: true },
- });
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: true')).toBeVisible();
-
- await user.click(screen.getByText('reset Compress'));
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 300, configurable: true },
- });
- await fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: false')).toBeVisible();
-});
-
-it('keep the compressed state if scroll dont move', async () => {
- renderComponent(<X />);
-
- expect(screen.getByText('isScrolled: false')).toBeVisible();
- expect(screen.getByText('isCompressed: false')).toBeVisible();
-
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 200, configurable: true },
- });
- fireEvent.scroll(window.document);
- Object.defineProperties(window.document.documentElement, {
- scrollTop: { value: 250, configurable: true },
- });
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: true')).toBeVisible();
-
- fireEvent.scroll(window.document);
- expect(await screen.findByText('isScrolled: true')).toBeVisible();
- expect(await screen.findByText('isCompressed: true')).toBeVisible();
-});
-
-function X() {
- const { isScrolled, isCompressed, resetScrollDownCompress } = useScrollDownCompress(100, 10);
-
- return (
- <div>
- <div>isScrolled: {`${isScrolled}`}</div>
- <div>isCompressed: {`${isCompressed}`}</div>
- <button onClick={resetScrollDownCompress} type="button">
- reset Compress
- </button>
- </div>
- );
-}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { act } from '@testing-library/react';
+import React from 'react';
+import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import { byRole } from '../../../../helpers/testSelector';
+import { mockIntersectionObserver } from '../../../../helpers/testUtils';
+import useStickyDetection from '../useStickyDetection';
+
+it('should render correctly based on intersection callback', () => {
+ const intersect = mockIntersectionObserver();
+ renderComponent(<StickyComponent />);
+
+ expect(byRole('heading', { name: 'static' }).get()).toBeInTheDocument();
+
+ act(() => {
+ intersect({
+ isIntersecting: false,
+ intersectionRatio: 0.99,
+ boundingClientRect: { top: 1 },
+ intersectionRect: { top: 0 },
+ });
+ });
+
+ expect(byRole('heading', { name: 'sticky' }).get()).toBeInTheDocument();
+});
+
+function StickyComponent() {
+ const isSticky = useStickyDetection('.target', { offset: 0 });
+
+ return (
+ <div className="target">
+ <h1>{isSticky ? 'sticky' : 'static'}</h1>
+ </div>
+ );
+}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { throttle } from 'lodash';
-import { useCallback, useEffect, useRef, useState } from 'react';
-
-const THROTTLE_LONG_DELAY = 100;
-
-export default function useScrollDownCompress(compressThreshold: number, scrollThreshold: number) {
- const [isCompressed, setIsCompressed] = useState(false);
- const [isScrolled, setIsScrolled] = useState(
- () => document?.documentElement?.scrollTop > scrollThreshold,
- );
-
- const initialScrollHeightRef = useRef<number | undefined>(undefined);
- const scrollTopRef = useRef<number | undefined>(undefined);
-
- useEffect(() => {
- const handleScroll = throttle(() => {
- // Save the initial scrollHeight of the document
- const scrollHeight = document?.documentElement?.scrollHeight;
- initialScrollHeightRef.current = Math.max(initialScrollHeightRef.current ?? 0, scrollHeight);
-
- // Compute the scrollTop value relative to the initial scrollHeight.
- // The scrollHeight value changes when we compress the header - influencing the scrollTop value
- const relativeScrollTop =
- document?.documentElement?.scrollTop + (initialScrollHeightRef.current - scrollHeight);
-
- if (
- // First scroll means we just loaded the page or changed tab, in this case we shouldn't compress
- scrollTopRef.current === undefined ||
- // We also shouldn't compress if the size of the document wouldn't have a scroll after being compressed
- initialScrollHeightRef.current - document?.documentElement?.clientHeight < compressThreshold
- ) {
- setIsCompressed(false);
-
- // We shouldn't change the compressed flag if the scrollTop value didn't change
- } else if (relativeScrollTop !== scrollTopRef.current) {
- // Compress when scrolling in down direction and we are scrolled more than a threshold
- setIsCompressed(
- relativeScrollTop > scrollTopRef.current && relativeScrollTop > scrollThreshold,
- );
- }
-
- // Should display the shadow when we are scrolled more than a small threshold
- setIsScrolled(relativeScrollTop > scrollThreshold);
-
- // Save the last scroll position to compare it with the next one and infer the directions
- scrollTopRef.current = relativeScrollTop;
- }, THROTTLE_LONG_DELAY);
-
- document.addEventListener('scroll', handleScroll);
- return () => document.removeEventListener('scroll', handleScroll);
- }, []);
-
- const resetScrollDownCompress = useCallback(() => {
- initialScrollHeightRef.current = undefined;
- scrollTopRef.current = undefined;
- }, []);
-
- return { isCompressed, isScrolled, resetScrollDownCompress };
-}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { useEffect, useState } from 'react';
+
+interface Options {
+ offset: number;
+ direction?: 'HORIZONTAL' | 'VERTICAL';
+}
+
+/*
+ * Detects if sticky element is out of viewport
+ */
+export default function useStickyDetection(target: string, options: Options) {
+ const { offset, direction = 'VERTICAL' } = options;
+ const [isSticky, setIsSticky] = useState(false);
+
+ useEffect(() => {
+ const rootMargin =
+ direction === 'VERTICAL' ? `${-offset - 1}px 0px 0px 0px` : `0px 0px 0px ${-offset - 1}px`;
+
+ const observer = new IntersectionObserver(
+ ([e]) => {
+ setIsSticky(e.intersectionRatio < 1 && elementIntersectedByDirection(e, direction));
+ },
+ // -1px moves viewport by direction that allows to detect when element became sticky and
+ // fully visible in viewport
+ { threshold: [1], rootMargin },
+ );
+
+ const element = document.querySelector(target);
+
+ if (element) {
+ observer.observe(element);
+ }
+
+ return () => {
+ if (element) {
+ observer.unobserve(element);
+ }
+ };
+ }, [target, setIsSticky, direction, offset]);
+
+ return isSticky;
+}
+
+function elementIntersectedByDirection(
+ e: IntersectionObserverEntry,
+ direction: 'VERTICAL' | 'HORIZONTAL',
+) {
+ const { boundingClientRect, intersectionRect } = e;
+ const prop = direction === 'VERTICAL' ? 'top' : 'right';
+
+ return boundingClientRect[prop] - intersectionRect[prop] !== 0;
+}
await new Promise(setImmediate);
wrapper.update();
}
+
+export function mockIntersectionObserver(): Function {
+ let callback: Function;
+
+ // @ts-ignore
+ global.IntersectionObserver = jest.fn((cb: Function) => {
+ const instance = {
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+ };
+
+ callback = cb;
+
+ callback([
+ {
+ isIntersecting: true,
+ intersectionRatio: 1,
+ boundingClientRect: { top: 0 },
+ intersectionRect: { top: 0 },
+ },
+ ]);
+ return instance;
+ });
+
+ return (entry: IntersectionObserverEntry) => callback([entry]);
+}