mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
feat: add new sticky component to handle stacked stickies (#5088)
https://linear.app/unleash/issue/2-1509/discovery-stacked-sticky-elements Adds a new `Sticky` element that will attempt to stack sticky elements in the DOM in a smart way. This needs a wrapping `StickyProvider` that will keep track of sticky elements. This PR adapts a few components to use this new element: - `DemoBanner` - `FeatureOverviewSidePanel` - `DraftBanner` - `MaintenanceBanner` - `MessageBanner` Pre-existing `top` properties are taken into consideration for the top offset, so we can have nice margins like in the feature overview side panel. ### Before - Sticky elements overlap 😞  ### After - Sticky elements stack 😄 
This commit is contained in:
parent
1335da6366
commit
347c1cabbc
@ -11,29 +11,22 @@ import { BannerDialog } from './BannerDialog/BannerDialog';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { BannerVariant, IBanner } from 'interfaces/banner';
|
||||
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||
|
||||
const StyledBar = styled('aside', {
|
||||
shouldForwardProp: (prop) => prop !== 'variant' && prop !== 'sticky',
|
||||
})<{ variant: BannerVariant; sticky?: boolean }>(
|
||||
({ theme, variant, sticky }) => ({
|
||||
position: sticky ? 'sticky' : 'relative',
|
||||
zIndex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(1),
|
||||
gap: theme.spacing(1),
|
||||
borderBottom: '1px solid',
|
||||
borderColor: theme.palette[variant].border,
|
||||
background: theme.palette[variant].light,
|
||||
color: theme.palette[variant].dark,
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
...(sticky && {
|
||||
top: 0,
|
||||
zIndex: theme.zIndex.sticky - 100,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
shouldForwardProp: (prop) => prop !== 'variant',
|
||||
})<{ variant: BannerVariant }>(({ theme, variant }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(1),
|
||||
gap: theme.spacing(1),
|
||||
borderBottom: '1px solid',
|
||||
borderColor: theme.palette[variant].border,
|
||||
background: theme.palette[variant].light,
|
||||
color: theme.palette[variant].dark,
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
}));
|
||||
|
||||
const StyledIcon = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'variant',
|
||||
@ -62,8 +55,8 @@ export const Banner = ({ banner }: IBannerProps) => {
|
||||
dialog,
|
||||
} = banner;
|
||||
|
||||
return (
|
||||
<StyledBar variant={variant} sticky={sticky}>
|
||||
const bannerBar = (
|
||||
<StyledBar variant={variant}>
|
||||
<StyledIcon variant={variant}>
|
||||
<BannerIcon icon={icon} variant={variant} />
|
||||
</StyledIcon>
|
||||
@ -84,6 +77,12 @@ export const Banner = ({ banner }: IBannerProps) => {
|
||||
</BannerDialog>
|
||||
</StyledBar>
|
||||
);
|
||||
|
||||
if (sticky) {
|
||||
return <Sticky>{bannerBar}</Sticky>;
|
||||
}
|
||||
|
||||
return bannerBar;
|
||||
};
|
||||
|
||||
const VariantIcons = {
|
||||
|
@ -8,6 +8,7 @@ import { AccessProvider } from '../providers/AccessProvider/AccessProvider';
|
||||
import { AnnouncerProvider } from '../common/Announcer/AnnouncerProvider/AnnouncerProvider';
|
||||
import { testServerRoute, testServerSetup } from '../../utils/testServer';
|
||||
import { UIProviderContainer } from '../providers/UIProvider/UIProviderContainer';
|
||||
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
|
||||
|
||||
const server = testServerSetup();
|
||||
|
||||
@ -227,12 +228,16 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<ThemeProvider>
|
||||
<AnnouncerProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path={pathTemplate}
|
||||
element={<MainLayout>{children}</MainLayout>}
|
||||
/>
|
||||
</Routes>
|
||||
<StickyProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path={pathTemplate}
|
||||
element={
|
||||
<MainLayout>{children}</MainLayout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</StickyProvider>
|
||||
</AnnouncerProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
|
@ -10,6 +10,7 @@ import { FC } from 'react';
|
||||
import { IPermission } from '../../interfaces/user';
|
||||
import { SWRConfig } from 'swr';
|
||||
import { ProjectMode } from '../project/Project/hooks/useProjectEnterpriseSettingsForm';
|
||||
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
|
||||
|
||||
const server = testServerSetup();
|
||||
|
||||
@ -186,9 +187,14 @@ const UnleashUiSetup: FC<{ path: string; pathTemplate: string }> = ({
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<ThemeProvider>
|
||||
<AnnouncerProvider>
|
||||
<Routes>
|
||||
<Route path={pathTemplate} element={children} />
|
||||
</Routes>
|
||||
<StickyProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path={pathTemplate}
|
||||
element={children}
|
||||
/>
|
||||
</Routes>
|
||||
</StickyProvider>
|
||||
</AnnouncerProvider>
|
||||
</ThemeProvider>
|
||||
</MemoryRouter>
|
||||
|
112
frontend/src/component/common/Sticky/Sticky.test.tsx
Normal file
112
frontend/src/component/common/Sticky/Sticky.test.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { Sticky } from './Sticky';
|
||||
import { IStickyContext, StickyContext } from './StickyContext';
|
||||
import { vi, expect } from 'vitest';
|
||||
|
||||
describe('Sticky component', () => {
|
||||
let originalConsoleError: () => void;
|
||||
let mockRegisterStickyItem: () => void;
|
||||
let mockUnregisterStickyItem: () => void;
|
||||
let mockGetTopOffset: () => number;
|
||||
let mockContextValue: IStickyContext;
|
||||
|
||||
beforeEach(() => {
|
||||
originalConsoleError = console.error;
|
||||
console.error = vi.fn();
|
||||
|
||||
mockRegisterStickyItem = vi.fn();
|
||||
mockUnregisterStickyItem = vi.fn();
|
||||
mockGetTopOffset = vi.fn(() => 10);
|
||||
|
||||
mockContextValue = {
|
||||
registerStickyItem: mockRegisterStickyItem,
|
||||
unregisterStickyItem: mockUnregisterStickyItem,
|
||||
getTopOffset: mockGetTopOffset,
|
||||
stickyItems: [],
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
console.error = originalConsoleError;
|
||||
});
|
||||
|
||||
it('renders correctly within StickyContext', () => {
|
||||
render(
|
||||
<StickyContext.Provider value={mockContextValue}>
|
||||
<Sticky>Content</Sticky>
|
||||
</StickyContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('throws error when not wrapped in StickyContext', () => {
|
||||
console.error = vi.fn();
|
||||
|
||||
expect(() => render(<Sticky>Content</Sticky>)).toThrow(
|
||||
'Sticky component must be used within a StickyProvider',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies sticky positioning', () => {
|
||||
render(
|
||||
<StickyContext.Provider value={mockContextValue}>
|
||||
<Sticky>Content</Sticky>
|
||||
</StickyContext.Provider>,
|
||||
);
|
||||
|
||||
const stickyElement = screen.getByText('Content');
|
||||
expect(stickyElement).toHaveStyle({ position: 'sticky' });
|
||||
});
|
||||
|
||||
it('registers and unregisters sticky item on mount/unmount', () => {
|
||||
const { unmount } = render(
|
||||
<StickyContext.Provider value={mockContextValue}>
|
||||
<Sticky>Content</Sticky>
|
||||
</StickyContext.Provider>,
|
||||
);
|
||||
|
||||
expect(mockRegisterStickyItem).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockUnregisterStickyItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('correctly sets the top value when mounted', async () => {
|
||||
render(
|
||||
<StickyContext.Provider value={mockContextValue}>
|
||||
<Sticky>Content</Sticky>
|
||||
</StickyContext.Provider>,
|
||||
);
|
||||
|
||||
const stickyElement = await screen.findByText('Content');
|
||||
expect(stickyElement).toHaveStyle({ top: '10px' });
|
||||
});
|
||||
|
||||
it('updates top offset when stickyItems changes', async () => {
|
||||
const { rerender } = render(
|
||||
<StickyContext.Provider value={mockContextValue}>
|
||||
<Sticky>Content</Sticky>
|
||||
</StickyContext.Provider>,
|
||||
);
|
||||
|
||||
let stickyElement = await screen.findByText('Content');
|
||||
expect(stickyElement).toHaveStyle({ top: '10px' });
|
||||
|
||||
const updatedMockContextValue = {
|
||||
...mockContextValue,
|
||||
getTopOffset: vi.fn(() => 20),
|
||||
};
|
||||
|
||||
rerender(
|
||||
<StickyContext.Provider value={updatedMockContextValue}>
|
||||
<Sticky>Content</Sticky>
|
||||
</StickyContext.Provider>,
|
||||
);
|
||||
|
||||
stickyElement = await screen.findByText('Content');
|
||||
expect(stickyElement).toHaveStyle({ top: '20px' });
|
||||
});
|
||||
});
|
80
frontend/src/component/common/Sticky/Sticky.tsx
Normal file
80
frontend/src/component/common/Sticky/Sticky.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import {
|
||||
HTMLAttributes,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { StickyContext } from './StickyContext';
|
||||
import { styled } from '@mui/material';
|
||||
|
||||
const StyledSticky = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'top',
|
||||
})<{ top?: number }>(({ theme, top }) => ({
|
||||
position: 'sticky',
|
||||
zIndex: theme.zIndex.sticky - 100,
|
||||
...(top !== undefined
|
||||
? {
|
||||
'&': {
|
||||
top,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
interface IStickyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Sticky = ({ children, ...props }: IStickyProps) => {
|
||||
const context = useContext(StickyContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [initialTopOffset, setInitialTopOffset] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [top, setTop] = useState<number>();
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'Sticky component must be used within a StickyProvider',
|
||||
);
|
||||
}
|
||||
|
||||
const { registerStickyItem, unregisterStickyItem, getTopOffset } = context;
|
||||
|
||||
useEffect(() => {
|
||||
// We should only set the initial top offset once - when the component is mounted
|
||||
// This value will be set based on the initial top that was set for this component
|
||||
// After that, the top will be calculated based on the height of the previous sticky items + this initial top offset
|
||||
if (ref.current && initialTopOffset === null) {
|
||||
setInitialTopOffset(
|
||||
parseInt(getComputedStyle(ref.current).getPropertyValue('top')),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// (Re)calculate the top offset based on the sticky items
|
||||
setTop(getTopOffset(ref) + (initialTopOffset || 0));
|
||||
}, [getTopOffset, initialTopOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
// We should register the sticky item when it is mounted and unregister it when it is unmounted
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerStickyItem(ref);
|
||||
|
||||
return () => {
|
||||
unregisterStickyItem(ref);
|
||||
};
|
||||
}, [ref, registerStickyItem, unregisterStickyItem]);
|
||||
|
||||
return (
|
||||
<StyledSticky ref={ref} top={top} {...props}>
|
||||
{children}
|
||||
</StyledSticky>
|
||||
);
|
||||
};
|
12
frontend/src/component/common/Sticky/StickyContext.tsx
Normal file
12
frontend/src/component/common/Sticky/StickyContext.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { RefObject, createContext } from 'react';
|
||||
|
||||
export interface IStickyContext {
|
||||
stickyItems: RefObject<HTMLDivElement>[];
|
||||
registerStickyItem: (ref: RefObject<HTMLDivElement>) => void;
|
||||
unregisterStickyItem: (ref: RefObject<HTMLDivElement>) => void;
|
||||
getTopOffset: (ref: RefObject<HTMLDivElement>) => number;
|
||||
}
|
||||
|
||||
export const StickyContext = createContext<IStickyContext | undefined>(
|
||||
undefined,
|
||||
);
|
160
frontend/src/component/common/Sticky/StickyProvider.test.tsx
Normal file
160
frontend/src/component/common/Sticky/StickyProvider.test.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
import { StickyProvider } from './StickyProvider';
|
||||
import { IStickyContext, StickyContext } from './StickyContext';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
const defaultGetBoundingClientRect = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
};
|
||||
|
||||
describe('StickyProvider component', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('provides the sticky context with expected functions', () => {
|
||||
let receivedContext = null;
|
||||
render(
|
||||
<StickyProvider>
|
||||
<StickyContext.Consumer>
|
||||
{(context) => {
|
||||
receivedContext = context;
|
||||
return null;
|
||||
}}
|
||||
</StickyContext.Consumer>
|
||||
</StickyProvider>,
|
||||
);
|
||||
|
||||
expect(receivedContext).not.toBeNull();
|
||||
expect(receivedContext).toHaveProperty('stickyItems');
|
||||
expect(receivedContext).toHaveProperty('registerStickyItem');
|
||||
expect(receivedContext).toHaveProperty('unregisterStickyItem');
|
||||
expect(receivedContext).toHaveProperty('getTopOffset');
|
||||
});
|
||||
|
||||
it('registers and unregisters sticky items', () => {
|
||||
let contextValues: IStickyContext | undefined;
|
||||
const refMock = { current: document.createElement('div') };
|
||||
|
||||
const { rerender } = render(
|
||||
<StickyProvider>
|
||||
<StickyContext.Consumer>
|
||||
{(context) => {
|
||||
contextValues = context;
|
||||
return null;
|
||||
}}
|
||||
</StickyContext.Consumer>
|
||||
</StickyProvider>,
|
||||
);
|
||||
|
||||
contextValues?.registerStickyItem(refMock);
|
||||
rerender(
|
||||
<StickyProvider>
|
||||
<StickyContext.Consumer>
|
||||
{(context) => {
|
||||
contextValues = context;
|
||||
return null;
|
||||
}}
|
||||
</StickyContext.Consumer>
|
||||
</StickyProvider>,
|
||||
);
|
||||
|
||||
expect(contextValues?.stickyItems).toContain(refMock);
|
||||
|
||||
contextValues?.unregisterStickyItem(refMock);
|
||||
rerender(
|
||||
<StickyProvider>
|
||||
<StickyContext.Consumer>
|
||||
{(context) => {
|
||||
contextValues = context;
|
||||
return null;
|
||||
}}
|
||||
</StickyContext.Consumer>
|
||||
</StickyProvider>,
|
||||
);
|
||||
|
||||
expect(contextValues?.stickyItems).not.toContain(refMock);
|
||||
});
|
||||
|
||||
it('sorts sticky items based on their DOM position', () => {
|
||||
let contextValues: IStickyContext | undefined;
|
||||
|
||||
const refMockA = { current: document.createElement('div') };
|
||||
const refMockB = { current: document.createElement('div') };
|
||||
|
||||
refMockA.current.getBoundingClientRect = () => ({
|
||||
...defaultGetBoundingClientRect,
|
||||
top: 200,
|
||||
});
|
||||
refMockB.current.getBoundingClientRect = () => ({
|
||||
...defaultGetBoundingClientRect,
|
||||
top: 100,
|
||||
});
|
||||
|
||||
render(
|
||||
<StickyProvider>
|
||||
<StickyContext.Consumer>
|
||||
{(context) => {
|
||||
contextValues = context;
|
||||
return null;
|
||||
}}
|
||||
</StickyContext.Consumer>
|
||||
</StickyProvider>,
|
||||
);
|
||||
|
||||
contextValues?.registerStickyItem(refMockA);
|
||||
contextValues?.registerStickyItem(refMockB);
|
||||
|
||||
expect(contextValues?.stickyItems[0]).toBe(refMockB);
|
||||
expect(contextValues?.stickyItems[1]).toBe(refMockA);
|
||||
});
|
||||
|
||||
it('calculates top offset correctly', () => {
|
||||
let contextValues: IStickyContext | undefined;
|
||||
const refMockA = { current: document.createElement('div') };
|
||||
const refMockB = { current: document.createElement('div') };
|
||||
|
||||
refMockA.current.getBoundingClientRect = () => ({
|
||||
...defaultGetBoundingClientRect,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
refMockB.current.getBoundingClientRect = () => ({
|
||||
...defaultGetBoundingClientRect,
|
||||
height: 200,
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<StickyProvider>
|
||||
<StickyContext.Consumer>
|
||||
{(context) => {
|
||||
contextValues = context;
|
||||
return null;
|
||||
}}
|
||||
</StickyContext.Consumer>
|
||||
</StickyProvider>,
|
||||
);
|
||||
|
||||
contextValues?.registerStickyItem(refMockA);
|
||||
contextValues?.registerStickyItem(refMockB);
|
||||
rerender(
|
||||
<StickyProvider>
|
||||
<StickyContext.Consumer>
|
||||
{(context) => {
|
||||
contextValues = context;
|
||||
return null;
|
||||
}}
|
||||
</StickyContext.Consumer>
|
||||
</StickyProvider>,
|
||||
);
|
||||
|
||||
const topOffset = contextValues?.getTopOffset(refMockB);
|
||||
expect(topOffset).toBe(100);
|
||||
});
|
||||
});
|
133
frontend/src/component/common/Sticky/StickyProvider.tsx
Normal file
133
frontend/src/component/common/Sticky/StickyProvider.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useState, useCallback, ReactNode, RefObject, useEffect } from 'react';
|
||||
import { StickyContext } from './StickyContext';
|
||||
|
||||
interface IStickyProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const StickyProvider = ({ children }: IStickyProviderProps) => {
|
||||
const [stickyItems, setStickyItems] = useState<RefObject<HTMLDivElement>[]>(
|
||||
[],
|
||||
);
|
||||
const [resizeListeners, setResizeListeners] = useState(
|
||||
new Set<RefObject<HTMLDivElement>>(),
|
||||
);
|
||||
|
||||
const registerStickyItem = useCallback(
|
||||
(item: RefObject<HTMLDivElement>) => {
|
||||
setStickyItems((prevItems) => {
|
||||
// We should only register a new item if it is not already registered
|
||||
if (!prevItems.includes(item)) {
|
||||
// Register resize listener for the item
|
||||
registerResizeListener(item);
|
||||
|
||||
const newItems = [...prevItems, item];
|
||||
// We should try to sort the items by their top on the viewport, so that their order in the DOM is the same as their order in the array
|
||||
return newItems.sort((a, b) => {
|
||||
const elementA = a.current;
|
||||
const elementB = b.current;
|
||||
if (elementA && elementB) {
|
||||
return (
|
||||
elementA.getBoundingClientRect().top -
|
||||
elementB.getBoundingClientRect().top
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return prevItems;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregisterStickyItem = useCallback(
|
||||
(ref: RefObject<HTMLDivElement>) => {
|
||||
unregisterResizeListener(ref);
|
||||
setStickyItems((prev) => prev.filter((item) => item !== ref));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const registerResizeListener = useCallback(
|
||||
(ref: RefObject<HTMLDivElement>) => {
|
||||
setResizeListeners((prev) => new Set(prev).add(ref));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregisterResizeListener = useCallback(
|
||||
(ref: RefObject<HTMLDivElement>) => {
|
||||
setResizeListeners((prev) => {
|
||||
const newListeners = new Set(prev);
|
||||
newListeners.delete(ref);
|
||||
return newListeners;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getTopOffset = useCallback(
|
||||
(ref: RefObject<HTMLDivElement>) => {
|
||||
if (!stickyItems.some((item) => item === ref)) {
|
||||
// Return 0 in case the item is not registered yet
|
||||
return 0;
|
||||
}
|
||||
const stickyItemsUpToOurItem = stickyItems.slice(
|
||||
0,
|
||||
stickyItems.findIndex((item) => item === ref),
|
||||
);
|
||||
return stickyItemsUpToOurItem.reduce((acc, item) => {
|
||||
if (item === ref) {
|
||||
// We should not include the current item in the calculation
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Accumulate the height of all sticky items above our item
|
||||
const itemHeight =
|
||||
item.current?.getBoundingClientRect().height || 0;
|
||||
|
||||
return acc + itemHeight;
|
||||
}, 0);
|
||||
},
|
||||
[stickyItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// We should recalculate top offsets whenever there's a resize
|
||||
// This will trigger the dependency in `getTopOffset` and recalculate the top offsets in the Sticky components
|
||||
setStickyItems((prev) => [...prev]);
|
||||
});
|
||||
|
||||
resizeListeners.forEach((item) => {
|
||||
if (item.current) {
|
||||
resizeObserver.observe(item.current);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (resizeListeners.size > 0) {
|
||||
resizeListeners.forEach((item) => {
|
||||
if (item.current) {
|
||||
resizeObserver.unobserve(item.current);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [resizeListeners]);
|
||||
|
||||
return (
|
||||
<StickyContext.Provider
|
||||
value={{
|
||||
stickyItems,
|
||||
registerStickyItem,
|
||||
unregisterStickyItem,
|
||||
getTopOffset,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StickyContext.Provider>
|
||||
);
|
||||
};
|
@ -1,9 +1,8 @@
|
||||
import { Button, styled } from '@mui/material';
|
||||
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
|
||||
const StyledBanner = styled('div')(({ theme }) => ({
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
const StyledBanner = styled(Sticky)(({ theme }) => ({
|
||||
zIndex: theme.zIndex.sticky,
|
||||
display: 'flex',
|
||||
gap: theme.spacing(1),
|
||||
|
@ -5,9 +5,9 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { FeatureOverviewSidePanelDetails } from './FeatureOverviewSidePanelDetails/FeatureOverviewSidePanelDetails';
|
||||
import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches';
|
||||
import { FeatureOverviewSidePanelTags } from './FeatureOverviewSidePanelTags/FeatureOverviewSidePanelTags';
|
||||
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
position: 'sticky',
|
||||
const StyledContainer = styled(Sticky)(({ theme }) => ({
|
||||
top: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadiusLarge,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
|
@ -5,6 +5,7 @@ import { ChangeRequestSidebar } from 'component/changeRequest/ChangeRequestSideb
|
||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
||||
import { IChangeRequest } from 'component/changeRequest/changeRequest.types';
|
||||
import { changesCount } from 'component/changeRequest/changesCount';
|
||||
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||
|
||||
interface IDraftBannerProps {
|
||||
project: string;
|
||||
@ -98,10 +99,7 @@ const DraftBannerContent: FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const StickyBanner = styled(Box)(({ theme }) => ({
|
||||
position: 'sticky',
|
||||
top: -1,
|
||||
zIndex: 250 /* has to lower than header.zIndex and higher than body.zIndex */,
|
||||
const StickyBanner = styled(Sticky)(({ theme }) => ({
|
||||
borderTop: `1px solid ${theme.palette.warning.border}`,
|
||||
borderBottom: `1px solid ${theme.palette.warning.border}`,
|
||||
color: theme.palette.warning.contrastText,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { styled } from '@mui/material';
|
||||
import { ErrorOutlineRounded } from '@mui/icons-material';
|
||||
import { Sticky } from 'component/common/Sticky/Sticky';
|
||||
|
||||
const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({
|
||||
color: theme.palette.error.main,
|
||||
@ -8,7 +9,7 @@ const StyledErrorRoundedIcon = styled(ErrorOutlineRounded)(({ theme }) => ({
|
||||
marginRight: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledDiv = styled('div')(({ theme }) => ({
|
||||
const StyledDiv = styled(Sticky)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
justifyContent: 'center',
|
||||
@ -18,8 +19,6 @@ const StyledDiv = styled('div')(({ theme }) => ({
|
||||
height: '65px',
|
||||
borderBottom: `1px solid ${theme.palette.error.border}`,
|
||||
whiteSpace: 'pre-wrap',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: theme.zIndex.sticky - 100,
|
||||
}));
|
||||
|
||||
|
@ -13,6 +13,7 @@ import { FeedbackCESProvider } from 'component/feedback/FeedbackCESContext/Feedb
|
||||
import { AnnouncerProvider } from 'component/common/Announcer/AnnouncerProvider/AnnouncerProvider';
|
||||
import { InstanceStatus } from 'component/common/InstanceStatus/InstanceStatus';
|
||||
import { UIProviderContainer } from 'component/providers/UIProvider/UIProviderContainer';
|
||||
import { StickyProvider } from 'component/common/Sticky/StickyProvider';
|
||||
|
||||
window.global ||= window;
|
||||
|
||||
@ -23,10 +24,12 @@ ReactDOM.render(
|
||||
<ThemeProvider>
|
||||
<AnnouncerProvider>
|
||||
<FeedbackCESProvider>
|
||||
<InstanceStatus>
|
||||
<ScrollTop />
|
||||
<App />
|
||||
</InstanceStatus>
|
||||
<StickyProvider>
|
||||
<InstanceStatus>
|
||||
<ScrollTop />
|
||||
<App />
|
||||
</InstanceStatus>
|
||||
</StickyProvider>
|
||||
</FeedbackCESProvider>
|
||||
</AnnouncerProvider>
|
||||
</ThemeProvider>
|
||||
|
@ -2,4 +2,14 @@ import '@testing-library/jest-dom';
|
||||
import 'whatwg-fetch';
|
||||
import 'regenerator-runtime';
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
if (!window.ResizeObserver) {
|
||||
window.ResizeObserver = ResizeObserver;
|
||||
}
|
||||
|
||||
process.env.TZ = 'UTC';
|
||||
|
Loading…
Reference in New Issue
Block a user