Skip to content

Commit 60cca10

Browse files
stefanvcholdgraf
andauthored
🫳 Adjust PrimarySideBar downwards when banner is visible (jupyter-book#683)
Co-authored-by: Chris Holdgraf <[email protected]>
1 parent de80937 commit 60cca10

File tree

6 files changed

+90
-21
lines changed

6 files changed

+90
-21
lines changed

.changeset/warm-rockets-push.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@myst-theme/providers': patch
3+
'@myst-theme/site': patch
4+
'@myst-theme/book': patch
5+
---
6+
7+
Adjust the primary sidebar height and positioning when banner is displayed.
8+
9+
Also expose the banner state for other components to use.

packages/providers/src/banner.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Context to provide the visibility and height of the top banner,
2+
// and a mechanism to set it from within the Banner upon redraw.
3+
4+
import React, {
5+
createContext,
6+
useContext,
7+
useState,
8+
useMemo,
9+
type Dispatch,
10+
type SetStateAction,
11+
} from 'react';
12+
13+
type BannerState = {
14+
visible?: boolean;
15+
height: number;
16+
};
17+
18+
type BannerContextValue = {
19+
bannerState: BannerState;
20+
setBannerState: Dispatch<SetStateAction<BannerState>>;
21+
};
22+
23+
const BannerStateContext = createContext<BannerContextValue | undefined>(undefined);
24+
25+
export function BannerStateProvider({ children }: { children: React.ReactNode }) {
26+
const [bannerState, setBannerState] = useState<BannerState>({ visible: undefined, height: 0 });
27+
28+
const value = useMemo(() => ({ bannerState, setBannerState }), [bannerState]);
29+
30+
return <BannerStateContext.Provider value={value}>{children}</BannerStateContext.Provider>;
31+
}
32+
33+
export function useBannerState() {
34+
const ctx = useContext(BannerStateContext);
35+
if (!ctx) {
36+
throw new Error('useBannerState must be used from within BannerStateProvider');
37+
}
38+
return ctx;
39+
}

packages/providers/src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export * from './tabs.js';
1010
export * from './xref.js';
1111
export * from './renderers.js';
1212
export * from './project.js';
13+
export * from './banner.js';

packages/site/src/components/Navigation/PrimarySidebar.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useIsWide,
1010
useBaseurl,
1111
withBaseurl,
12+
useBannerState,
1213
} from '@myst-theme/providers';
1314
import type { Heading } from '@myst-theme/common';
1415
import { Toc } from './TableOfContentsItems.js';
@@ -103,15 +104,18 @@ export function useSidebarHeight<T extends HTMLElement = HTMLElement>(top = 0, i
103104
const toc = useRef<HTMLDivElement>(null);
104105
const transitionState = useNavigation().state;
105106
const wide = useIsWide();
107+
const { bannerState } = useBannerState();
108+
const totalTop = top + bannerState.height;
109+
106110
const setHeight = () => {
107111
if (!container.current || !toc.current) return;
108112
const height = container.current.offsetHeight - window.scrollY;
109113
const div = toc.current.firstChild as HTMLDivElement;
110114
if (div)
111115
div.style.height = wide
112-
? `min(calc(100vh - ${top}px), ${height + inset}px)`
113-
: `calc(100vh - ${top}px)`;
114-
if (div) div.style.height = `min(calc(100vh - ${top}px), ${height + inset}px)`;
116+
? `min(calc(100vh - ${totalTop}px), ${height + inset}px)`
117+
: `calc(100vh - ${totalTop}px)`;
118+
if (div) div.style.height = `min(calc(100vh - ${totalTop}px), ${height + inset}px)`;
115119
const nav = toc.current.querySelector('nav');
116120
if (nav) nav.style.opacity = height > 150 ? '1' : '0';
117121
};
@@ -123,7 +127,7 @@ export function useSidebarHeight<T extends HTMLElement = HTMLElement>(top = 0, i
123127
return () => {
124128
window.removeEventListener('scroll', handleScroll);
125129
};
126-
}, [container, toc, transitionState, wide]);
130+
}, [container, toc, transitionState, wide, totalTop]);
127131
return { container, toc };
128132
}
129133

@@ -143,6 +147,7 @@ export const PrimarySidebar = ({
143147
mobileOnly?: boolean;
144148
}) => {
145149
const top = useThemeTop();
150+
const { bannerState } = useBannerState();
146151
const grid = useGridSystemProvider();
147152
const footerRef = useRef<HTMLDivElement>(null);
148153
const [open] = useNavOpen();
@@ -168,7 +173,7 @@ export const PrimarySidebar = ({
168173
{ 'lg:hidden': nav && hide_toc },
169174
{ hidden: !open, 'z-30': open, 'z-10': !open },
170175
)}
171-
style={{ top }}
176+
style={{ top: top + bannerState.height }}
172177
>
173178
<div
174179
className={classNames(

themes/book/app/components/Banner.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { useState, useEffect } from 'react';
1+
import { useState, useEffect, useRef } from 'react';
22
import { MyST } from 'myst-to-react';
33
import classNames from 'classnames';
44
import type { GenericParent } from 'myst-common';
55
import { hashString } from '~/utils/hash';
66
import { XMarkIcon } from '@heroicons/react/24/solid';
7+
import { useBannerState } from '@myst-theme/providers';
78

89
/**
910
* A dismissible banner component at the top that shows content passed as a MyST AST.
@@ -13,24 +14,34 @@ export function Banner({ content, className }: { content: GenericParent; classNa
1314
const contentString = JSON.stringify(content);
1415
const bannerId = hashString(contentString);
1516

16-
// Start hidden, only show after checking localStorage on client
17-
// This avoids flickering on initial load
18-
const [isVisible, setIsVisible] = useState(false);
17+
// Start hidden, only show after checking localStorage on client.
18+
// This avoids flickering on initial load.
19+
const { bannerState, setBannerState } = useBannerState();
1920

20-
// Check dismissal state on client side only
21+
const ref = useRef<HTMLElement | null>(null);
22+
23+
// Check dismissal state on client side
2124
// If the banner content changes, the ID will be different and it'll show again
2225
useEffect(() => {
26+
const el = ref.current;
27+
2328
const dismissed = localStorage.getItem(`myst-dismissed-banner-${bannerId}`) === 'true';
24-
setIsVisible(!dismissed);
25-
}, [bannerId]);
29+
setBannerState({
30+
visible: !dismissed,
31+
height: el ? el.getBoundingClientRect().height : 0,
32+
});
33+
}, [bannerId, bannerState.visible]);
2634

2735
const handleDismiss = () => {
2836
localStorage.setItem(`myst-dismissed-banner-${bannerId}`, 'true');
29-
setIsVisible(false);
37+
setBannerState({
38+
visible: false,
39+
height: 0,
40+
});
3041
};
3142

3243
// Don't render if not visible
33-
if (!isVisible) return null;
44+
if (!bannerState.visible) return null;
3445

3546
// Should be styled similarly to the footer
3647
return (
@@ -42,6 +53,7 @@ export function Banner({ content, className }: { content: GenericParent; classNa
4253
'relative z-40',
4354
className,
4455
)}
56+
ref={ref}
4557
>
4658
<div className="max-w-screen-lg mx-auto flex items-center gap-4">
4759
{/* Banner content */}

themes/book/app/routes/$.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
useSiteManifest,
2626
useThemeTop,
2727
ProjectProvider,
28+
BannerStateProvider,
2829
} from '@myst-theme/providers';
2930
import { ComputeOptionsProvider, ThebeLoaderAndServer } from '@myst-theme/jupyter';
3031
import { MadeWithMyst } from '@myst-theme/icons';
@@ -139,13 +140,15 @@ export function ArticlePageAndNavigation({
139140
}) {
140141
return (
141142
<UiStateProvider>
142-
<ArticlePageAndNavigationInternal
143-
children={children}
144-
hide_toc={hide_toc}
145-
hideSearch={hideSearch}
146-
projectSlug={projectSlug}
147-
inset={inset}
148-
/>
143+
<BannerStateProvider>
144+
<ArticlePageAndNavigationInternal
145+
children={children}
146+
hide_toc={hide_toc}
147+
hideSearch={hideSearch}
148+
projectSlug={projectSlug}
149+
inset={inset}
150+
/>
151+
</BannerStateProvider>
149152
</UiStateProvider>
150153
);
151154
}

0 commit comments

Comments
 (0)