Skip to content
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

Refactor JavaScript for tabs functionality #2242

Merged
merged 7 commits into from
Jul 20, 2022
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 3 additions & 3 deletions __tests__/tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ describe('Patterns page', () => {
describe('when "hideTab" parameter is set to true', () => {
it('the tab list is not rendered', async () => {
await page.goto(baseUrl + '/patterns/question-pages/', { waitUntil: 'load' })
const expandedTabContentWithNoTab = await page.evaluate(() => document.body.querySelector('#example-section-headings-open .app-tabs'))
expect(expandedTabContentWithNoTab).toBeFalsy()
const expandedTabContentWithNoTab = await page.evaluate(() => document.body.querySelector('#section-headings-question-pages-example-open .app-tabs'))
expect(expandedTabContentWithNoTab).toBeNull()
})

it('close button is not shown on the code block', async () => {
await page.goto(baseUrl + '/patterns/question-pages/', { waitUntil: 'load' })
const expandedTabContentWithNoTabCloseButton = await page.evaluate(() => document.body.querySelector('.js-tabs__container--no-tabs .js-tabs__close'))
expect(expandedTabContentWithNoTabCloseButton).toBeFalsy()
expect(expandedTabContentWithNoTabCloseButton).toBeNull()
})
})
})
Expand Down
8 changes: 3 additions & 5 deletions src/javascripts/components/options-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,12 @@ var OptionsTable = {
var detailsText = optionsDetailsElement.querySelector('.govuk-details__text')

if (detailsSummary && detailsText) {
tabLink.setAttribute('aria-expanded', true)
tabLink.setAttribute('aria-expanded', 'true')
tabHeading.className += ' app-tabs__item--current'
tabsElement.classList.remove('app-tabs__container--hidden')
tabsElement.setAttribute('aria-hidden', false)
tabsElement.removeAttribute('hidden')
Copy link
Contributor

Choose a reason for hiding this comment

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

This appears to be an issue with the existing JavaScript as well, but if you follow the link to the Nunjucks options from another component (e.g. https://deploy-preview-2242--govuk-design-system-preview.netlify.app/components/error-message/#options-error-message-example) on mobile you get a weird open state with a double border between the tab and the panel:

deploy-preview-2242--govuk-design-system-preview netlify app_components_error-message_(iPhone 12 Pro)

I think this is because the app-tabs__heading--current isn't being added to the tab?

Given this is an existing issue, if this isn't trivial to fix then let's leave it for now.

Copy link
Member Author

Choose a reason for hiding this comment

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

Wasn't aware of that one specifically, but I did notice that the options-table.js file seems to only account for the desktop tabs. It'd be a nice candidate to for refactoring itself, especially if it becomes possible to toggle the details/tabs state programatically rather than setting classes/attributes manually.


optionsDetailsElement.setAttribute('open', 'open')
detailsSummary.setAttribute('aria-expanded', true)
detailsText.setAttribute('aria-hidden', false)
detailsSummary.setAttribute('aria-expanded', 'true')
detailsText.style.display = ''
window.setTimeout(function () {
tabLink.focus()
Expand Down
223 changes: 137 additions & 86 deletions src/javascripts/components/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,128 +3,179 @@ import 'govuk-frontend/govuk/vendor/polyfills/Element/prototype/classList'
import 'govuk-frontend/govuk/vendor/polyfills/Event'
import { nodeListForEach } from './helpers.js'

var tabsItemClass = 'app-tabs__item'
var tabsItemCurrentClass = tabsItemClass + '--current'
var tabsItemJsClass = 'js-tabs__item'
var headingItemClass = 'app-tabs__heading'
var headingItemCurrentClass = headingItemClass + '--current'
var headingItemJsClass = 'js-tabs__heading'
var headingItemJsLinkSelector = '.js-tabs__heading a'
var tabContainerHiddenClass = 'app-tabs__container--hidden'
var tabContainerJsClass = '.js-tabs__container'
var tabContainerNoTabsJsClass = 'js-tabs__container--no-tabs'
var allTabTogglers = '.' + tabsItemJsClass + ' a'
var tabTogglersMarkedOpenClass = '.js-tabs__item--open a'
/**
* The naming of things is a little complicated in here.
* For reference:
*
* - AppTabs - this JS module
* - app-tabs, js-tabs - groups of classes used by the tabs component
* - mobile tabs - the controls to show or hide panels on mobile; these are functionally closer to being an accordion than tabs
* - desktop tabs - the controls to show, hide or switch panels on tablet/desktop
* - panels - the content that is shown/hidden/switched; same across all breakpoints
*/

function AppTabs ($module) {
this.$module = $module
this.$allTabContainers = this.$module.querySelectorAll(tabContainerJsClass)
this.$allTabTogglers = this.$module.querySelectorAll(allTabTogglers)
this.$allTabTogglersMarkedOpen = this.$module.querySelectorAll(tabTogglersMarkedOpenClass)
this.$mobileTabs = this.$module.querySelectorAll(headingItemJsLinkSelector)
this.$mobileTabs = this.$module.querySelectorAll('.js-tabs__heading a')
this.$desktopTabs = this.$module.querySelectorAll('.js-tabs__item a')
this.$panels = this.$module.querySelectorAll('.js-tabs__container')
}

AppTabs.prototype.init = function () {
var self = this

querkmachine marked this conversation as resolved.
Show resolved Hide resolved
// Exit if no module has been defined
if (!this.$module) {
return
}

// Enhance tab links to buttons on mobile if JS enabled
this.enhanceMobileButtons(this.$mobileTabs)
// Enhance mobile tabs into buttons
this.enhanceMobileTabs()

// Add bindings to desktop tabs
nodeListForEach(this.$desktopTabs, function ($tab) {
$tab.bindClick = self.onClick.bind(self)
$tab.addEventListener('click', $tab.bindClick)
querkmachine marked this conversation as resolved.
Show resolved Hide resolved
})
querkmachine marked this conversation as resolved.
Show resolved Hide resolved

// reset all tabs
// Reset all tabs and panels to closed state
// We also add all our default ARIA goodness here
this.resetTabs()
// add close to each tab
this.$module.addEventListener('click', this.handleClick.bind(this))

nodeListForEach(this.$allTabTogglersMarkedOpen, function ($tabToggler) {
$tabToggler.click()
})
// Show the first panel already open if the `open` attribute is present
if (this.$module.hasAttribute('data-open')) {
this.openPanel(this.$panels[0].id)
}
}

// expand and collapse functionality
AppTabs.prototype.activateAndToggle = function (event) {
/**
*
*/
AppTabs.prototype.onClick = function (event) {
event.preventDefault()
var $currentToggler = event.target
var $currentTogglerSiblings = this.$module.querySelectorAll('[aria-controls="' + $currentToggler.getAttribute('aria-controls') + '"]')
var $tabContainer

try {
$tabContainer = this.$module.querySelector('#' + $currentToggler.getAttribute('aria-controls'))
} catch (exception) {
throw new Error('Invalid example ID given: ' + exception)
}
var isTabAlreadyOpen = $currentToggler.getAttribute('aria-expanded') === 'true'
var $currentTab = event.target
var panelId = $currentTab.getAttribute('aria-controls')
var $panel = this.getPanel(panelId)
var isTabAlreadyOpen = $currentTab.getAttribute('aria-expanded') === 'true'

if (!$tabContainer) {
return
if (!$panel) {
throw new Error('Invalid example ID given: ' + panelId)
}

// If the panel that's been called is already open, close it.
// Otherwise, close all panels and open the one requested.
if (isTabAlreadyOpen) {
$tabContainer.classList.add(tabContainerHiddenClass)
$tabContainer.setAttribute('aria-hidden', 'true')
nodeListForEach($currentTogglerSiblings, function ($tabToggler) {
$tabToggler.setAttribute('aria-expanded', 'false')
// desktop and mobile
$tabToggler.parentNode.classList.remove(tabsItemCurrentClass, headingItemCurrentClass)
})
this.closePanel(panelId)
} else {
// Reset tabs
this.resetTabs()
// make current active
$tabContainer.classList.remove(tabContainerHiddenClass)
$tabContainer.setAttribute('aria-hidden', 'false')

nodeListForEach($currentTogglerSiblings, function ($tabToggler) {
$tabToggler.setAttribute('aria-expanded', 'true')
if ($tabToggler.parentNode.classList.contains(tabsItemClass)) {
$tabToggler.parentNode.classList.add(tabsItemCurrentClass)
} else if ($tabToggler.parentNode.classList.contains(headingItemClass)) {
$tabToggler.parentNode.classList.add(headingItemCurrentClass)
}
})
this.openPanel(panelId)
}
}

// We progressively enhance the mobile tab links to buttons
// to make sure we're using semantic HTML to describe the behaviour of the tabs
AppTabs.prototype.enhanceMobileButtons = function (mobileTabs) {
nodeListForEach(mobileTabs, function (mobileTab) {
var button = document.createElement('button')
button.setAttribute('aria-controls', mobileTab.getAttribute('aria-controls'))
button.setAttribute('data-track', mobileTab.getAttribute('data-track'))
button.classList.add('app-tabs__heading-button')
button.innerHTML = mobileTab.innerHTML
mobileTab.parentNode.appendChild(button)
mobileTab.parentNode.removeChild(mobileTab)
/**
* Enhances mobile tab anchors to buttons elements
*
* On mobile, tabs act like an accordion and are semantically more similar to
* buttons than links, so let's use the appropriate element
*/
AppTabs.prototype.enhanceMobileTabs = function () {
var self = this
querkmachine marked this conversation as resolved.
Show resolved Hide resolved
// Loop through mobile tabs...
nodeListForEach(this.$mobileTabs, function ($tab) {
// ...construct a button equivalent of each anchor...
var $button = document.createElement('button')
$button.setAttribute('aria-controls', $tab.getAttribute('aria-controls'))
$button.setAttribute('data-track', $tab.getAttribute('data-track'))
$button.classList.add('app-tabs__heading-button')
$button.innerHTML = $tab.innerHTML
// ...bind controls...
$button.bindClick = self.onClick.bind(self)
$button.addEventListener('click', $button.bindClick)
// ...and replace the anchor with the button
$tab.parentNode.appendChild($button)
$tab.parentNode.removeChild($tab)
})
querkmachine marked this conversation as resolved.
Show resolved Hide resolved

this.$allTabTogglers = this.$module.querySelectorAll(allTabTogglers)
// Replace the value of $mobileTabs with the new buttons
this.$mobileTabs = this.$module.querySelectorAll('.js-tabs__heading button')
}

// reset aria attributes to default and close the tab content container
/**
* Reset tabs and panels to closed state
*/
AppTabs.prototype.resetTabs = function () {
nodeListForEach(this.$allTabContainers, function ($tabContainer) {
// unless the tab content has not tabs and it's been set as open
if (!$tabContainer.classList.contains(tabContainerNoTabsJsClass)) {
$tabContainer.classList.add(tabContainerHiddenClass)
$tabContainer.setAttribute('aria-hidden', 'true')
var self = this
querkmachine marked this conversation as resolved.
Show resolved Hide resolved
nodeListForEach(this.$panels, function ($panel) {
// We don't want to hide the panel if there are no tabs present to show it
if (!$panel.classList.contains('js-tabs__container--no-tabs')) {
self.closePanel($panel.id)
}
})
querkmachine marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Open a panel and set the associated controls and styles
*/
AppTabs.prototype.openPanel = function (panelId) {
var $mobileTab = this.getMobileTab(panelId)
var $desktopTab = this.getDesktopTab(panelId)

// Panels can exist without associated tabs—for example if there's a single
// panel that's open by default—so make sure they actually exist before use
if ($mobileTab && $desktopTab) {
$mobileTab.setAttribute('aria-expanded', 'true')
$mobileTab.parentNode.classList.add('app-tabs__heading--current')
$desktopTab.setAttribute('aria-expanded', 'true')
$desktopTab.parentNode.classList.add('app-tabs__item--current')
}

this.getPanel(panelId).removeAttribute('hidden')
}

/**
* Close a panel and set the associated controls and styles
*/
AppTabs.prototype.closePanel = function (panelId) {
var $mobileTab = this.getMobileTab(panelId)
var $desktopTab = this.getDesktopTab(panelId)
$mobileTab.setAttribute('aria-expanded', 'false')
$desktopTab.setAttribute('aria-expanded', 'false')
$mobileTab.parentNode.classList.remove('app-tabs__heading--current')
$desktopTab.parentNode.classList.remove('app-tabs__item--current')
this.getPanel(panelId).setAttribute('hidden', 'hidden')
}

/**
* Helper function to get a specific mobile tab by the associated panel ID
*/
AppTabs.prototype.getMobileTab = function (panelId) {
var result = null
nodeListForEach(this.$mobileTabs, function ($tab) {
if ($tab.getAttribute('aria-controls') === panelId) {
result = $tab
}
})
return result
}

nodeListForEach(this.$allTabTogglers, function ($tabToggler) {
$tabToggler.setAttribute('aria-expanded', 'false')
// desktop and mobile
$tabToggler.parentNode.classList.remove(tabsItemCurrentClass, headingItemCurrentClass)
/**
* Helper function to get a specific desktop tab by the associated panel ID
*/
AppTabs.prototype.getDesktopTab = function (panelId) {
var result = null
nodeListForEach(this.$desktopTabs, function ($tab) {
if ($tab.getAttribute('aria-controls') === panelId) {
result = $tab
}
})
return result
querkmachine marked this conversation as resolved.
Show resolved Hide resolved
}

AppTabs.prototype.handleClick = function (event) {
// toggle and active selected tab and heading (on mobile)
if (event.target.parentNode.classList.contains(tabsItemJsClass) ||
event.target.parentNode.classList.contains(headingItemJsClass)) {
this.activateAndToggle(event)
}
/**
* Helper function to get a specific panel by ID
*/
AppTabs.prototype.getPanel = function (panelId) {
return document.getElementById(panelId)
}

export default AppTabs
4 changes: 0 additions & 4 deletions src/stylesheets/components/_tabs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,6 @@
}
}

.app-tabs__container--hidden {
display: none;
}

.app-tabs__container pre {
max-width: inherit;
margin-bottom: 0;
Expand Down
Loading