Skip to content
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ We've made fixes to NHS.UK frontend in the following pull requests:
- [#1148: Fix Tabs component in Safari < 14 and Internet Explorer 11](https://github.com/nhsuk/nhsuk-frontend/pull/1148)
- [#908: Updating secondary, reverse and warning buttons to use their hover variable rather than darkening the base colour](https://github.com/nhsuk/nhsuk-frontend/pull/908)
- [#1169: Update visually hidden behaviour to match GOV.UK Frontend](https://github.com/nhsuk/nhsuk-frontend/pull/1169)
- [#1173: Focus skip link target to improve screen reader announcements](https://github.com/nhsuk/nhsuk-frontend/pull/1173)

## 9.3.0 - 13 February 2025

Expand Down
56 changes: 56 additions & 0 deletions packages/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,59 @@ export const toggleConditionalInput = (input, className) => {
}
}
}

/**
* Move focus to element
*
* Sets tabindex to -1 to make the element programmatically focusable,
* but removes it on blur as the element doesn't need to be focused again.
*
* Original code taken from GDS (Government Digital Service)
* {@link https://github.com/alphagov/govuk-frontend}
*
* @template {HTMLElement} FocusElement
* @param {FocusElement} $element - HTML element
* @param {object} [options] - Handler options
* @param {function(this: FocusElement): void} [options.onBeforeFocus] - Callback before focus
* @param {function(this: FocusElement): void} [options.onBlur] - Callback on blur
*/
export function setFocus($element, options = {}) {
const isFocusable = $element.getAttribute('tabindex')

if (!isFocusable) {
$element.setAttribute('tabindex', '-1')
}

/**
* Handle element focus
*/
function onFocus() {
$element.removeEventListener('focus', onFocus)
$element.addEventListener('blur', onBlur)
}

/**
* Handle element blur
*/
function onBlur() {
$element.removeEventListener('blur', onBlur)

if (options.onBlur) {
options.onBlur.call($element)
}

if (!isFocusable) {
$element.removeAttribute('tabindex')
}
}

// Add listener to reset element on blur, after focus
$element.addEventListener('focus', onFocus)

// Focus element
if (options.onBeforeFocus) {
options.onBeforeFocus.call($element)
}

$element.focus()
}
29 changes: 17 additions & 12 deletions packages/components/skip-link/_skip-link.scss
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
////
/// Skip link component
///
/// 1. Hides the skip link off the page,
/// 2. until the link gains focus from keyboard tabbing.
/// 1. Hide until the skip link gains focus from keyboard tabbing.
///
/// @group components
////

.nhsuk-skip-link {
left: -9999px; // [1]
padding: nhsuk-spacing(2);
position: absolute;
left: nhsuk-spacing(3);
top: nhsuk-spacing(3);
padding: nhsuk-spacing(2);
@include visually-hidden-focusable; // [1]
}

&:active,
.nhsuk-skip-link-focused-element {
&:focus {
left: nhsuk-spacing(3); // [2]
top: nhsuk-spacing(3);
z-index: 2;
}

&:visited {
color: $color_nhsuk-black;
// Remove the native visible focus indicator when the element is
// programmatically focused.
//
// We set the focus on the linked element (this is usually the <main>
// element) when the skip link is activated to improve screen reader
// announcements. However, we remove the visible focus indicator from the
// linked element because the user cannot interact with it.
//
// A related discussion: https://github.com/w3c/wcag/issues/1001
outline: none;
}
}
48 changes: 32 additions & 16 deletions packages/components/skip-link/skip-link.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { setFocus } from '../../common.js'

/*
* NHS.UK skip link.
*
Expand All @@ -6,23 +8,37 @@
*/

export default () => {
// Assign required DOM elements
const heading = document.querySelector('h1')
const skipLink = document.querySelector('.nhsuk-skip-link')
const $skipLink = document.querySelector('.nhsuk-skip-link')

const addEvents = () => {
// Add tabindex = -1 and apply focus to heading on skip link click
skipLink.addEventListener('click', (event) => {
event.preventDefault()
heading.setAttribute('tabIndex', '-1')
heading.focus()
})
// Remove tabindex from heading on blur
heading.addEventListener('blur', (event) => {
event.preventDefault()
heading.removeAttribute('tabIndex')
})
// Check for skip link
if (!$skipLink || !($skipLink instanceof HTMLAnchorElement)) {
return
}

if (heading && skipLink) addEvents()
const linkedElementId = $skipLink.hash.split('#').pop()
const $linkedElement = linkedElementId
? document.getElementById(linkedElementId)
: null

// Check for linked element
if (!$linkedElement) {
return
}

/**
* Focus the linked element on click
*
* Adds a helper CSS class to hide native focus styles,
* but removes it on blur to restore native focus styles
*/
$skipLink.addEventListener('click', () =>
setFocus($linkedElement, {
onBeforeFocus() {
$linkedElement.classList.add('nhsuk-skip-link-focused-element')
},
onBlur() {
$linkedElement.classList.remove('nhsuk-skip-link-focused-element')
}
})
)
}
15 changes: 15 additions & 0 deletions packages/core/tools/_mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@
}
}

/// Hide an element visually, but have it available for screen readers whilst
/// allowing the element to be focused when navigated to via the keyboard (e.g.
/// for the skip link)
///
/// @param {Boolean} $important [true] - Whether to mark as `!important`
///
/// @link https://github.com/alphagov/govuk-frontend Original code taken from GDS (Government Digital Service)

@mixin visually-hidden-focusable($important: true) {
// IE 11 doesn't support the combined `:not(:active, :focus)` syntax.
&:not(:active):not(:focus) {
@include _nhsuk-visually-hide-content($important: $important);
}
}

/// Show an element visually that has previously been hidden by visually-hidden
///
/// For differences between mobile and desktop views, use $display to set the CSS display property
Expand Down
4 changes: 4 additions & 0 deletions packages/core/utilities/_visually-hidden.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@
.nhsuk-u-visually-hidden {
@include visually-hidden;
}

.nhsuk-u-visually-hidden-focusable {
@include visually-hidden-focusable;
}
71 changes: 41 additions & 30 deletions tests/integration/jsdom/skip-link.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,71 +3,82 @@ import SkipLink from '../../../packages/components/skip-link/skip-link.js'
// Mock HTML
const skipLinkHtml =
'<a class="nhsuk-skip-link" href="#maincontent">Skip to main content</a>'
const headingHtml = '<h1>Test Heading</h1>'
const mainHtml = '<main class="nhsuk-main-wrapper" id="maincontent"></main>'

// DOM Elements to be set
let skipLink
let heading
/** @type {HTMLElement | null} */
let main = null

/** @type {HTMLAnchorElement | null} */
let skipLink = null

// Helper to set DOM Elements
const initTest = (html = '') => {
document.body.innerHTML = html
heading = document.querySelector('h1')
skipLink = document.querySelector('.nhsuk-skip-link')
main = document.querySelector('main')
SkipLink()
}

describe('NHS.UK skiplink', () => {
describe('Does not throw an error', () => {
it('if no skiplink exists', () => {
initTest(headingHtml)
expect(heading).toBeDefined()
initTest(mainHtml)
expect(skipLink).toBeNull()
expect(main).toBeDefined()
})

it('if no heading exists', () => {
it('if no main content exists', () => {
initTest(skipLinkHtml)
expect(skipLink).toBeDefined()
expect(heading).toBeNull()
expect(main).toBeNull()
})

it('if no skiplink or heading exists', () => {
it('if no skiplink or main content exists', () => {
initTest()
expect(heading).toBeNull()
expect(skipLink).toBeNull()
expect(main).toBeNull()
})
})

describe('Focuses the heading on skiplink click', () => {
it('if skiplink and heading elements exist', () => {
initTest(skipLinkHtml + headingHtml)
expect(skipLink).toBeDefined()
expect(heading).toBeDefined()
describe('Focuses main content on skiplink click', () => {
it('if skiplink and main element exist', () => {
initTest(skipLinkHtml + mainHtml)

// Main content not focused
expect(main).not.toEqual(document.activeElement)
expect(main.getAttribute('tabIndex')).toBeNull()
expect(main.classList.value).not.toContain(
'nhsuk-skip-link-focused-element'
)

skipLink.click()

expect(heading.getAttribute('tabIndex')).toBe('-1')
expect(document.activeElement).toEqual(heading)
// Main content focused
expect(main).toEqual(document.activeElement)
expect(main.getAttribute('tabIndex')).toBe('-1')
expect(main.classList.value).toContain('nhsuk-skip-link-focused-element')
})
})

describe('Unfocuses the heading on blur', () => {
it('if skiplink and heading elements exist', () => {
initTest(skipLinkHtml + headingHtml)
expect(skipLink).toBeDefined()
expect(heading).toBeDefined()
describe('Unfocuses main content on blur', () => {
it('if skiplink and main element exist', () => {
initTest(skipLinkHtml + mainHtml)

skipLink.click()

expect(heading.getAttribute('tabIndex')).toBe('-1')
expect(document.activeElement).toEqual(heading)
// Main content focused
expect(main).toEqual(document.activeElement)
expect(main.getAttribute('tabIndex')).toBe('-1')
expect(main.classList.value).toContain('nhsuk-skip-link-focused-element')

heading.blur()
main.blur()

expect(heading.getAttribute('tabIndex')).toBeNull()
expect(document.activeElement).not.toEqual(heading)
// Main content not focused
expect(main).not.toEqual(document.activeElement)
expect(main.getAttribute('tabIndex')).toBeNull()
expect(main.classList.value).not.toContain(
'nhsuk-skip-link-focused-element'
)
})
})

describe('Is initialised by nhsuk.js', () => {})
})