Skip to content

Commit

Permalink
Implement accordion functionality for ServiceNav
Browse files Browse the repository at this point in the history
- New partial for building HTML of sub-navigation items
- Accordion functionality for Service Navigation component when page
viewed on mobile viewport
- Update configuration for building list of links for navigation and
sub navigation items
  • Loading branch information
patrickpatrickpatrick committed Feb 27, 2025
1 parent df0f3dc commit 4edcc0c
Show file tree
Hide file tree
Showing 9 changed files with 445 additions and 27 deletions.
90 changes: 90 additions & 0 deletions __tests__/mobile-navigation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const { KnownDevices } = require('puppeteer')

const { goTo, getProperty, getAttribute } = require('./helpers/puppeteer.js')

describe('Homepage', () => {
let $navigationToggler

async function setup(page) {
$navigationToggler = await page.$('.govuk-js-service-navigation-toggle')
}

beforeAll(async () => {
await page.emulate(KnownDevices['iPhone 6'])
})

beforeEach(async () => {
await page.setJavaScriptEnabled(true)
await goTo(page, '/')
await setup(page)
})

describe('when JavaScript is unavailable or fails', () => {
it('the mobile subnav will not render', async () => {
await page.setJavaScriptEnabled(false)

// Reload page again
await page.reload()

// Menu open state (visually)
await expect(
page.$$('.govuk-service-navigation__link .app-mobile-navigation__list')
).resolves.toStrictEqual([])
})
})

describe('when JavaScript is available', () => {
it('the mobile subnav will render', async () => {
// Menu open state (visually)
await expect(
page.$$('.govuk-service-navigation__link template')
).resolves.toStrictEqual([])
})

it('the mobile subnav will toggle hidden when open or closed', async () => {
const $mobileNavButton = await page.$(
'.govuk-service-navigation__link.app-mobile-navigation__toggle-button'
)
const $mobileNavMenu = await page.$('.app-mobile-navigation__list')

// default state on homepage is hidden
await expect(getProperty($mobileNavMenu, 'hidden')).resolves.toBeTruthy()

await $navigationToggler.click()
await $mobileNavButton.click()

// default state on homepage is hidden
await expect(getProperty($mobileNavMenu, 'hidden')).resolves.toBeFalsy()
})

it('the mobile subnav will toggle aria-expanded when open or closed', async () => {
const $mobileNavButton = await page.$(
'.govuk-service-navigation__link.app-mobile-navigation__toggle-button'
)

// default state on homepage is hidden
await expect(
getAttribute($mobileNavButton, 'aria-expanded')
).resolves.toBe('false')

await $navigationToggler.click()
await $mobileNavButton.click()

// default state on homepage is hidden
await expect(
getAttribute($mobileNavButton, 'aria-expanded')
).resolves.toBe('true')
})

it('applies current link style and expands subnav if user on page in subnav', async () => {
await goTo(page, '/get-started')
await setup(page)

await expect(
page.$(
'[aria-expanded="true"].app-mobile-navigation__toggle-button + ul [href="/get-started"].app-mobile-navigation__link--active'
)
).resolves.not.toBeNull()
})
})
})
42 changes: 23 additions & 19 deletions config/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,49 @@
*/
const config = [
{
label: 'Get started',
url: 'get-started',
text: 'Get started',
href: '/get-started'
href: 'get-started',
label: 'Get started',
url: 'get-started'
},
{
label: 'Styles',
url: 'styles',
text: 'Styles',
href: '/styles'
href: 'styles',
label: 'Styles',
url: 'styles'
},
{
label: 'Components',
url: 'components',
text: 'Components',
href: '/components'
href: 'components',
label: 'Components',
url: 'components'
},
{
label: 'Patterns',
url: 'patterns',
text: 'Patterns',
href: '/patterns'
href: 'patterns',
label: 'Patterns',
url: 'patterns'
},
{
label: 'Community',
url: 'community',
text: 'Community',
href: '/community'
href: 'community',
label: 'Community',
url: 'community'
},
{
label: 'Accessibility',
url: 'accessibility',
text: 'Accessibility',
href: '/accessibility'
href: 'accessibility',
label: 'Accessibility',
url: 'accessibility'
}
]

module.exports = config

/**
* @typedef {object} NavigationItem
* @property {string} text - Navigation item text
* @property {string} href - URL path without leading slash
* @property {string} label - Navigation item text
* @property {string} url - URL path without leading slash
* @property {boolean} [ignoreInSearch] - Ignore in search index
Expand All @@ -54,8 +56,10 @@ module.exports = config

/**
* @typedef {object} NavigationSubItem
* @property {string} text - Navigation item text
* @property {string} href - URL path without leading slash
* @property {string} label - Navigation item text
* @property {string} url - URL path without leading slash
* @property {string} url - URL path without leading slashp
* @property {string[]} [aliases] - Additional search terms (optional)
* @property {string[]} [headings] - Markdown extracted headings (optional)
* @property {string} [order] - Menu item sort order (optional)
Expand Down
6 changes: 5 additions & 1 deletion lib/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ module.exports = (config) => (files, metalsmith, done) => {
for (const item of items) {
// Match navigation item child directories
// (for example, ['components/breadcrumbs/index.html', 'components/checkboxes/index.html', ...])
const itemPaths = metalsmith.match(`${item.url}/*/index.html`, paths)
const itemPaths = metalsmith.match(`${item.href}/*/index.html`, paths)

// No sub items required for this path
if (!itemPaths.length) {
Expand All @@ -49,6 +49,8 @@ module.exports = (config) => (files, metalsmith, done) => {
item.items.push({
url: dirname(itemPath),
label: frontmatter.title,
href: `/${dirname(itemPath)}`,
text: frontmatter.title,
order: frontmatter.order,
theme: frontmatter.theme,

Expand All @@ -65,6 +67,8 @@ module.exports = (config) => (files, metalsmith, done) => {

// Sort navigation sub items using 'order' (optional)
item.items?.sort((a, b) => compare(a.order, b.order))

item.href = `/${item.href}`
}

// Add navigation to global variables
Expand Down
4 changes: 4 additions & 0 deletions src/javascripts/application.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import CookiesPage from './components/cookies-page.mjs'
import Copy from './components/copy.mjs'
import EmbedCard from './components/embed-card.mjs'
import ExampleFrame from './components/example-frame.mjs'
import MobileNavigation from './components/mobile-navigation.mjs'
import Navigation from './components/navigation.mjs'
import OptionsTable from './components/options-table.mjs'
import ScrollContainer from './components/scroll-container.mjs'
Expand Down Expand Up @@ -57,6 +58,9 @@ new OptionsTable()
// Initialise mobile navigation
createAll(Navigation)

// Initialise mobile navigation
createAll(MobileNavigation)

// Initialise scrollable container handling
createAll(ScrollContainer)

Expand Down
152 changes: 152 additions & 0 deletions src/javascripts/components/mobile-navigation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { Component } from 'govuk-frontend'

/**
* Mobile Navigation enhancement for Service Navigation component
*/
class MobileNavigation extends Component {
static moduleName = 'app-mobile-navigation'

/**
* @param {Element} $root - HTML element
*/
constructor($root) {
super($root)

this.templates = this.$root.querySelectorAll(
'.app-mobile-navigation__template'
)
this.links = this.$root.querySelectorAll('.govuk-service-navigation__link')

Array.from(this.templates).forEach((template) => {
const templateClone = template.content.cloneNode(true)
let link

if (template.parentNode.tagName === 'A') {
link = template.parentNode
link.removeChild(template)
} else {
link = template.parentNode.parentNode
template.parentNode.removeChild(template)
}

// a is not a valid descendent of a, even within a template
// can't change this about the ServiceNavigation component
// so we need to have placeholder links in the template
// which we then replace
templateClone
.querySelectorAll('.app-mobile-navigation__link')
.forEach((linkPlaceholder) => {
const link = document.createElement('a')
link.className = linkPlaceholder.className
link.href = linkPlaceholder.dataset.href
link.innerHTML = linkPlaceholder.innerHTML
linkPlaceholder.replaceWith(link)
})

const button = document.createElement('button')
button.classList.add('govuk-service-navigation__link')
button.classList.add('app-mobile-navigation__toggle-button')
button.textContent = link.textContent
button.setAttribute('aria-expanded', 'false')

link.insertAdjacentElement('afterend', templateClone.firstElementChild)
link.insertAdjacentElement('afterend', button)

if (
link.parentNode.classList.contains(
'govuk-service-navigation__item--active'
)
) {
this.toggleSubnav(button)
}
})

const currentLink = this.$root.querySelector(
`.app-mobile-navigation__link[href^="${window.location.pathname.slice(0, -1)}"]`
)

if (currentLink) {
currentLink.classList.add('app-mobile-navigation__link--active')
}

this.subNavs = $root.querySelectorAll('.app-mobile-navigation__list')

// A global const for storing a matchMedia instance which we'll use to detect when a screen size change happens
// Set the matchMedia to the govuk-frontend tablet breakpoint

const breakPoint = getComputedStyle(
document.documentElement
).getPropertyValue('--govuk-frontend-breakpoint-tablet')

this.mql = window.matchMedia(`(min-width: ${breakPoint})`)

// MediaQueryList.addEventListener isn't supported by Safari < 14 so we need
// to be able to fall back to the deprecated MediaQueryList.addListener
if ('addEventListener' in this.mql) {
this.mql.addEventListener('change', () => this.setHiddenStates())
} else {
// @ts-expect-error Property 'addListener' does not exist
this.mql.addListener(() => this.setHiddenStates())
}

this.setHiddenStates()
this.setEventListener()
}

/**
* Toggle subnav menu open or closed
*
* @param {HTMLButtonElement} button - button of subnav to toggle
*/
toggleSubnav(button) {
const ariaExpanded = button.getAttribute('aria-expanded') === 'true'

button.setAttribute('aria-expanded', `${!ariaExpanded}`)

const subNav = button.nextElementSibling

if (
subNav &&
subNav.classList &&
subNav.classList.contains('app-mobile-navigation__list')
) {
if (ariaExpanded) {
subNav.setAttribute('hidden', '')
} else {
subNav.removeAttribute('hidden')
}
}
}

/**
* Set up event delegation for button clicks
*/
setEventListener() {
this.$root.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON') {
this.toggleSubnav(e.target)
}
})
}

/**
* Hide links if viewport is below tablet
*/
setHiddenStates() {
if (!this.mql.matches) {
this.links.forEach((a) => a.setAttribute('hidden', ''))
this.subNavs.forEach((subNav) => {
if (
subNav.previousElementSibling.getAttribute('aria-expanded') === 'true'
) {
subNav.removeAttribute('hidden')
}
})
} else {
this.subNavs.forEach((subNav) => subNav.setAttribute('hidden', ''))
this.links.forEach((link) => link.removeAttribute('hidden'))
}
}
}

export default MobileNavigation
Loading

0 comments on commit 4edcc0c

Please sign in to comment.