Skip to content

Add breadcrumbs to all layouts #4710

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 13 commits into from
May 30, 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
297 changes: 297 additions & 0 deletions __tests__/mobile-navigation-section.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
const { goTo, getAttribute } = require('./helpers/puppeteer.js')

describe('MobileNavigationSection', () => {
beforeEach(async () => {
await page.setViewport({ width: 375, height: 667 })
})

describe('when JavaScript is unavailable or fails', () => {
beforeEach(async () => {
await page.setJavaScriptEnabled(false)

await goTo(page, '/')
})

it('does not render the `<button>`s that toggle sections', async () => {
await goTo(page, '/')

await expect(
page.$$('.govuk-service-navigation__link button')
).resolves.toStrictEqual([])
})

it('does not render the sub navigation items', async () => {
await expect(
page.$$('.govuk-service-navigation__link ul')
).resolves.toStrictEqual([])
})
})

describe('when JavaScript is available', () => {
beforeEach(async () => {
await page.setJavaScriptEnabled(true)

await goTo(page, '/')
})

describe('Initial state', () => {
it('adds a `<button>` to each Service Navigation items with the same label as its link', async () => {
const $serviceNavigationItems = await page.$$(
'.govuk-service-navigation__item'
)

for (const $serviceNavigationItem of $serviceNavigationItems) {
const [linkLabel, buttonLabel] =
await $serviceNavigationItem.evaluate((el) => {
return [
el.querySelector('a').textContent,
el.querySelector('button')?.textContent
]
})

expect(buttonLabel.trim()).toBe(linkLabel.trim())
}
})

it('adds the sub navigation to each Service Navigation items', async () => {
const $serviceNavigationItems = await page.$$(
'.govuk-service-navigation__item'
)

for (const $serviceNavigationItem of $serviceNavigationItems) {
await expect($serviceNavigationItem.$('ul')).resolves.not.toBeNull()
}
})
})

describe('User interactions', () => {
it("toggles the sub navigation when clicking on an item's button", async () => {
// Open the service navigation so Puppeteer can click on elements inside it
const $navigationToggler = await page.$(
'.govuk-js-service-navigation-toggle'
)
await $navigationToggler.click()

const $serviceNavigationItem = await page.$(
'.govuk-service-navigation__item'
)

const $subNavigation = await $serviceNavigationItem.$('ul')
const $button = await $serviceNavigationItem.$('button')

await $button.click()

await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
'true'
)
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBeNull()

await $button.click()

await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
'false'
)
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBe('')
})
})

describe('Responsiveness', () => {
it('hides the link from the service navigation when the viewport is narrow', async () => {
const $serviceNavigationItems = await page.$$(
'.govuk-service-navigation__item'
)

// await jestPuppeteer.debug();

for (const $serviceNavigationItem of $serviceNavigationItems) {
const [
linkHiddenAttribute,
buttonHiddenAttribute,
subnavHiddenAttribute
] = await $serviceNavigationItem.evaluate((el) => [
el
.querySelector('.govuk-service-navigation__link')
.getAttribute('hidden'),
el.querySelector('button').getAttribute('hidden'),
el.querySelector('ul').getAttribute('hidden')
])

expect(linkHiddenAttribute).toBe('')
expect(buttonHiddenAttribute).toBeNull()
expect(subnavHiddenAttribute).toBe('')
}
})

it('hides the sub navigation and its toggle when the viewport becomes large enough', async () => {
await page.setViewport({ width: 1024, height: 768 })
// Wait a little bit that the Media Query List reacting to the change of viewport
// has triggered its 'change' event before we look at the page
await page.waitForSelector(
'.govuk-service-navigation__link:not([hidden])'
)

const $serviceNavigationItems = await page.$$(
'.govuk-service-navigation__item'
)

for (const $serviceNavigationItem of $serviceNavigationItems) {
const [
linkHiddenAttribute,
buttonHiddenAttribute,
subnavHiddenAttribute
] = await $serviceNavigationItem.evaluate((el) => [
el
.querySelector('.govuk-service-navigation__link')
.getAttribute('hidden'),
el.querySelector('button').getAttribute('hidden'),
el.querySelector('ul').getAttribute('hidden')
])

expect(linkHiddenAttribute).toBeNull()
expect(buttonHiddenAttribute).toBe('')
expect(subnavHiddenAttribute).toBe('')
}

await page.setViewport({ width: 375, height: 667 })
// Wait a little bit that the Media Query List reacting to the change of viewport
// has triggered its 'change' event before we look at the page
await page.waitForSelector('.govuk-service-navigation__link[hidden]')

for (const $serviceNavigationItem of $serviceNavigationItems) {
const [
linkHiddenAttribute,
buttonHiddenAttribute,
subnavHiddenAttribute
] = await $serviceNavigationItem.evaluate((el) => [
el
.querySelector('.govuk-service-navigation__link')
.getAttribute('hidden'),
el.querySelector('button').getAttribute('hidden'),
el.querySelector('ul').getAttribute('hidden')
])

expect(linkHiddenAttribute).toBe('')
expect(buttonHiddenAttribute).toBeNull()
expect(subnavHiddenAttribute).toBe('')
}
})

it('keeps the open sections open when the viewport is resized', async () => {
// Navigate to a page where a section will be open
await goTo(page, '/components/')

const $buttonOfOpenSection = await page.$(
'.govuk-service-navigation__item--active button'
)
const $subnavOfOpenSection = await page.$(
'.govuk-service-navigation__item--active ul'
)

await expect(
getAttribute($subnavOfOpenSection, 'hidden')
).resolves.toBeNull()
await expect(
getAttribute($buttonOfOpenSection, 'aria-expanded')
).resolves.toBe('true')

await page.setViewport({ width: 1024, height: 768 })
// Wait a little bit that the Media Query List reacting to the change of viewport
// has triggered its 'change' event before we look at the page
await page.waitForSelector(
'[data-module="app-mobile-navigation-section"]:not([hidden])'
)

await expect(
getAttribute($subnavOfOpenSection, 'hidden')
).resolves.toBe('')
// Button remains expanded as it's hidden anyways
await expect(
getAttribute($buttonOfOpenSection, 'aria-expanded')
).resolves.toBe('true')

await page.setViewport({ width: 375, height: 667 })
// Wait a little bit that the Media Query List reacting to the change of viewport
// has triggered its 'change' event before we look at the page
await page.waitForSelector(
'[data-module="app-mobile-navigation-section"][hidden]'
)

// When viewport is narrow again, the subnav is visible like it was before the viewport got larger
await expect(
getAttribute($subnavOfOpenSection, 'hidden')
).resolves.toBeNull()
await expect(
getAttribute($buttonOfOpenSection, 'aria-expanded')
).resolves.toBe('true')
})
})

describe('On a section root', () => {
beforeEach(async () => {
await goTo(page, '/components/')
})

it('keeps sub-navigation in the active section open', async () => {
const $serviceNavigationItem = await page.$(
'.govuk-service-navigation__item--active'
)

const $button = await $serviceNavigationItem.$('button')
const $subNavigation = await $serviceNavigationItem.$('ul')

await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
'true'
)
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBeNull()
})

it('marks the overview as the current link', async () => {
const $serviceNavigationItem = await page.$(
'.govuk-service-navigation__item--active'
)

const $subNavigationOverviewLink = await $serviceNavigationItem.$(
'ul a[href="/components/"]'
)

await expect(
getAttribute($subNavigationOverviewLink, 'aria-current')
).resolves.toBe('page')
})
})

describe('In a page within a section', () => {
beforeEach(async () => {
await goTo(page, '/components/button/')
})

it('keeps the active section open', async () => {
const $serviceNavigationItem = await page.$(
'.govuk-service-navigation__item--active'
)

const $button = await $serviceNavigationItem.$('button')
const $subNavigation = await $serviceNavigationItem.$('ul')

await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
'true'
)
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBeNull()
})

it('marks the overview as the current link', async () => {
const $serviceNavigationItem = await page.$(
'.govuk-service-navigation__item--active'
)

const $subNavigationLink = await $serviceNavigationItem.$(
'ul a[href="/components/button/"]'
)

await expect(
getAttribute($subNavigationLink, 'aria-current')
).resolves.toBe('page')
})
})
})
})
55 changes: 55 additions & 0 deletions lib/nunjucks/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,59 @@ exports.getHTMLCode = function (path) {
})
}

/**
* This helper function traverses the navigation object to find an item with a
* given `url`, and returns the ancestors of that item.
*
* NOTE: This is used to assemble breadcrumb navigation, so outputs the result
* with keys and values used by that component. These differ from those used
* internally.
*
* @param {string} targetUrl - The URL to look for, without leading `/`
* @returns {AncestorPage[]} Array of ancestor pages to this one
*/
exports.getAncestorPages = function (targetUrl) {
// Get navigation object from the template context
const navigationContext = this.lookup('navigation') ?? []

// Create a stack to store our levels of hierarchy in.
// The homepage doesn't appear in the `navigation` object,
// so it needs adding manually
const ancestors = [{ text: 'Home', href: '/' }]

// Create a recursive function we can use to navigate the nested objects
const traverse = function (navItemArray) {
// Using a for instead of a forEach so that we can break the loop once done.
for (let i = 0; i < navItemArray.length; i++) {
const navItem = navItemArray[i]

// If the target URL and item URL match, it's the current page. It doesn't
// get added to the stack, but it implies that we've reached our target
// so there's no reason to continue searching.
if (targetUrl === navItem.url) {
break
}

// If the target URL *begins* with the current item URL, it's an ancestor
// of the target page. Add it to the stack and start looking through it's
// child items.
if (navItem.items && targetUrl.startsWith(navItem.url)) {
ancestors.push({ text: navItem.label, href: `/${navItem.url}` })
traverse(navItem.items)
}
}
}

// Call the recursive function
traverse(navigationContext)

return ancestors
}

exports.getMacroOptions = getMacroOptions

/**
* @typedef {object} AncestorPage
* @property {string} href - The URL of the ancestor page
* @property {string} text - The title of the ancestor page
*/
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"glob": "^11.0.2",
"gray-matter": "^4.0.2",
"highlight.js": "^11.11.1",
"html-validate": "^9.5.3",
"html-validate": "^9.5.4",
"husky": "^9.1.7",
"hyperlink": "^5.0.4",
"jest": "^29.7.0",
Expand Down
Loading