-
-
Notifications
You must be signed in to change notification settings - Fork 734
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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)
- Loading branch information
Showing
14 changed files
with
564 additions
and
48 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' }); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
); |
Oops, something went wrong.