Skip to content
Closed
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,15 @@ Tags now have a 1px border, with the colour based on the background colour of th

We made this change in [pull request #6379: Add borders to tags](https://github.com/alphagov/govuk-frontend/pull/6379).

#### Service Navigation's menu `<button>` no longer has an `aria-controls` attribute before JavaScript initialises

We've removed the `aria-controls` attribute set on the hidden `<button>` element used for the Service Navigation's mobile menu
before the component's JavaScript is initialised.

The attribute was keeping the button accessible in VoiceOver's rotor on Desktop and focusable on iPadOS. You should now use the `data-aria-controls` attribute to reference the id of the list of navigation links.

We made this change in [pull request #6342: Fix VoiceOver access to hidden Service Navigation menu button](https://github.com/alphagov/govuk-frontend/pull/6342)

#### Other fixes

We've made fixes to GOV.UK Frontend in the following pull requests:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,13 @@ export class ServiceNavigation extends Component {
return this
}

const menuId = $menuButton.getAttribute('aria-controls')
const menuId = $menuButton.getAttribute('data-aria-controls')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When testing locally, if you remove data-aria-controls then the class aborts early as it can't find the button, meaning the button doesn't show and the show/hide functionality doesn't get applied.

Could we also check for aria-controls still to avoid this being a breaking change?

Suggested change
const menuId = $menuButton.getAttribute('data-aria-controls')
const menuId = $menuButton.getAttribute('data-aria-controls') ?? $menuButton.getAttribute('aria-controls')

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aria-controls is unfortunately the source of the issue, as it's what makes VoiceOver still maintain access to the button even when it has the hidden attribute. This means the button cannot have the aria-controls attribute in the HTML.

I'd like to propose a different approach where the button is not in the HTML at all and injected by JavaScript, using the ID of the menu to fill aria-controls. However, that's a whole separate piece of work.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortuantely unless we're willing to release that breakage and stop the mobile menu working then we'll have to delay this being resolved 😕 It also means we can't backport this, but it sounds like we can't anyway since this either will break the design on mobile or won't actually solve the problem.

Sounds like this isn't backportable but we should make a call now on if we release it in v6 or not.

Copy link
Member Author

@romaricpascal romaricpascal Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the JavaScript change remains backportable if it uses aria-controls instead of the new data-aria-controls for v5. It's not as thorough a fix as for v6 as if JavaScript does not load, the hidden menu button is still accessible through Voice Over, but still an improvement on the situation that it gets hidden properly once JavaScript kicks in 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding v6, I guess our choices are:

  • implement your proposition to smoothen the upgrade path. Users who haven't upgraded their Service Navigation's HTML to use data-aria-controls would get a fix when JavaScript is loaded, but not if it doesn't load. Then in v7, we'd remove the support for aria-controls.
  • take advantage of the breaking release in v6.0, potentially preceeded by a 5.14 release to completely remove the support for the button having aria-controls

I'll bring this up at dev catch-up as we're once more in the blurry land of deprecations at the time of a breaking release.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to write a CHANGELOG for this to guide users' updates, I'm wondering if it'd be easier to remove the button than the attribute 😓

We could still ease the update path by checking if a button already exists, and backport to v5 with the same caveat that the fix is only when JavaScript has loaded.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing I'm still not 100% is if it's an incrimental improvement or if the fact that aria-controls will still be there in v5 means that it's almost not worth doing. Thinking about your comment above:

The aria-controls is unfortunately the source of the issue, as it's what makes VoiceOver still maintain access to the button even when it has the hidden attribute. This means the button cannot have the aria-controls attribute in the HTML.

For the purpose of having a way forward, my proposal is:

  • proceed with this change with the smoothed upgrade path as a fix that incriments the issue but doesn't solve it
  • backport as planned
  • pin a change to v7 to remove that button


if (!menuId) {
throw new ElementError({
component: ServiceNavigation,
identifier:
'Navigation button (`<button class="govuk-js-service-navigation-toggle">`) attribute (`aria-controls`)'
'Navigation button (`<button class="govuk-js-service-navigation-toggle">`) attribute (`data-aria-controls`)'
})
}

Expand All @@ -69,6 +70,7 @@ export class ServiceNavigation extends Component {

this.$menu = $menu
this.$menuButton = $menuButton
this.$menuButton.setAttribute('aria-controls', this.$menu.id)

this.setupResponsiveChecks()

Expand Down Expand Up @@ -125,9 +127,9 @@ export class ServiceNavigation extends Component {

if (this.mql.matches) {
this.$menu.removeAttribute('hidden')
this.$menuButton.setAttribute('hidden', '')
setAttributes(this.$menuButton, attributesForHidingButton)
} else {
this.$menuButton.removeAttribute('hidden')
removeAttributes(this.$menuButton, Object.keys(attributesForHidingButton))
this.$menuButton.setAttribute('aria-expanded', this.menuIsOpen.toString())

if (this.menuIsOpen) {
Expand Down Expand Up @@ -156,3 +158,39 @@ export class ServiceNavigation extends Component {
*/
static moduleName = 'govuk-service-navigation'
}

/**
* Collection of attributes that needs setting on a `<button>`
* to fully hide it, both visually and from screen-readers,
* and prevent its activation while hidden
*/
const attributesForHidingButton = {
hidden: '',
// Fix button still appearing in VoiceOver's form control's menu despite being hidden
// https://bugs.webkit.org/show_bug.cgi?id=300899
'aria-hidden': 'true'
}

/**
* Sets a group of attributes on the given element
*
* @param {Element} $element - The element to set the attribute on
* @param {{[attributeName: string]: string}} attributes - The attributes to set
*/
function setAttributes($element, attributes) {
for (const attributeName in attributes) {
$element.setAttribute(attributeName, attributes[attributeName])
}
}

/**
* Removes a list of attributes from the given element
*
* @param {Element} $element - The element to remove the attributes from
* @param {string[]} attributeNames - The names of the attributes to remove
*/
function removeAttributes($element, attributeNames) {
for (const attributeName of attributeNames) {
$element.removeAttribute(attributeName)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ describe('/components/service-navigation', () => {
await expect(
render(page, 'service-navigation', examples.default, {
beforeInitialisation($root, { selector }) {
$root.querySelector(selector).removeAttribute('aria-controls')
$root
.querySelector(selector)
.removeAttribute('data-aria-controls')
},
context: {
selector: toggleButtonSelector
Expand All @@ -119,7 +121,7 @@ describe('/components/service-navigation', () => {
cause: {
name: 'ElementError',
message:
'govuk-service-navigation: Navigation button (`<button class="govuk-js-service-navigation-toggle">`) attribute (`aria-controls`) not found'
'govuk-service-navigation: Navigation button (`<button class="govuk-js-service-navigation-toggle">`) attribute (`data-aria-controls`) not found'
}
})
})
Expand Down Expand Up @@ -167,6 +169,15 @@ describe('/components/service-navigation', () => {
expect(buttonHiddenAttribute).toBeFalsy()
})

it('does not add an `aria-hidden` attribute to the button for Voice Over', async () => {
const buttonHiddenAttribute = await page.$eval(
toggleButtonSelector,
(el) => el.hasAttribute('aria-hidden')
)

expect(buttonHiddenAttribute).toBeFalsy()
})

it('renders the toggle button with `aria-expanded` set to false', async () => {
const toggleExpandedAttribute = await page.$eval(
toggleButtonSelector,
Expand All @@ -175,6 +186,15 @@ describe('/components/service-navigation', () => {

expect(toggleExpandedAttribute).toBe('false')
})

it('adds the `aria-controls` attribute to the toggle', async () => {
const toggleExpandedAttribute = await page.$eval(
toggleButtonSelector,
(el) => el.getAttribute('aria-controls')
)

expect(toggleExpandedAttribute).toBe('navigation')
})
})

describe('when toggle button is clicked once', () => {
Expand Down Expand Up @@ -229,5 +249,97 @@ describe('/components/service-navigation', () => {
expect(toggleExpandedAttribute).toBe('false')
})
})

describe('on wide viewports', () => {
beforeAll(async () => {
// First let's reset the viewport to a desktop one
await page.setViewport({
width: 1280,
height: 720
})
})

afterAll(async () => {
// After tests have run reset teh viewport to a phone
await page.emulate(iPhone)
})

describe('on page load', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depending on the outcome of the other thread, we'd also want a test checking for disabled here as well

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot!

beforeAll(async () => {
await render(page, 'service-navigation', examples.default)
})

it('shows the navigation', async () => {
const navigationHiddenAttribute = await page.$eval(
navigationSelector,
(el) => el.hasAttribute('hidden')
)

expect(navigationHiddenAttribute).toBeFalsy()
})

it('keeps the toggle button hidden', async () => {
const buttonHiddenAttribute = await page.$eval(
toggleButtonSelector,
(el) => el.hasAttribute('hidden')
)

expect(buttonHiddenAttribute).toBeTruthy()
})

it('adds an `aria-hidden` attribute for Voice Over', async () => {
const buttonHiddenAttribute = await page.$eval(
toggleButtonSelector,
(el) => el.getAttribute('aria-hidden')
)

expect(buttonHiddenAttribute).toBe('true')
})
})

describe('when page is resized to a narrow viewport', () => {
beforeAll(async () => {
await render(page, 'service-navigation', examples.default)

// Once the page is loaded resize the viewport to a narrow one
// Only set the width and height to avoid the page getting wiped
await page.setViewport({
width: iPhone.viewport.width,
height: iPhone.viewport.height
})

// Wait that the page got updated after the resize,
// as sometimes the tests run too early
await page.waitForSelector(`${navigationSelector}[hidden]`)
Copy link
Member Author

@romaricpascal romaricpascal Oct 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note This effectively doubles up with the first test within the describe, but I couldn't find another selector to wait on to guarantee that the media query listener had ran.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth expanding the comment to note that that's why there are 2 instances of await selector[hidden]?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops, that's actually a copy paste in that first test. The selector in the first test should just be navigationSelector not `${navigationSelector}[hidden]`, I'll update.

})

it('hides the navigation', async () => {
const navigationHiddenAttribute = await page.$eval(
`${navigationSelector}`,
(el) => el.hasAttribute('hidden')
)

expect(navigationHiddenAttribute).toBeTruthy()
})

it('reveals the toggle button', async () => {
const buttonHiddenAttribute = await page.$eval(
toggleButtonSelector,
(el) => el.hasAttribute('hidden')
)

expect(buttonHiddenAttribute).toBeFalsy()
})

it('removes the `aria-hidden` attribute on the toggle', async () => {
const buttonHiddenAttribute = await page.$eval(
toggleButtonSelector,
(el) => el.hasAttribute('aria-hidden')
)

expect(buttonHiddenAttribute).toBeFalsy()
})
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ data-module="govuk-service-navigation"
{% if navigationItems | length or params.slots.navigationStart or params.slots.navigationEnd %}
<nav aria-label="{{ params.navigationLabel | default(menuButtonText, true) }}" class="govuk-service-navigation__wrapper {%- if params.navigationClasses %} {{ params.navigationClasses }}{% endif %}">
{% if collapseNavigationOnMobile %}
<button type="button" class="govuk-service-navigation__toggle govuk-js-service-navigation-toggle" aria-controls="{{ navigationId }}" {%- if params.menuButtonLabel and params.menuButtonLabel != menuButtonText %} aria-label="{{ params.menuButtonLabel }}"{% endif %} hidden>
<button type="button" class="govuk-service-navigation__toggle govuk-js-service-navigation-toggle" data-aria-controls="{{ navigationId }}" {%- if params.menuButtonLabel and params.menuButtonLabel != menuButtonText %} aria-label="{{ params.menuButtonLabel }}"{% endif %} hidden>
{{ menuButtonText }}
</button>
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('Service Navigation', () => {
const navId = $nav.attr('id')

expect(navId).toBe('navigation')
expect($navToggle.attr('aria-controls')).toBe(navId)
expect($navToggle.attr('data-aria-controls')).toBe(navId)
})

it('omits empty items from the navigation', () => {
Expand Down Expand Up @@ -158,7 +158,7 @@ describe('Service Navigation', () => {
const navId = $nav.attr('id')

expect(navId).toBe('main-nav')
expect($navToggle.attr('aria-controls')).toBe(navId)
expect($navToggle.attr('data-aria-controls')).toBe(navId)
})
})

Expand Down
Loading