Skip to content

Commit

Permalink
feat: add new sticky component to handle stacked stickies (#5088)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
nunogois authored Oct 19, 2023
1 parent 1335da6 commit 347c1ca
Show file tree
Hide file tree
Showing 14 changed files with 564 additions and 48 deletions.
45 changes: 22 additions & 23 deletions frontend/src/component/banners/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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>
Expand All @@ -84,6 +77,12 @@ export const Banner = ({ banner }: IBannerProps) => {
</BannerDialog>
</StyledBar>
);

if (sticky) {
return <Sticky>{bannerBar}</Sticky>;
}

return bannerBar;
};

const VariantIcons = {
Expand Down
17 changes: 11 additions & 6 deletions frontend/src/component/changeRequest/ChangeRequest.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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>
Expand Down
112 changes: 112 additions & 0 deletions frontend/src/component/common/Sticky/Sticky.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 80 additions & 0 deletions frontend/src/component/common/Sticky/Sticky.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions frontend/src/component/common/Sticky/StickyContext.tsx
Original file line number Diff line number Diff line change
@@ -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,
);
Loading

0 comments on commit 347c1ca

Please sign in to comment.