Skip to content

Commit

Permalink
feat: add new sticky component to handle stacked stickies
Browse files Browse the repository at this point in the history
  • Loading branch information
nunogois committed Oct 18, 2023
1 parent 4a49cd5 commit a2e559a
Show file tree
Hide file tree
Showing 9 changed files with 221 additions and 39 deletions.
96 changes: 96 additions & 0 deletions frontend/src/component/common/Sticky/Sticky.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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,
stickyItems,
} = context;

useEffect(() => {
if (ref.current && initialTopOffset === null) {
setInitialTopOffset(
ref.current
? parseInt(
getComputedStyle(ref.current).getPropertyValue('top'),
)
: 0,
);
}
}, []);

useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
if (ref.current) {
setTop(getTopOffset(ref) + (initialTopOffset || 0));
}
});

if (ref.current) {
resizeObserver.observe(ref.current);
}

setTop(getTopOffset(ref) + (initialTopOffset || 0));

return () => {
if (ref.current) {
resizeObserver.unobserve(ref.current);
}
};
}, [stickyItems, initialTopOffset, getTopOffset]);

useEffect(() => {
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';

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,
);
76 changes: 76 additions & 0 deletions frontend/src/component/common/Sticky/StickyProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState, useCallback, ReactNode, RefObject } from 'react';
import { StickyContext } from './StickyContext';

interface IStickyProviderProps {
children: ReactNode;
}

export const StickyProvider = ({ children }: IStickyProviderProps) => {
const [stickyItems, setStickyItems] = useState<RefObject<HTMLDivElement>[]>(
[],
);

const registerStickyItem = useCallback(
(item: RefObject<HTMLDivElement>) => {
setStickyItems((prevItems) => {
if (item && !prevItems.includes(item)) {
const newItems = [...prevItems, item];
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>) => {
setStickyItems((prev) => prev.filter((item) => item !== ref));
},
[],
);

const getTopOffset = useCallback(
(ref: RefObject<HTMLDivElement>) => {
return stickyItems.some((item) => item === ref)
? stickyItems
.slice(
0,
stickyItems.findIndex((item) => item === ref),
)
.reduce((acc, item) => {
return item === ref
? acc
: acc +
(item.current?.getBoundingClientRect()
.height || 0);
}, 0)
: 0;
},
[stickyItems],
);

return (
<StickyContext.Provider
value={{
stickyItems,
registerStickyItem,
unregisterStickyItem,
getTopOffset,
}}
>
{children}
</StickyContext.Provider>
);
};
5 changes: 2 additions & 3 deletions frontend/src/component/demo/DemoBanner/DemoBanner.tsx
Original file line number Diff line number Diff line change
@@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/component/maintenance/MaintenanceBanner.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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',
Expand All @@ -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,
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,22 @@ import { MessageBannerDialog } from './MessageBannerDialog/MessageBannerDialog';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { BannerVariant, IMessageBanner } from 'interfaces/messageBanner';
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 MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
dialog,
} = messageBanner;

return (
<StyledBar variant={variant} sticky={sticky}>
const banner = (
<StyledBar variant={variant}>
<StyledIcon variant={variant}>
<BannerIcon icon={icon} variant={variant} />
</StyledIcon>
Expand All @@ -84,6 +77,12 @@ export const MessageBanner = ({ messageBanner }: IMessageBannerProps) => {
</MessageBannerDialog>
</StyledBar>
);

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

return banner;
};

const VariantIcons = {
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -23,10 +24,12 @@ ReactDOM.render(
<ThemeProvider>
<AnnouncerProvider>
<FeedbackCESProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
<StickyProvider>
<InstanceStatus>
<ScrollTop />
<App />
</InstanceStatus>
</StickyProvider>
</FeedbackCESProvider>
</AnnouncerProvider>
</ThemeProvider>
Expand Down

0 comments on commit a2e559a

Please sign in to comment.