Skip to content

feat: add skip to content link for keyboard-first visitors #347

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.45.0

- 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.

## 0.44.4

- Updates Steam widget "Recently-Played Games" to reflect MINUTES for games played.
Expand Down
11 changes: 11 additions & 0 deletions theme/gatsby-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'lightgallery/css/lightgallery.css'
import 'lightgallery/css/lg-thumbnail.css'
import 'lightgallery/css/lg-zoom.css'
import 'prismjs/themes/prism-solarizedlight.css'
import '@reach/skip-nav/styles.css' //this will show/hide the link on focus

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

Expand All @@ -26,3 +27,13 @@ export const shouldUpdateScroll = ({ routerProps }) => {
// For actual page changes, use default scroll behavior
return true
}

// See https://fossies.org/linux/gatsby/examples/using-reach-skip-nav/README.md
export const onRouteUpdate = ({ prevLocation }) => {
if (prevLocation !== null) {
const skipContent = document.querySelector('[data-reach-skip-nav-content]') // Comes with the <SkipNavContent> component.
if (skipContent) {
skipContent.focus()
}
}
}
116 changes: 116 additions & 0 deletions theme/gatsby-browser.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { shouldUpdateScroll, onRouteUpdate } from './gatsby-browser'

// Mock document methods
const mockQuerySelector = jest.fn()
const mockFocus = jest.fn()

// Mock DOM element
const mockSkipContent = {
focus: mockFocus
}

// Setup document mock by overriding the querySelector method
beforeEach(() => {
jest.clearAllMocks()
// Mock the querySelector method on the existing document
document.querySelector = mockQuerySelector
})

// Restore the original querySelector after tests
afterEach(() => {
delete document.querySelector
})

describe('gatsby-browser', () => {
describe('shouldUpdateScroll', () => {
it('should return true when routerProps is undefined', () => {
const result = shouldUpdateScroll({})
expect(result).toBe(true)
})

it('should return true when routerProps is null', () => {
const result = shouldUpdateScroll({ routerProps: null })
expect(result).toBe(true)
})

it('should return false when only query parameters change', () => {
const routerProps = {
location: { pathname: '/blog', search: '?page=2' },
prevLocation: { pathname: '/blog', search: '?page=1' }
}
const result = shouldUpdateScroll({ routerProps })
expect(result).toBe(false)
})

it('should return true when pathname changes', () => {
const routerProps = {
location: { pathname: '/about', search: '' },
prevLocation: { pathname: '/blog', search: '' }
}
const result = shouldUpdateScroll({ routerProps })
expect(result).toBe(true)
})

it('should return true when prevLocation is null', () => {
const routerProps = {
location: { pathname: '/blog', search: '' },
prevLocation: null
}
const result = shouldUpdateScroll({ routerProps })
expect(result).toBe(true)
})

it('should return true when prevLocation is undefined', () => {
const routerProps = {
location: { pathname: '/blog', search: '' },
prevLocation: undefined
}
const result = shouldUpdateScroll({ routerProps })
expect(result).toBe(true)
})
})

describe('onRouteUpdate', () => {
it('should not call focus when prevLocation is null', () => {
onRouteUpdate({ prevLocation: null })
expect(mockQuerySelector).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
})

it('should call focus when prevLocation is undefined', () => {
mockQuerySelector.mockReturnValue(mockSkipContent)

onRouteUpdate({ prevLocation: undefined })

expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
expect(mockFocus).toHaveBeenCalled()
})

it('should call focus when skip content element exists', () => {
mockQuerySelector.mockReturnValue(mockSkipContent)

onRouteUpdate({ prevLocation: { pathname: '/previous' } })

expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
expect(mockFocus).toHaveBeenCalled()
})

it('should not call focus when skip content element does not exist', () => {
mockQuerySelector.mockReturnValue(null)

onRouteUpdate({ prevLocation: { pathname: '/previous' } })

expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
expect(mockFocus).not.toHaveBeenCalled()
})

it('should handle when querySelector returns undefined', () => {
mockQuerySelector.mockReturnValue(undefined)

onRouteUpdate({ prevLocation: { pathname: '/previous' } })

expect(mockQuerySelector).toHaveBeenCalledWith('[data-reach-skip-nav-content]')
expect(mockFocus).not.toHaveBeenCalled()
})
})
})
3 changes: 2 additions & 1 deletion theme/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "gatsby-theme-chrisvogt",
"description": "My personal blog and website.",
"version": "0.44.4",
"version": "0.45.0",
"author": "Chris Vogt <mail@chrisvogt.me> (https://www.chrisvogt.me)",
"main": "index.js",
"license": "MIT",
Expand Down Expand Up @@ -66,6 +66,7 @@
"@mdx-js/loader": "^3.0.1",
"@mdx-js/mdx": "^3.0.1",
"@mdx-js/react": "^3.0.1",
"@reach/skip-nav": "^0.18.0",
"@reduxjs/toolkit": "^2.8.2",
"@theme-ui/color": "^0.17.2",
"@theme-ui/components": "^0.17.2",
Expand Down
161 changes: 160 additions & 1 deletion theme/src/components/__snapshots__/layout.spec.js.snap
Original file line number Diff line number Diff line change
@@ -1,4 +1,78 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`Layout hides header when hideHeader is true 1`] = `
<div
className="css-ez1gh8"
>
<div
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<canvas
style={
{
"display": "block",
"height": "100%",
"left": 0,
"position": "absolute",
"top": 0,
"width": "100%",
}
}
/>
<div
style={
{
"WebkitBackdropFilter": "blur(75px)",
"backdropFilter": "blur(75px)",
"backgroundColor": "rgba(253, 248, 245, 0.35)",
"height": "100%",
"left": 0,
"pointerEvents": "none",
"position": "absolute",
"top": 0,
"width": "100%",
}
}
/>
</div>
<a
data-reach-skip-link=""
data-reach-skip-nav-link=""
href="#reach-skip-nav"
>
Skip to content
</a>
<main
role="main"
>
<div
data-reach-skip-nav-content=""
id="reach-skip-nav"
/>
<div
className="fake-website"
>
<h1>
Fake Website
</h1>
<p>
Lorum ipsum dolor sit amet.
</p>
</div>
</main>
<div
className="MOCK__Footer"
/>
</div>
`;

exports[`Layout matches the snapshot 1`] = `
<div
Expand Down Expand Up @@ -43,6 +117,13 @@ exports[`Layout matches the snapshot 1`] = `
}
/>
</div>
<a
data-reach-skip-link=""
data-reach-skip-nav-link=""
href="#reach-skip-nav"
>
Skip to content
</a>
<header
className="css-l5xv05"
role="banner"
Expand All @@ -54,6 +135,10 @@ exports[`Layout matches the snapshot 1`] = `
<main
role="main"
>
<div
data-reach-skip-nav-content=""
id="reach-skip-nav"
/>
<div
className="fake-website"
>
Expand All @@ -70,3 +155,77 @@ exports[`Layout matches the snapshot 1`] = `
/>
</div>
`;

exports[`Layout renders children without main wrapper when disableMainWrapper is true 1`] = `
<div
className="css-ez1gh8"
>
<div
style={
{
"bottom": 0,
"left": 0,
"position": "absolute",
"right": 0,
"top": 0,
}
}
>
<canvas
style={
{
"display": "block",
"height": "100%",
"left": 0,
"position": "absolute",
"top": 0,
"width": "100%",
}
}
/>
<div
style={
{
"WebkitBackdropFilter": "blur(75px)",
"backdropFilter": "blur(75px)",
"backgroundColor": "rgba(253, 248, 245, 0.35)",
"height": "100%",
"left": 0,
"pointerEvents": "none",
"position": "absolute",
"top": 0,
"width": "100%",
}
}
/>
</div>
<a
data-reach-skip-link=""
data-reach-skip-nav-link=""
href="#reach-skip-nav"
>
Skip to content
</a>
<header
className="css-l5xv05"
role="banner"
>
<div
className="MOCK__TopNavigation"
/>
</header>
<div
className="fake-website"
>
<h1>
Fake Website
</h1>
<p>
Lorum ipsum dolor sit amet.
</p>
</div>
<div
className="MOCK__Footer"
/>
</div>
`;
11 changes: 10 additions & 1 deletion theme/src/components/layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { jsx } from 'theme-ui'
import { useSelector } from 'react-redux'
import React from 'react'
import { SkipNavLink, SkipNavContent } from '@reach/skip-nav'

import BackgroundPattern from './animated-background'
import Footer from './footer'
Expand Down Expand Up @@ -32,6 +33,7 @@ const Layout = ({ children, disableMainWrapper, hideHeader, hideFooter }) => {
}}
>
<BackgroundPattern />
<SkipNavLink />

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

{disableMainWrapper ? children : <main role='main'>{children}</main>}
{disableMainWrapper ? (
children
) : (
<main role='main'>
<SkipNavContent />
{children}
</main>
)}

{!hideFooter && <Footer />}
</div>
Expand Down
Loading