Skip to content

Commit 826cd81

Browse files
authored
Fix AnchorNav layout shift when sticky (#1124)
* add snapshots and unit test * fix a test * add changeset
1 parent cb34663 commit 826cd81

16 files changed

+142
-13
lines changed

.changeset/tame-boxes-shout.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@primer/react-brand': patch
3+
---
4+
5+
Fixed page layout shift caused by the `AnchorNav` component in a sticky state.
6+
7+
Previously the `AnchorNav` would remove its computed height from the underlying page in sticky state. Now that lost space is compensated for to create a smoother scrolling experience.

packages/react/src/AnchorNav/AnchorNav.features.stories.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,8 @@ HideUntilSticky.storyName = 'Hide until sticky'
266266
export const AnchorNavPrimaryActions = () => {
267267
const data = ['link1', 'link2', 'link3']
268268
return (
269-
<>
269+
<div style={{backgroundColor: 'var(--brand-color-canvas-default)'}}>
270+
<RedlineBackground height={75}></RedlineBackground>
270271
<AnchorNav>
271272
{data.map((link, index) => (
272273
<AnchorNav.Link href={link} key={index}>
@@ -302,15 +303,16 @@ export const AnchorNavPrimaryActions = () => {
302303
</RedlineBackground>
303304
))}
304305
</Stack>
305-
</>
306+
</div>
306307
)
307308
}
308309
AnchorNavPrimaryActions.storyName = 'With optional primary CTA'
309310

310311
export const AnchorNavLargerActions = () => {
311312
const data = ['link1', 'link2', 'link3']
312313
return (
313-
<>
314+
<div style={{backgroundColor: 'var(--brand-color-canvas-default)'}}>
315+
<RedlineBackground height={75}></RedlineBackground>
314316
<AnchorNav>
315317
{data.map((link, index) => (
316318
<AnchorNav.Link href={link} key={index}>
@@ -349,7 +351,7 @@ export const AnchorNavLargerActions = () => {
349351
</RedlineBackground>
350352
))}
351353
</Stack>
352-
</>
354+
</div>
353355
)
354356
}
355357
AnchorNavLargerActions.storyName = 'With larger CTAs'

packages/react/src/AnchorNav/AnchorNav.test.tsx

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {render, cleanup, fireEvent} from '@testing-library/react'
1+
import React, {render, cleanup, fireEvent, act} from '@testing-library/react'
22
import '@testing-library/jest-dom'
33
import {axe, toHaveNoViolations} from 'jest-axe'
44

@@ -40,17 +40,99 @@ const MockAnchorNavFixture = ({data = mockData, withSecondAction = false, ...res
4040
}
4141

4242
describe('AnchorNav', () => {
43-
afterEach(cleanup)
43+
let mockIntersectionObserver: jest.Mock
44+
let addEventListenerSpy: jest.SpyInstance
45+
let scrollListener: EventListener | null
46+
47+
const setScrollPosition = (position: number) => {
48+
Object.defineProperty(window, 'pageYOffset', {
49+
writable: true,
50+
configurable: true,
51+
value: position,
52+
})
53+
}
54+
55+
const triggerScrollEvent = async () => {
56+
if (scrollListener) {
57+
scrollListener(new Event('scroll'))
58+
}
59+
}
60+
61+
const simulateNavBecomingSticky = async (navYPosition: number, scrollToPosition: number) => {
62+
await act(async () => {
63+
triggerObserverByRootMargin('0px 0px -100%', {
64+
boundingClientRect: mockRect(navYPosition),
65+
isIntersecting: true,
66+
})
67+
})
68+
69+
await act(async () => {
70+
setScrollPosition(scrollToPosition)
71+
await triggerScrollEvent()
72+
})
73+
}
74+
75+
const mockRect = (y = 100): DOMRectReadOnly => ({
76+
y,
77+
top: y,
78+
bottom: y + 50,
79+
left: 0,
80+
right: 0,
81+
width: 0,
82+
height: 50,
83+
x: 0,
84+
toJSON: () => ({}),
85+
})
86+
87+
const triggerObserverByRootMargin = (rootMargin: string, entry: Partial<IntersectionObserverEntry>) => {
88+
const observerCall = mockIntersectionObserver.mock.calls.find(call => {
89+
const options = call[1]
90+
return options && options.rootMargin === rootMargin
91+
})
92+
93+
if (observerCall) {
94+
const [callback] = observerCall
95+
const mockEntry = {
96+
isIntersecting: true,
97+
boundingClientRect: mockRect(),
98+
...entry,
99+
}
100+
callback([mockEntry])
101+
}
102+
}
44103

45104
beforeEach(() => {
46-
// IntersectionObserver isn't available in test environment
47-
const mockIntersectionObserver = jest.fn()
105+
scrollListener = null
106+
107+
mockIntersectionObserver = jest.fn()
48108
mockIntersectionObserver.mockReturnValue({
49-
observe: () => null,
50-
unobserve: () => null,
51-
disconnect: () => null,
109+
observe: jest.fn(),
110+
unobserve: jest.fn(),
111+
disconnect: jest.fn(),
52112
})
53113
window.IntersectionObserver = mockIntersectionObserver
114+
115+
Object.defineProperty(window, 'pageYOffset', {
116+
writable: true,
117+
configurable: true,
118+
value: 0,
119+
})
120+
121+
addEventListenerSpy = jest
122+
.spyOn(window, 'addEventListener')
123+
.mockImplementation((type: string, listener: EventListenerOrEventListenerObject) => {
124+
if (type === 'scroll') {
125+
scrollListener = typeof listener === 'function' ? listener : listener.handleEvent
126+
}
127+
})
128+
})
129+
130+
afterEach(() => {
131+
cleanup()
132+
133+
addEventListenerSpy.mockRestore()
134+
135+
jest.clearAllMocks()
54136
})
55137

56138
it('renders the root element correctly into the document', () => {
@@ -108,15 +190,13 @@ describe('AnchorNav', () => {
108190
const {getByTestId} = render(<MockAnchorNavFixture />)
109191
const actionEl = getByTestId(AnchorNav.testIds.action)
110192
expect(actionEl).toBeInTheDocument() // renders
111-
expect(actionEl).toBeInTheDocument() // renders as an anchor
112193
expect(actionEl).toHaveAttribute('href', '#') // renders with correct href
113194
})
114195

115196
it('renders an secondary correctly', () => {
116197
const {getByTestId} = render(<MockAnchorNavFixture withSecondAction />)
117198
const secondaryActionEl = getByTestId(AnchorNav.testIds.secondaryAction)
118199
expect(secondaryActionEl).toBeInTheDocument() // renders
119-
expect(secondaryActionEl).toBeInTheDocument() // renders as an anchor
120200
expect(secondaryActionEl).toHaveAttribute('href', '#') // renders with correct href
121201
})
122202

@@ -158,4 +238,32 @@ describe('AnchorNav', () => {
158238

159239
expect(results).toHaveNoViolations()
160240
})
241+
242+
it('shows an equivalent height spacer when nav is sticky and hides it when not', async () => {
243+
const MockPage = () => (
244+
<div style={{height: '200vh'}}>
245+
<MockAnchorNavFixture />
246+
<div style={{height: '100vh'}}>After nav</div>
247+
</div>
248+
)
249+
250+
const {getByTestId, queryByTestId} = render(<MockPage />)
251+
const rootEl = getByTestId(AnchorNav.testIds.root)
252+
const navPosition = 100
253+
254+
expect(rootEl).not.toHaveClass('AnchorNav--stuck')
255+
expect(queryByTestId(AnchorNav.testIds.navSpacer)).not.toBeInTheDocument()
256+
257+
await simulateNavBecomingSticky(navPosition, navPosition + 50)
258+
expect(rootEl).toHaveClass('AnchorNav--stuck')
259+
expect(getByTestId(AnchorNav.testIds.navSpacer)).toBeInTheDocument()
260+
261+
await act(async () => {
262+
setScrollPosition(navPosition - 50)
263+
await triggerScrollEvent()
264+
})
265+
266+
expect(rootEl).not.toHaveClass('AnchorNav--stuck')
267+
expect(queryByTestId(AnchorNav.testIds.navSpacer)).not.toBeInTheDocument()
268+
})
161269
})

packages/react/src/AnchorNav/AnchorNav.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ const testIds = {
3232
get secondaryAction() {
3333
return `${this.root}-secondary-action`
3434
},
35+
get navSpacer() {
36+
return `${this.root}-nav-spacer`
37+
},
3538
}
3639

3740
export type AnchorNavProps = BaseProps<HTMLElement> & {
@@ -56,6 +59,7 @@ function _AnchorNav({children, enableDefaultBgColor = false, hideUntilSticky = f
5659
const [intersectionEntry, setIntersectionEntry] = useState<IntersectionObserverEntry>()
5760
const [initialYOffset, setInitialYOffset] = useState<undefined | number>()
5861
const [navShouldFix, setNavShouldFix] = useState<boolean>(false)
62+
const [navHeight, setNavHeight] = useState<number>(0)
5963

6064
const wrapperRef = useRef<HTMLDivElement | null>(null)
6165
const rootRef = useRef<HTMLElement | null>(null)
@@ -88,6 +92,13 @@ function _AnchorNav({children, enableDefaultBgColor = false, hideUntilSticky = f
8892
useKeyboardEscape(closeMenuCallback)
8993
useExpandedMenu(menuOpen, linkContainerRef, !isLarge)
9094

95+
useEffect(() => {
96+
if (wrapperRef.current) {
97+
const height = wrapperRef.current.offsetHeight
98+
setNavHeight(height)
99+
}
100+
}, [isLarge])
101+
91102
useEffect(() => {
92103
const queryResult = window.matchMedia('(prefers-reduced-motion: reduce)')
93104

@@ -271,6 +282,7 @@ function _AnchorNav({children, enableDefaultBgColor = false, hideUntilSticky = f
271282
aria-hidden
272283
/>
273284
</nav>
285+
{navShouldFix && <div style={{height: navHeight}} aria-hidden="true" data-testid={testIds.navSpacer} />}
274286
</div>
275287
)
276288
}
Loading
Loading
Loading
Loading
Loading
Loading

0 commit comments

Comments
 (0)