1
0
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 😞

![image](https://github.com/Unleash/unleash/assets/14320932/dd6fa188-6774-4afb-86fd-0eefb9aba93e)

### After - Sticky elements stack 😄 

![image](https://github.com/Unleash/unleash/assets/14320932/c73a84ab-7133-448f-9df6-69bd4c5330c2)
This commit is contained in:
Nuno Góis 2023-10-19 15:50:37 +01:00 committed by GitHub
parent 1335da6366
commit 347c1cabbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 564 additions and 48 deletions

View File

@ -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 = {

View File

@ -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>

View File

@ -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>

View 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' });
});
});

View 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>
);
};

View 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,
);

View 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);
});
});

View 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>
);
};

View File

@ -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),

View File

@ -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,

View File

@ -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,

View File

@ -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,
}));

View File

@ -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>

View File

@ -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';