Skip to content

Commit c2d2682

Browse files
Merge pull request #4710 from alphagov/spike-breadcrumbs-all-pages
2 parents 74d268d + 58b2e8b commit c2d2682

16 files changed

+820
-29
lines changed
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
const { goTo, getAttribute } = require('./helpers/puppeteer.js')
2+
3+
describe('MobileNavigationSection', () => {
4+
beforeEach(async () => {
5+
await page.setViewport({ width: 375, height: 667 })
6+
})
7+
8+
describe('when JavaScript is unavailable or fails', () => {
9+
beforeEach(async () => {
10+
await page.setJavaScriptEnabled(false)
11+
12+
await goTo(page, '/')
13+
})
14+
15+
it('does not render the `<button>`s that toggle sections', async () => {
16+
await goTo(page, '/')
17+
18+
await expect(
19+
page.$$('.govuk-service-navigation__link button')
20+
).resolves.toStrictEqual([])
21+
})
22+
23+
it('does not render the sub navigation items', async () => {
24+
await expect(
25+
page.$$('.govuk-service-navigation__link ul')
26+
).resolves.toStrictEqual([])
27+
})
28+
})
29+
30+
describe('when JavaScript is available', () => {
31+
beforeEach(async () => {
32+
await page.setJavaScriptEnabled(true)
33+
34+
await goTo(page, '/')
35+
})
36+
37+
describe('Initial state', () => {
38+
it('adds a `<button>` to each Service Navigation items with the same label as its link', async () => {
39+
const $serviceNavigationItems = await page.$$(
40+
'.govuk-service-navigation__item'
41+
)
42+
43+
for (const $serviceNavigationItem of $serviceNavigationItems) {
44+
const [linkLabel, buttonLabel] =
45+
await $serviceNavigationItem.evaluate((el) => {
46+
return [
47+
el.querySelector('a').textContent,
48+
el.querySelector('button')?.textContent
49+
]
50+
})
51+
52+
expect(buttonLabel.trim()).toBe(linkLabel.trim())
53+
}
54+
})
55+
56+
it('adds the sub navigation to each Service Navigation items', async () => {
57+
const $serviceNavigationItems = await page.$$(
58+
'.govuk-service-navigation__item'
59+
)
60+
61+
for (const $serviceNavigationItem of $serviceNavigationItems) {
62+
await expect($serviceNavigationItem.$('ul')).resolves.not.toBeNull()
63+
}
64+
})
65+
})
66+
67+
describe('User interactions', () => {
68+
it("toggles the sub navigation when clicking on an item's button", async () => {
69+
// Open the service navigation so Puppeteer can click on elements inside it
70+
const $navigationToggler = await page.$(
71+
'.govuk-js-service-navigation-toggle'
72+
)
73+
await $navigationToggler.click()
74+
75+
const $serviceNavigationItem = await page.$(
76+
'.govuk-service-navigation__item'
77+
)
78+
79+
const $subNavigation = await $serviceNavigationItem.$('ul')
80+
const $button = await $serviceNavigationItem.$('button')
81+
82+
await $button.click()
83+
84+
await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
85+
'true'
86+
)
87+
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBeNull()
88+
89+
await $button.click()
90+
91+
await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
92+
'false'
93+
)
94+
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBe('')
95+
})
96+
})
97+
98+
describe('Responsiveness', () => {
99+
it('hides the link from the service navigation when the viewport is narrow', async () => {
100+
const $serviceNavigationItems = await page.$$(
101+
'.govuk-service-navigation__item'
102+
)
103+
104+
// await jestPuppeteer.debug();
105+
106+
for (const $serviceNavigationItem of $serviceNavigationItems) {
107+
const [
108+
linkHiddenAttribute,
109+
buttonHiddenAttribute,
110+
subnavHiddenAttribute
111+
] = await $serviceNavigationItem.evaluate((el) => [
112+
el
113+
.querySelector('.govuk-service-navigation__link')
114+
.getAttribute('hidden'),
115+
el.querySelector('button').getAttribute('hidden'),
116+
el.querySelector('ul').getAttribute('hidden')
117+
])
118+
119+
expect(linkHiddenAttribute).toBe('')
120+
expect(buttonHiddenAttribute).toBeNull()
121+
expect(subnavHiddenAttribute).toBe('')
122+
}
123+
})
124+
125+
it('hides the sub navigation and its toggle when the viewport becomes large enough', async () => {
126+
await page.setViewport({ width: 1024, height: 768 })
127+
// Wait a little bit that the Media Query List reacting to the change of viewport
128+
// has triggered its 'change' event before we look at the page
129+
await page.waitForSelector(
130+
'.govuk-service-navigation__link:not([hidden])'
131+
)
132+
133+
const $serviceNavigationItems = await page.$$(
134+
'.govuk-service-navigation__item'
135+
)
136+
137+
for (const $serviceNavigationItem of $serviceNavigationItems) {
138+
const [
139+
linkHiddenAttribute,
140+
buttonHiddenAttribute,
141+
subnavHiddenAttribute
142+
] = await $serviceNavigationItem.evaluate((el) => [
143+
el
144+
.querySelector('.govuk-service-navigation__link')
145+
.getAttribute('hidden'),
146+
el.querySelector('button').getAttribute('hidden'),
147+
el.querySelector('ul').getAttribute('hidden')
148+
])
149+
150+
expect(linkHiddenAttribute).toBeNull()
151+
expect(buttonHiddenAttribute).toBe('')
152+
expect(subnavHiddenAttribute).toBe('')
153+
}
154+
155+
await page.setViewport({ width: 375, height: 667 })
156+
// Wait a little bit that the Media Query List reacting to the change of viewport
157+
// has triggered its 'change' event before we look at the page
158+
await page.waitForSelector('.govuk-service-navigation__link[hidden]')
159+
160+
for (const $serviceNavigationItem of $serviceNavigationItems) {
161+
const [
162+
linkHiddenAttribute,
163+
buttonHiddenAttribute,
164+
subnavHiddenAttribute
165+
] = await $serviceNavigationItem.evaluate((el) => [
166+
el
167+
.querySelector('.govuk-service-navigation__link')
168+
.getAttribute('hidden'),
169+
el.querySelector('button').getAttribute('hidden'),
170+
el.querySelector('ul').getAttribute('hidden')
171+
])
172+
173+
expect(linkHiddenAttribute).toBe('')
174+
expect(buttonHiddenAttribute).toBeNull()
175+
expect(subnavHiddenAttribute).toBe('')
176+
}
177+
})
178+
179+
it('keeps the open sections open when the viewport is resized', async () => {
180+
// Navigate to a page where a section will be open
181+
await goTo(page, '/components/')
182+
183+
const $buttonOfOpenSection = await page.$(
184+
'.govuk-service-navigation__item--active button'
185+
)
186+
const $subnavOfOpenSection = await page.$(
187+
'.govuk-service-navigation__item--active ul'
188+
)
189+
190+
await expect(
191+
getAttribute($subnavOfOpenSection, 'hidden')
192+
).resolves.toBeNull()
193+
await expect(
194+
getAttribute($buttonOfOpenSection, 'aria-expanded')
195+
).resolves.toBe('true')
196+
197+
await page.setViewport({ width: 1024, height: 768 })
198+
// Wait a little bit that the Media Query List reacting to the change of viewport
199+
// has triggered its 'change' event before we look at the page
200+
await page.waitForSelector(
201+
'[data-module="app-mobile-navigation-section"]:not([hidden])'
202+
)
203+
204+
await expect(
205+
getAttribute($subnavOfOpenSection, 'hidden')
206+
).resolves.toBe('')
207+
// Button remains expanded as it's hidden anyways
208+
await expect(
209+
getAttribute($buttonOfOpenSection, 'aria-expanded')
210+
).resolves.toBe('true')
211+
212+
await page.setViewport({ width: 375, height: 667 })
213+
// Wait a little bit that the Media Query List reacting to the change of viewport
214+
// has triggered its 'change' event before we look at the page
215+
await page.waitForSelector(
216+
'[data-module="app-mobile-navigation-section"][hidden]'
217+
)
218+
219+
// When viewport is narrow again, the subnav is visible like it was before the viewport got larger
220+
await expect(
221+
getAttribute($subnavOfOpenSection, 'hidden')
222+
).resolves.toBeNull()
223+
await expect(
224+
getAttribute($buttonOfOpenSection, 'aria-expanded')
225+
).resolves.toBe('true')
226+
})
227+
})
228+
229+
describe('On a section root', () => {
230+
beforeEach(async () => {
231+
await goTo(page, '/components/')
232+
})
233+
234+
it('keeps sub-navigation in the active section open', async () => {
235+
const $serviceNavigationItem = await page.$(
236+
'.govuk-service-navigation__item--active'
237+
)
238+
239+
const $button = await $serviceNavigationItem.$('button')
240+
const $subNavigation = await $serviceNavigationItem.$('ul')
241+
242+
await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
243+
'true'
244+
)
245+
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBeNull()
246+
})
247+
248+
it('marks the overview as the current link', async () => {
249+
const $serviceNavigationItem = await page.$(
250+
'.govuk-service-navigation__item--active'
251+
)
252+
253+
const $subNavigationOverviewLink = await $serviceNavigationItem.$(
254+
'ul a[href="/components/"]'
255+
)
256+
257+
await expect(
258+
getAttribute($subNavigationOverviewLink, 'aria-current')
259+
).resolves.toBe('page')
260+
})
261+
})
262+
263+
describe('In a page within a section', () => {
264+
beforeEach(async () => {
265+
await goTo(page, '/components/button/')
266+
})
267+
268+
it('keeps the active section open', async () => {
269+
const $serviceNavigationItem = await page.$(
270+
'.govuk-service-navigation__item--active'
271+
)
272+
273+
const $button = await $serviceNavigationItem.$('button')
274+
const $subNavigation = await $serviceNavigationItem.$('ul')
275+
276+
await expect(getAttribute($button, 'aria-expanded')).resolves.toBe(
277+
'true'
278+
)
279+
await expect(getAttribute($subNavigation, 'hidden')).resolves.toBeNull()
280+
})
281+
282+
it('marks the overview as the current link', async () => {
283+
const $serviceNavigationItem = await page.$(
284+
'.govuk-service-navigation__item--active'
285+
)
286+
287+
const $subNavigationLink = await $serviceNavigationItem.$(
288+
'ul a[href="/components/button/"]'
289+
)
290+
291+
await expect(
292+
getAttribute($subNavigationLink, 'aria-current')
293+
).resolves.toBe('page')
294+
})
295+
})
296+
})
297+
})

lib/nunjucks/globals.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,59 @@ exports.getHTMLCode = function (path) {
106106
})
107107
}
108108

109+
/**
110+
* This helper function traverses the navigation object to find an item with a
111+
* given `url`, and returns the ancestors of that item.
112+
*
113+
* NOTE: This is used to assemble breadcrumb navigation, so outputs the result
114+
* with keys and values used by that component. These differ from those used
115+
* internally.
116+
*
117+
* @param {string} targetUrl - The URL to look for, without leading `/`
118+
* @returns {AncestorPage[]} Array of ancestor pages to this one
119+
*/
120+
exports.getAncestorPages = function (targetUrl) {
121+
// Get navigation object from the template context
122+
const navigationContext = this.lookup('navigation') ?? []
123+
124+
// Create a stack to store our levels of hierarchy in.
125+
// The homepage doesn't appear in the `navigation` object,
126+
// so it needs adding manually
127+
const ancestors = [{ text: 'Home', href: '/' }]
128+
129+
// Create a recursive function we can use to navigate the nested objects
130+
const traverse = function (navItemArray) {
131+
// Using a for instead of a forEach so that we can break the loop once done.
132+
for (let i = 0; i < navItemArray.length; i++) {
133+
const navItem = navItemArray[i]
134+
135+
// If the target URL and item URL match, it's the current page. It doesn't
136+
// get added to the stack, but it implies that we've reached our target
137+
// so there's no reason to continue searching.
138+
if (targetUrl === navItem.url) {
139+
break
140+
}
141+
142+
// If the target URL *begins* with the current item URL, it's an ancestor
143+
// of the target page. Add it to the stack and start looking through it's
144+
// child items.
145+
if (navItem.items && targetUrl.startsWith(navItem.url)) {
146+
ancestors.push({ text: navItem.label, href: `/${navItem.url}` })
147+
traverse(navItem.items)
148+
}
149+
}
150+
}
151+
152+
// Call the recursive function
153+
traverse(navigationContext)
154+
155+
return ancestors
156+
}
157+
109158
exports.getMacroOptions = getMacroOptions
159+
160+
/**
161+
* @typedef {object} AncestorPage
162+
* @property {string} href - The URL of the ancestor page
163+
* @property {string} text - The title of the ancestor page
164+
*/

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
"glob": "^11.0.2",
7070
"gray-matter": "^4.0.2",
7171
"highlight.js": "^11.11.1",
72-
"html-validate": "^9.5.3",
72+
"html-validate": "^9.5.4",
7373
"husky": "^9.1.7",
7474
"hyperlink": "^5.0.4",
7575
"jest": "^29.7.0",

0 commit comments

Comments
 (0)