Skip to content

Instantly share code, notes, and snippets.

@cferdinandi
Last active February 20, 2024 16:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cferdinandi/387c9b7db4997248f674040fe526fbe0 to your computer and use it in GitHub Desktop.
Save cferdinandi/387c9b7db4997248f674040fe526fbe0 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html>
<head>
<title>Toggle Tabs</title>
<style type="text/css">
body {
margin: 1em auto;
max-width: 30em;
width: 88%;
}
toggle-tabs {
/* The navigation list */
--toggle-tab-list-margin: 0 0 2em;
/* Toggle nav links */
--toggle-tab-link-color: currentColor;
--toggle-tab-link-margin: 0 0 0.25em;
--toggle-tab-link-padding: 0.5em 1em;
/* Toggle nav link :hover */
--toggle-tab-list-hover-bg-color: #f7f7f7;
/* The active toggle nav link */
--toggle-tab-link-active-bg-color: #e5e5e5;
}
</style>
</head>
<body>
<h1>Toggle Tabs</h1>
<toggle-tabs>
<ul tabs>
<li><a href="#wizard">Wizard</a></li>
<li><a href="#sorcerer">Sorcerer</a></li>
<li><a href="#druid">Druid</a></li>
</ul>
<div id="wizard">
Wizards gain their magic through study...
</div>
<div id="sorcerer">
Sorcerers get their power from an otherworldly being...
</div>
<div id="druid">
Druids get their power from nature.
</div>
</toggle-tabs>
<script>
customElements.define('toggle-tabs', class extends HTMLElement {
/**
* Instantiate the Web Component
*/
constructor () {
// Get parent class properties
super();
// Define properties
this.tabList = this.querySelector('[tabs]');
// Setup UI
this.setupDOM();
this.loadCSS();
this.clickHandler = this.createClickHandler();
this.keyHandler = this.createKeyHandler();
}
/**
* Add buttons and hide content on page load
*/
setupDOM () {
// Only run if there are tabs
if (!this.tabList) return;
// Get the list items and links
let listItems = this.tabList.querySelectorAll('li');
let links = this.tabList.querySelectorAll('a');
// Add ARIA to list
this.tabList.setAttribute('role', 'tablist');
// Add ARIA to the list items
for (let item of listItems) {
item.setAttribute('role', 'presentation');
}
// Add ARIA to the links and content
let instance = this;
links.forEach(function (link, index) {
// Get the the target element
let tabPane = instance.querySelector(link.hash);
if (!tabPane) return;
// Add [role] and [aria-selected] attributes
link.setAttribute('role', 'tab');
link.setAttribute('aria-selected', index === 0 ? true : false);
// If it's not the active (first) tab, remove focus
if (index > 0) {
link.setAttribute('tabindex', -1);
}
// If there's no ID, add one
if (!link.id) {
link.id = `tab_${tabPane.id}`;
}
// Add ARIA to tab pane
tabPane.setAttribute('role', 'tabpanel');
tabPane.setAttribute('aria-labelledby', link.id);
// If not the active pane, hide it
if (index > 0) {
tabPane.setAttribute('hidden', '');
}
});
}
/**
* Load accordion styles
*/
loadCSS () {
if (document.querySelector('[data-toggle-tabs-css]')) return;
let css = document.createElement('style');
css.innerHTML =
`toggle-tabs [role="tablist"] {
list-style: none;
margin: var(--toggle-tab-list-margin, 0 0 2em);
padding: 0;
}
toggle-tabs [role="tablist"] li {
display: inline-block;
}
toggle-tabs [role="tab"] {
color: var(--toggle-tab-link-color, currentColor);
margin: var(--toggle-tab-link-margin, 0 0 0.25em);
padding: var(--toggle-tab-link-padding, 0.5em 1em);
text-decoration: none;
}
toggle-tabs [role="tab"]:active,
toggle-tabs [role="tab"]:hover {
background-color: var(--toggle-tab-list-hover-bg-color, #f7f7f7);
}
toggle-tabs [role="tab"][aria-selected="true"] {
background-color: var(--toggle-tab-link-active-bg-color, #e5e5e5);
}`;
css.setAttribute('data-toggle-tabs-css', '');
document.head.append(css);
}
/**
* Create the event handler
*/
createClickHandler () {
return (event) => {
// Only run on tab links
if (!event.target.matches('[role="tab"]')) return;
// Prevent the link from updating the URL
event.preventDefault();
// Ignore the currently active tab
if (event.target.matches('[aria-selected="true"]')) return;
// Toggle tab visibility
this.toggle(event.target);
};
}
createKeyHandler () {
return (event) => {
// Only run for left and right arrow keys
if (!['ArrowLeft', 'ArrowRight'].includes(event.code)) return;
// Only run if element in focus is on a tab
let tab = document.activeElement.closest('[role="tab"]');
if (!tab) return;
// Only run if focused tab is in this component
if (!this.tabList.contains(tab)) return;
// Get the currently active tab
let currentTab = this.tabList.querySelector('[role="tab"][aria-selected="true"]');
// Get the parent list item
let listItem = currentTab.closest('li');
// If right arrow, get the next sibling
// Otherwise, get the previous
let nextListItem = event.code === 'ArrowRight' ? listItem.nextElementSibling : listItem.previousElementSibling;
if (!nextListItem) return;
let nextTab = nextListItem.querySelector('a');
// Toggle tab visibility
this.toggle(nextTab);
nextTab.focus();
};
}
/**
* Toggle tab visibility
* @param {Node} tab The tab to show
*/
toggle (tab) {
// Get the target tab pane
let tabPane = this.querySelector(tab.hash);
if (!tabPane) return;
// Get the current tab and content
let currentTab = tab.closest('[role="tablist"]').querySelector('[aria-selected="true"]');
let currentPane = document.querySelector(currentTab.hash);
// Update the selected tab
tab.setAttribute('aria-selected', true);
currentTab.setAttribute('aria-selected', false);
// Update the visible tabPane
tabPane.removeAttribute('hidden');
currentPane.setAttribute('hidden', '');
// Make sure current tab can be focused and other tabs cannot
tab.removeAttribute('tabindex');
currentTab.setAttribute('tabindex', -1);
}
/**
* Start listening to clicks
*/
connectedCallback () {
if (!this.tabList) return;
this.tabList.addEventListener('click', this.clickHandler);
document.addEventListener('keydown', this.keyHandler);
}
/**
* Stop listening to clicks
*/
disconnectedCallback () {
this.tabList.removeEventListener('click', this.clickHandler);
document.removeEventListener('keydown', this.keyHandler);
}
});
</script>
</body>
</html>
@adamjohnson
Copy link

What are your thoughts about adding support for Home and End functionality?

Reference: ARIA Authoring Practices Guidelines - Tabs

@cferdinandi
Copy link
Author

@adamjohnson I didn't implement it myself because it's optional and would ramp up the complexity of the code quite a bit. But as a boilerplate, I highly encourage folks to add things like this if they want to. These tools are designed to be starting points.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment