Skip to content

Commit 8c987fb

Browse files
authored
feat: add skip to content link for keyboard-first visitors (#347)
This PR reattempts #212 and adds [a skip navigation link](https://webaim.org/techniques/skipnav/) to the home and page templates. ## AI summary This pull request introduces a new accessibility feature, a "Skip to content" link, for improved navigation by keyboard-first users. It also includes related updates to the codebase, tests, and documentation. Below is a breakdown of the most important changes grouped by theme. ### Accessibility Feature: "Skip to Content" Link * Added `SkipNavLink` and `SkipNavContent` components from the `@reach/skip-nav` library to the `Layout` and `HomeTemplate` components, enabling users to skip directly to the main content. (`theme/src/components/layout.js`, `theme/src/templates/home.js`) [[1]](diffhunk://#diff-3b0e5bbe71b45a4c9eb24943876bed9538777aab09e2dfb633505da858b62828R5) [[2]](diffhunk://#diff-3b0e5bbe71b45a4c9eb24943876bed9538777aab09e2dfb633505da858b62828R36) [[3]](diffhunk://#diff-3b0e5bbe71b45a4c9eb24943876bed9538777aab09e2dfb633505da858b62828L43-R52) [[4]](diffhunk://#diff-95bf18ccdcf26fde4d5f491935d8fc68116d08389b056b905ffd5f23a11065b0R4) [[5]](diffhunk://#diff-95bf18ccdcf26fde4d5f491935d8fc68116d08389b056b905ffd5f23a11065b0R37) * Updated styles by importing `@reach/skip-nav/styles.css` in `gatsby-browser.js`. (`theme/gatsby-browser.js`) ### Navigation Behavior Enhancements * Implemented an `onRouteUpdate` function to focus the main content (`SkipNavContent`) after a route change, improving accessibility for keyboard users. (`theme/gatsby-browser.js`) ### Testing * Added comprehensive unit tests for `shouldUpdateScroll` and `onRouteUpdate` functions, including mock implementations for DOM manipulation. (`theme/gatsby-browser.spec.js`) ### Documentation and Versioning * Updated the `CHANGELOG.md` to document the new feature in version `0.45.0`. (`CHANGELOG.md`) * Updated the `package.json` version to `0.45.0` and added `@reach/skip-nav` as a dependency. (`theme/package.json`) [[1]](diffhunk://#diff-864251b807b1925eadafb797a61b4fb855065215fc4e7129a00ec23a2dd4ed72L4-R4) [[2]](diffhunk://#diff-864251b807b1925eadafb797a61b4fb855065215fc4e7129a00ec23a2dd4ed72R69) ### Snapshot Updates * Updated Jest snapshots to reflect the addition of the "Skip to content" link and the `SkipNavContent` element. (`theme/src/components/__snapshots__/layout.spec.js.snap`) [[1]](diffhunk://#diff-ff59f0caf88f27fbeded37a7967b7b210c0a1ae8b52ba16f03c69763b1933551L1-R1) [[2]](diffhunk://#diff-ff59f0caf88f27fbeded37a7967b7b210c0a1ae8b52ba16f03c69763b1933551R46-R52) [[3]](diffhunk://#diff-ff59f0caf88f27fbeded37a7967b7b210c0a1ae8b52ba16f03c69763b1933551R64-R67)
1 parent 11dc51c commit 8c987fb

File tree

9 files changed

+365
-3
lines changed

9 files changed

+365
-3
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 0.45.0
4+
5+
- Adds a new "Skip to content" link for keyboard-first visitors, allowing them to TAB once on the page and then skip to the <main> content.
6+
37
## 0.44.4
48

59
- Updates Steam widget "Recently-Played Games" to reflect MINUTES for games played.

theme/gatsby-browser.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'lightgallery/css/lightgallery.css'
77
import 'lightgallery/css/lg-thumbnail.css'
88
import 'lightgallery/css/lg-zoom.css'
99
import 'prismjs/themes/prism-solarizedlight.css'
10+
import '@reach/skip-nav/styles.css' //this will show/hide the link on focus
1011

1112
export { default as wrapRootElement } from './wrapRootElement'
1213

@@ -26,3 +27,13 @@ export const shouldUpdateScroll = ({ routerProps }) => {
2627
// For actual page changes, use default scroll behavior
2728
return true
2829
}
30+
31+
// See https://fossies.org/linux/gatsby/examples/using-reach-skip-nav/README.md
32+
export const onRouteUpdate = ({ prevLocation }) => {
33+
if (prevLocation !== null) {
34+
const skipContent = document.querySelector('[data-reach-skip-nav-content]') // Comes with the <SkipNavContent> component.
35+
if (skipContent) {
36+
skipContent.focus()
37+
}
38+
}
39+
}

theme/gatsby-browser.spec.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { shouldUpdateScroll, onRouteUpdate } from './gatsby-browser'
2+
3+
// Mock document methods
4+
const mockQuerySelector = jest.fn()
5+
const mockFocus = jest.fn()
6+
7+
// Mock DOM element
8+
const mockSkipContent = {
9+
focus: mockFocus
10+
}
11+
12+
// Setup document mock by overriding the querySelector method
13+
beforeEach(() => {
14+
jest.clearAllMocks()
15+
// Mock the querySelector method on the existing document
16+
document.querySelector = mockQuerySelector
17+
})
18+
19+
// Restore the original querySelector after tests
20+
afterEach(() => {
21+
delete document.querySelector
22+
})
23+
24+
describe('gatsby-browser', () => {
25+
describe('shouldUpdateScroll', () => {
26+
it('should return true when routerProps is undefined', () => {
27+
const result = shouldUpdateScroll({})
28+
expect(result).toBe(true)
29+
})
30+
31+
it('should return true when routerProps is null', () => {
32+
const result = shouldUpdateScroll({ routerProps: null })
33+
expect(result).toBe(true)
34+
})
35+
36+
it('should return false when only query parameters change', () => {
37+
const routerProps = {
38+
location: { pathname: '/blog', search: '?page=2' },
39+
prevLocation: { pathname: '/blog', search: '?page=1' }
40+
}
41+
const result = shouldUpdateScroll({ routerProps })
42+
expect(result).toBe(false)
43+
})
44+
45+
it('should return true when pathname changes', () => {
46+
const routerProps = {
47+
location: { pathname: '/about', search: '' },
48+
prevLocation: { pathname: '/blog', search: '' }
49+
}
50+
const result = shouldUpdateScroll({ routerProps })
51+
expect(result).toBe(true)
52+
})
53+
54+
it('should return true when prevLocation is null', () => {
55+
const routerProps = {
56+
location: { pathname: '/blog', search: '' },
57+
prevLocation: null
58+
}
59+
const result = shouldUpdateScroll({ routerProps })
60+
expect(result).toBe(true)
61+
})
62+
63+
it('should return true when prevLocation is undefined', () => {
64+
const routerProps = {
65+
location: { pathname: '/blog', search: '' },
66+
prevLocation: undefined
67+
}
68+
const result = shouldUpdateScroll({ routerProps })
69+
expect(result).toBe(true)
70+
})
71+
})
72+
73+
describe('onRouteUpdate', () => {
74+
it('should not call focus when prevLocation is null', () => {
75+
onRouteUpdate({ prevLocation: null })
76+
expect(mockQuerySelector).not.toHaveBeenCalled()
77+
expect(mockFocus).not.toHaveBeenCalled()
78+
})
79+
80+
it('should call focus when prevLocation is undefined', () => {
81+
mockQuerySelector.mockReturnValue(mockSkipContent)
82+
83+
onRouteUpdate({ prevLocation: undefined })
84+
85+
expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
86+
expect(mockFocus).toHaveBeenCalled()
87+
})
88+
89+
it('should call focus when skip content element exists', () => {
90+
mockQuerySelector.mockReturnValue(mockSkipContent)
91+
92+
onRouteUpdate({ prevLocation: { pathname: '/previous' } })
93+
94+
expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
95+
expect(mockFocus).toHaveBeenCalled()
96+
})
97+
98+
it('should not call focus when skip content element does not exist', () => {
99+
mockQuerySelector.mockReturnValue(null)
100+
101+
onRouteUpdate({ prevLocation: { pathname: '/previous' } })
102+
103+
expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
104+
expect(mockFocus).not.toHaveBeenCalled()
105+
})
106+
107+
it('should handle when querySelector returns undefined', () => {
108+
mockQuerySelector.mockReturnValue(undefined)
109+
110+
onRouteUpdate({ prevLocation: { pathname: '/previous' } })
111+
112+
expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
113+
expect(mockFocus).not.toHaveBeenCalled()
114+
})
115+
})
116+
})

theme/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "gatsby-theme-chrisvogt",
33
"description": "My personal blog and website.",
4-
"version": "0.44.4",
4+
"version": "0.45.0",
55
"author": "Chris Vogt <mail@chrisvogt.me> (https://www.chrisvogt.me)",
66
"main": "index.js",
77
"license": "MIT",
@@ -66,6 +66,7 @@
6666
"@mdx-js/loader": "^3.0.1",
6767
"@mdx-js/mdx": "^3.0.1",
6868
"@mdx-js/react": "^3.0.1",
69+
"@reach/skip-nav": "^0.18.0",
6970
"@reduxjs/toolkit": "^2.8.2",
7071
"@theme-ui/color": "^0.17.2",
7172
"@theme-ui/components": "^0.17.2",

theme/src/components/__snapshots__/layout.spec.js.snap

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,78 @@
1-
// Jest Snapshot v1, https://goo.gl/fbAQLP
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`Layout hides header when hideHeader is true 1`] = `
4+
<div
5+
className="css-ez1gh8"
6+
>
7+
<div
8+
style={
9+
{
10+
"bottom": 0,
11+
"left": 0,
12+
"position": "absolute",
13+
"right": 0,
14+
"top": 0,
15+
}
16+
}
17+
>
18+
<canvas
19+
style={
20+
{
21+
"display": "block",
22+
"height": "100%",
23+
"left": 0,
24+
"position": "absolute",
25+
"top": 0,
26+
"width": "100%",
27+
}
28+
}
29+
/>
30+
<div
31+
style={
32+
{
33+
"WebkitBackdropFilter": "blur(75px)",
34+
"backdropFilter": "blur(75px)",
35+
"backgroundColor": "rgba(253, 248, 245, 0.35)",
36+
"height": "100%",
37+
"left": 0,
38+
"pointerEvents": "none",
39+
"position": "absolute",
40+
"top": 0,
41+
"width": "100%",
42+
}
43+
}
44+
/>
45+
</div>
46+
<a
47+
data-reach-skip-link=""
48+
data-reach-skip-nav-link=""
49+
href="#reach-skip-nav"
50+
>
51+
Skip to content
52+
</a>
53+
<main
54+
role="main"
55+
>
56+
<div
57+
data-reach-skip-nav-content=""
58+
id="reach-skip-nav"
59+
/>
60+
<div
61+
className="fake-website"
62+
>
63+
<h1>
64+
Fake Website
65+
</h1>
66+
<p>
67+
Lorum ipsum dolor sit amet.
68+
</p>
69+
</div>
70+
</main>
71+
<div
72+
className="MOCK__Footer"
73+
/>
74+
</div>
75+
`;
276

377
exports[`Layout matches the snapshot 1`] = `
478
<div
@@ -43,6 +117,13 @@ exports[`Layout matches the snapshot 1`] = `
43117
}
44118
/>
45119
</div>
120+
<a
121+
data-reach-skip-link=""
122+
data-reach-skip-nav-link=""
123+
href="#reach-skip-nav"
124+
>
125+
Skip to content
126+
</a>
46127
<header
47128
className="css-l5xv05"
48129
role="banner"
@@ -54,6 +135,10 @@ exports[`Layout matches the snapshot 1`] = `
54135
<main
55136
role="main"
56137
>
138+
<div
139+
data-reach-skip-nav-content=""
140+
id="reach-skip-nav"
141+
/>
57142
<div
58143
className="fake-website"
59144
>
@@ -70,3 +155,77 @@ exports[`Layout matches the snapshot 1`] = `
70155
/>
71156
</div>
72157
`;
158+
159+
exports[`Layout renders children without main wrapper when disableMainWrapper is true 1`] = `
160+
<div
161+
className="css-ez1gh8"
162+
>
163+
<div
164+
style={
165+
{
166+
"bottom": 0,
167+
"left": 0,
168+
"position": "absolute",
169+
"right": 0,
170+
"top": 0,
171+
}
172+
}
173+
>
174+
<canvas
175+
style={
176+
{
177+
"display": "block",
178+
"height": "100%",
179+
"left": 0,
180+
"position": "absolute",
181+
"top": 0,
182+
"width": "100%",
183+
}
184+
}
185+
/>
186+
<div
187+
style={
188+
{
189+
"WebkitBackdropFilter": "blur(75px)",
190+
"backdropFilter": "blur(75px)",
191+
"backgroundColor": "rgba(253, 248, 245, 0.35)",
192+
"height": "100%",
193+
"left": 0,
194+
"pointerEvents": "none",
195+
"position": "absolute",
196+
"top": 0,
197+
"width": "100%",
198+
}
199+
}
200+
/>
201+
</div>
202+
<a
203+
data-reach-skip-link=""
204+
data-reach-skip-nav-link=""
205+
href="#reach-skip-nav"
206+
>
207+
Skip to content
208+
</a>
209+
<header
210+
className="css-l5xv05"
211+
role="banner"
212+
>
213+
<div
214+
className="MOCK__TopNavigation"
215+
/>
216+
</header>
217+
<div
218+
className="fake-website"
219+
>
220+
<h1>
221+
Fake Website
222+
</h1>
223+
<p>
224+
Lorum ipsum dolor sit amet.
225+
</p>
226+
</div>
227+
<div
228+
className="MOCK__Footer"
229+
/>
230+
</div>
231+
`;

theme/src/components/layout.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { jsx } from 'theme-ui'
33
import { useSelector } from 'react-redux'
44
import React from 'react'
5+
import { SkipNavLink, SkipNavContent } from '@reach/skip-nav'
56

67
import BackgroundPattern from './animated-background'
78
import Footer from './footer'
@@ -32,6 +33,7 @@ const Layout = ({ children, disableMainWrapper, hideHeader, hideFooter }) => {
3233
}}
3334
>
3435
<BackgroundPattern />
36+
<SkipNavLink />
3537

3638
{/* NOTE(chrisvogt): hide the top navigation on the home and 404 pages */}
3739
{!hideHeader && (
@@ -40,7 +42,14 @@ const Layout = ({ children, disableMainWrapper, hideHeader, hideFooter }) => {
4042
</header>
4143
)}
4244

43-
{disableMainWrapper ? children : <main role='main'>{children}</main>}
45+
{disableMainWrapper ? (
46+
children
47+
) : (
48+
<main role='main'>
49+
<SkipNavContent />
50+
{children}
51+
</main>
52+
)}
4453

4554
{!hideFooter && <Footer />}
4655
</div>

0 commit comments

Comments
 (0)