Skip to content

Commit

Permalink
Refactor design system tabs JS
Browse files Browse the repository at this point in the history
  • Loading branch information
querkmachine committed Jul 11, 2022
1 parent 7cb9eca commit ead7548
Show file tree
Hide file tree
Showing 3 changed files with 137 additions and 99 deletions.
218 changes: 132 additions & 86 deletions src/javascripts/components/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,128 +3,174 @@ 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

// 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)
})

// 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')) {
// TODO: Open first panel
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
// 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)
})

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
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)
}
})
}

/**
* 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)
$mobileTab.setAttribute('aria-expanded', 'true')
$desktopTab.setAttribute('aria-expanded', 'true')
$mobileTab.parentNode.classList.add('app-tabs__heading--current')
$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
}

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
14 changes: 5 additions & 9 deletions views/partials/_example.njk
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,11 @@
{% set exampleId = (exampleTitle + " example") | slugger %}
{% endif %}

{% if params.open %}
{% set exampleId = exampleId + '-open' %}
{% endif %}

{% set display = params.displayExample | default(true) %}

{% set multipleTabs = params.html and params.nunjucks %}

<div class="app-example-wrapper" id="{{ exampleId }}" data-module="app-tabs">
<div class="app-example-wrapper" id="{{ exampleId }}" data-module="app-tabs" {%- if params.open %} data-open{% endif %}>
{% if display %}
<div class="app-example {{ "app-example--tabs" if params.html or params.nunjucks }}">
<div class="app-example__toolbar">
Expand All @@ -44,15 +40,15 @@
{%- if (multipleTabs) %}
<span id="options-{{ exampleId }}"></span>
<ul class="app-tabs" role="tablist">
<li class="app-tabs__item js-tabs__item{{ " js-tabs__item--open" if (params.open) }}" role="presentation"><a href="#{{ exampleId }}-html" role="tab" aria-controls="{{ exampleId }}-html" data-track="tab-html">HTML</a></li>
<li class="app-tabs__item js-tabs__item" role="presentation"><a href="#{{ exampleId }}-html" role="tab" aria-controls="{{ exampleId }}-html" data-track="tab-html">HTML</a></li>
<li class="app-tabs__item js-tabs__item" role="presentation"><a href="#{{ exampleId }}-nunjucks" role="tab" aria-controls="{{ exampleId }}-nunjucks" data-track="tab-nunjucks">Nunjucks</a></li>
</ul>
{% elif not (params.hideTab) %}
{% set tabType = "html" if params.html else ("nunjucks" if params.nunjucks ) %}
{#- if at least one tab is set to true show the list -#}
{% if tabType %}
<ul class="app-tabs" role="tablist">
<li class="app-tabs__item js-tabs__item{{ " js-tabs__item--open" if (params.open) }}" role="presentation">
<li class="app-tabs__item js-tabs__item" role="presentation">
<a href="#{{ exampleId }}-{{ tabType }}" role="tab" aria-controls="{{ exampleId }}-{{ tabType }}" data-track="tab-{{ tabType }}">{{ "HTML" if params.html else ("Nunjucks" if params.nunjucks )}}</a>
</li>
</ul>
Expand All @@ -61,7 +57,7 @@

{%- if (params.html) %}
{%- if (multipleTabs) or (not params.hideTab) %}
<div class="app-tabs__heading js-tabs__heading{{ " js-tabs__heading--open" if (params.open) }}"><a href="#{{ exampleId }}-html" aria-controls="{{ exampleId }}-html" data-track="tab-html">HTML</a></div>
<div class="app-tabs__heading js-tabs__heading"><a href="#{{ exampleId }}-html" aria-controls="{{ exampleId }}-html" data-track="tab-html">HTML</a></div>
{% endif %}
<div class="app-tabs__container js-tabs__container{{ " js-tabs__container--no-tabs" if (params.hideTab) }}" id="{{ exampleId }}-html" role="tabpanel">
<div class="app-example__code">
Expand All @@ -76,7 +72,7 @@
{%- if (multipleTabs) %}
<div class="app-tabs__heading js-tabs__heading"><a class="app-tabs__heading-link" href="#{{ exampleId }}-nunjucks" aria-controls="{{ exampleId }}-nunjucks" data-track="tab-nunjucks">Nunjucks</a></div>
{% elif not (params.hideTab) %}
<div class="app-tabs__heading js-tabs__heading{{ " js-tabs__heading--open" if (params.open) }}"><a href="#{{ exampleId }}-nunjucks" role="tab" aria-controls="{{ exampleId }}-nunjucks" data-track="tab-nunjucks">Nunjucks</a></div>
<div class="app-tabs__heading js-tabs__heading"><a href="#{{ exampleId }}-nunjucks" role="tab" aria-controls="{{ exampleId }}-nunjucks" data-track="tab-nunjucks">Nunjucks</a></div>
{% endif %}
<div class="app-tabs__container js-tabs__container{{ " js-tabs__container--no-tabs" if (params.hideTab) }}" id="{{ exampleId }}-nunjucks" role="tabpanel">
{%- if (params.group == 'components') %}
Expand Down

0 comments on commit ead7548

Please sign in to comment.