The Anatomy of a Tablist Component in Vanilla JavaScript Versus React
Publikováno: 5.5.2020
If you follow the undercurrent of the JavaScript community, there seems to be a divide as of late. It goes back over a decade. Really, this sort of strife has always been. Perhaps it is human nature.
Whenever a popular framework gains traction, you inevitably see people comparing it to rivals. I suppose that is to be expected. Everyone has a particular favorite.
Lately, the framework everyone loves (to hate?) is React. You often see it pitted against others … Read article “The Anatomy of a Tablist Component in Vanilla JavaScript Versus React”
The post The Anatomy of a Tablist Component in Vanilla JavaScript Versus React appeared first on CSS-Tricks.
If you follow the undercurrent of the JavaScript community, there seems to be a divide as of late. It goes back over a decade. Really, this sort of strife has always been. Perhaps it is human nature.
Whenever a popular framework gains traction, you inevitably see people comparing it to rivals. I suppose that is to be expected. Everyone has a particular favorite.
Lately, the framework everyone loves (to hate?) is React. You often see it pitted against others in head-to-head blog posts and feature comparison matrices of enterprise whitepapers. Yet a few years ago, it seemed like jQuery would forever be king of the hill.
Frameworks come and go. To me, what is more interesting is when React — or any JS framework for that matter — gets pitted against the programming language itself. Because of course, under the hood, it is all built atop JS.
The two are not inherently at odds. I would even go so far as to say that if you do not have a good handle on JS fundamentals, you probably are not going to reap the full benefits of using React. It can still be helpful, similar to using a jQuery plugin without understanding its internals. But I feel like React presupposes more JS familiarity.
HTML is equally important. There exists a fair bit of FUD around how React affects accessibility. I think this narrative is inaccurate. In fact, the ESLint JSX a11y plugin will warn of possible accessibility violations in the console
.
Recently, an annual study of the top 1 million sites was released. It shows that for sites using JS frameworks, there is an increased likelihood of accessibility problems. This is correlation, not causation.
This does not necessarily mean that the frameworks caused these errors, but it does indicate that home pages with these frameworks had more errors than on average.
In a manner of speaking, React’s magic incantations work regardless of whether you recognize the words. Ultimately, you are still responsible for the outcome.
Philosophical musings aside, I am a firm believer in choosing the best tool for the job. Sometimes, that means building a single page app with a Jamstack approach. Or maybe a particular project is better suited to offloading HTML rendering to the server, where it has historically been handled.
Either way, there inevitably comes the need for JS to augment the user experience. At Reaktiv Studios, to that end I have been attempting to keep most of our React components in sync with our “flat HTML” approach. I have been writing commonly used functionality in vanilla JS as well. This keeps our options open, so that our clients are free to choose. It also allows us to reuse the same CSS.
If I may, I would like to share how I built our <Tabs>
and <Accordion>
React components. I will also demonstrate how I wrote the same functionality without using a framework.
Hopefully, this lesson will feel like we are making a layered cake. Let us first start with the base markup, then cover the vanilla JS, and finish with how it works in React.
For reference, you can tinker with our live examples:
Flat HTML examples
Since we need JavaScript to make interactive widgets either way, I figured the easiest approach — from a server side implementation standpoint — would be to require only the bare minimum HTML. The rest can be augmented with JS.
The following are examples of markup for tabs and accordion components, showing a before/after comparison of how JS affects the DOM.
I have added id="TABS_ID"
and id="ACCORDION_ID"
for demonstrative purposes. This is to make it more obvious what is happening. But the JS that I will be explaining automatically generates unique IDs if nothing is supplied in the HTML. It would work fine either way, with or without an id
specified.
Tabs (without ARIA)
<div class="tabs" id="TABS_ID">
<ul class="tabs__list">
<li class="tabs__item">
Tab 1
</li>
<!-- .tabs__item -->
<li class="tabs__item">
Tab 2
</li>
<!-- .tabs__item -->
<li class="tabs__item" disabled>
Tab 3 (disabled)
</li>
<!-- .tabs__item -->
</ul>
<!-- .tabs__list -->
<div class="tabs__panel">
<p>
Tab 1 content
</p>
</div>
<!-- .tabs__panel -->
<div class="tabs__panel">
<p>
Tab 2 content
</p>
</div>
<!-- .tabs__panel -->
<div class="tabs__panel">
<p>
NOTE: This tab is disabled.
</p>
</div>
<!-- .tabs__panel -->
</div>
<!-- .tabs -->
Tabs (with ARIA)
<div class="tabs" id="TABS_ID">
<ul class="tabs__list" role="tablist">
<li
aria-controls="tabpanel_TABS_ID_0"
aria-selected="false"
class="tabs__item"
id="tab_TABS_ID_0"
role="tab"
tabindex="0"
>
Tab 1
</li>
<!-- .tabs__item -->
<li
aria-controls="tabpanel_TABS_ID_1"
aria-selected="true"
class="tabs__item"
id="tab_TABS_ID_1"
role="tab"
tabindex="0"
>
Tab 2
</li>
<!-- .tabs__item -->
<li
aria-controls="tabpanel_TABS_ID_2"
aria-disabled="true"
aria-selected="false"
class="tabs__item"
disabled
id="tab_TABS_ID_2"
role="tab"
>
Tab 3 (disabled)
</li>
<!-- .tabs__item -->
</ul>
<!-- .tabs__list -->
<div
aria-hidden="true"
aria-labelledby="tab_TABS_ID_0"
class="tabs__panel"
id="tabpanel_TABS_ID_0"
role="tabpanel"
>
<p>
Tab 1 content
</p>
</div>
<!-- .tabs__panel -->
<div
aria-hidden="false"
aria-labelledby="tab_TABS_ID_1"
class="tabs__panel"
id="tabpanel_TABS_ID_1"
role="tabpanel"
>
<p>
Tab 2 content
</p>
</div>
<!-- .tabs__panel -->
<div
aria-hidden="true"
aria-labelledby="tab_TABS_ID_2"
class="tabs__panel"
id="tabpanel_TABS_ID_2"
role="tabpanel"
>
<p>
NOTE: This tab is disabled.
</p>
</div>
<!-- .tabs__panel -->
</div>
<!-- .tabs -->
Accordion (without ARIA)
<div class="accordion" id="ACCORDION_ID">
<div class="accordion__item">
Tab 1
</div>
<!-- .accordion__item -->
<div class="accordion__panel">
<p>
Tab 1 content
</p>
</div>
<!-- .accordion__panel -->
<div class="accordion__item">
Tab 2
</div>
<!-- .accordion__item -->
<div class="accordion__panel">
<p>
Tab 2 content
</p>
</div>
<!-- .accordion__panel -->
<div class="accordion__item" disabled>
Tab 3 (disabled)
</div>
<!-- .accordion__item -->
<div class="accordion__panel">
<p>
NOTE: This tab is disabled.
</p>
</div>
<!-- .accordion__panel -->
</div>
<!-- .accordion -->
Accordion (with ARIA)
<div
aria-multiselectable="true"
class="accordion"
id="ACCORDION_ID"
role="tablist"
>
<div
aria-controls="tabpanel_ACCORDION_ID_0"
aria-selected="true"
class="accordion__item"
id="tab_ACCORDION_ID_0"
role="tab"
tabindex="0"
>
<i aria-hidden="true" class="accordion__item__icon"></i>
Tab 1
</div>
<!-- .accordion__item -->
<div
aria-hidden="false"
aria-labelledby="tab_ACCORDION_ID_0"
class="accordion__panel"
id="tabpanel_ACCORDION_ID_0"
role="tabpanel"
>
<p>
Tab 1 content
</p>
</div>
<!-- .accordion__panel -->
<div
aria-controls="tabpanel_ACCORDION_ID_1"
aria-selected="false"
class="accordion__item"
id="tab_ACCORDION_ID_1"
role="tab"
tabindex="0"
>
<i aria-hidden="true" class="accordion__item__icon"></i>
Tab 2
</div>
<!-- .accordion__item -->
<div
aria-hidden="true"
aria-labelledby="tab_ACCORDION_ID_1"
class="accordion__panel"
id="tabpanel_ACCORDION_ID_1"
role="tabpanel"
>
<p>
Tab 2 content
</p>
</div>
<!-- .accordion__panel -->
<div
aria-controls="tabpanel_ACCORDION_ID_2"
aria-disabled="true"
aria-selected="false"
class="accordion__item"
disabled
id="tab_ACCORDION_ID_2"
role="tab"
>
<i aria-hidden="true" class="accordion__item__icon"></i>
Tab 3 (disabled)
</div>
<!-- .accordion__item -->
<div
aria-hidden="true"
aria-labelledby="tab_ACCORDION_ID_2"
class="accordion__panel"
id="tabpanel_ACCORDION_ID_2"
role="tabpanel"
>
<p>
NOTE: This tab is disabled.
</p>
</div>
<!-- .accordion__panel -->
</div>
<!-- .accordion -->
Vanilla JavaScript examples
Okay. Now that we have seen the aforementioned HTML examples, let us walk through how we get from before to after.
First, I want to cover a few helper functions. These will make more sense in a bit. I figure it is best to get them documented first, so we can stay focused on the rest of the code once we dive in further.
File: getDomFallback.js
This function provides common DOM properties and methods as no-op, rather than having to make lots of typeof foo.getAttribute
checks and whatnot. We could forego those types of confirmations altogether.
Since live HTML changes can be a potentially volatile environment, I always feel a bit safer making sure my JS is not bombing out and taking the rest of the page with it. Here is what that function looks like. It simply returns an object with the DOM equivalents of falsy results.
/*
Helper to mock DOM methods, for
when an element might not exist.
*/
const getDomFallback = () => {
return {
// Props.
children: [],
className: '',
classList: {
contains: () => false,
},
id: '',
innerHTML: '',
name: '',
nextSibling: null,
previousSibling: null,
outerHTML: '',
tagName: '',
textContent: '',
// Methods.
appendChild: () => Object.create(null),
cloneNode: () => Object.create(null),
closest: () => null,
createElement: () => Object.create(null),
getAttribute: () => null,
hasAttribute: () => false,
insertAdjacentElement: () => Object.create(null),
insertBefore: () => Object.create(null),
querySelector: () => null,
querySelectorAll: () => [],
removeAttribute: () => undefined,
removeChild: () => Object.create(null),
replaceChild: () => Object.create(null),
setAttribute: () => undefined,
};
};
// Export.
export { getDomFallback };
File: unique.js
This function is a poor man’s UUID equivalent.
It generates a unique string that can be used to associate DOM elements with one another. It is handy, because then the author of an HTML page does not have to ensure that every tabs and accordion component have unique IDs. In the previous HTML examples, this is where TABS_ID
and ACCORDION_ID
would typically contain the randomly generated numeric strings instead.
// ==========
// Constants.
// ==========
const BEFORE = '0.';
const AFTER = '';
// ==================
// Get unique string.
// ==================
const unique = () => {
// Get prefix.
let prefix = Math.random();
prefix = String(prefix);
prefix = prefix.replace(BEFORE, AFTER);
// Get suffix.
let suffix = Math.random();
suffix = String(suffix);
suffix = suffix.replace(BEFORE, AFTER);
// Expose string.
return `${prefix}_${suffix}`;
};
// Export.
export { unique };
On larger JavaScript projects, I would typically use npm install uuid
. But since we are keeping this simple and do not require cryptographic parity, concatenating two lightly edited Math.random()
numbers will suffice for our string
uniqueness needs.
File: tablist.js
This file does the bulk of the work. What is cool about it, if I do say so myself, is that there are enough similarities between a tabs component and an accordion that we can handle both with the same *.js
file. Go ahead and scroll through the entirety, and then we will break down what each function does individually.
// Helpers.
import { getDomFallback } from './getDomFallback';
import { unique } from './unique';
// ==========
// Constants.
// ==========
// Boolean strings.
const TRUE = 'true';
const FALSE = 'false';
// ARIA strings.
const ARIA_CONTROLS = 'aria-controls';
const ARIA_DISABLED = 'aria-disabled';
const ARIA_LABELLEDBY = 'aria-labelledby';
const ARIA_HIDDEN = 'aria-hidden';
const ARIA_MULTISELECTABLE = 'aria-multiselectable';
const ARIA_SELECTED = 'aria-selected';
// Attribute strings.
const DISABLED = 'disabled';
const ID = 'id';
const ROLE = 'role';
const TABLIST = 'tablist';
const TABINDEX = 'tabindex';
// Event strings.
const CLICK = 'click';
const KEYDOWN = 'keydown';
// Key strings.
const ENTER = 'enter';
const FUNCTION = 'function';
// Tag strings.
const LI = 'li';
// Selector strings.
const ACCORDION_ITEM_ICON = 'accordion__item__icon';
const ACCORDION_ITEM_ICON_SELECTOR = `.${ACCORDION_ITEM_ICON}`;
const TAB = 'tab';
const TAB_SELECTOR = `[${ROLE}=${TAB}]`;
const TABPANEL = 'tabpanel';
const TABPANEL_SELECTOR = `[${ROLE}=${TABPANEL}]`;
const ACCORDION = 'accordion';
const TABLIST_CLASS_SELECTOR = '.accordion, .tabs';
const TAB_CLASS_SELECTOR = '.accordion__item, .tabs__item';
const TABPANEL_CLASS_SELECTOR = '.accordion__panel, .tabs__panel';
// ===========
// Get tab ID.
// ===========
const getTabId = (id = '', index = 0) => {
return `tab_${id}_${index}`;
};
// =============
// Get panel ID.
// =============
const getPanelId = (id = '', index = 0) => {
return `tabpanel_${id}_${index}`;
};
// ==============
// Click handler.
// ==============
const globalClick = (event = {}) => {
// Get target.
const { key = '', target = getDomFallback() } = event;
// Get parent.
const { parentNode = getDomFallback(), tagName = '' } = target;
// Set later.
let wrapper = getDomFallback();
/*
=====
NOTE:
=====
We test for this, because the method does
not exist on `document.documentElement`.
*/
if (typeof target.closest === FUNCTION) {
// Get wrapper.
wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback();
}
// Is `<li>`?
const isListItem = tagName.toLowerCase() === LI;
// Is multi?
const isMulti = wrapper.getAttribute(ARIA_MULTISELECTABLE) === TRUE;
// Valid key?
const isValidKey = !key || key.toLowerCase() === ENTER;
// Valid target?
const isValidTarget =
!target.hasAttribute(DISABLED) &&
target.getAttribute(ROLE) === TAB &&
parentNode.getAttribute(ROLE) === TABLIST;
// Valid event?
const isValidEvent = isValidKey && isValidTarget;
// Continue?
if (isValidEvent) {
// Get panel.
const panelId = target.getAttribute(ARIA_CONTROLS);
const panel = wrapper.querySelector(`#${panelId}`) || getDomFallback();
// Get booleans.
let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE;
let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE;
// List item?
if (isListItem) {
boolPanel = FALSE;
boolTab = TRUE;
}
// [aria-multiselectable="false"]
if (!isMulti) {
// Get tabs & panels.
const childTabs = wrapper.querySelectorAll(TAB_SELECTOR);
const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR);
// Loop through tabs.
childTabs.forEach((tab = getDomFallback()) => {
tab.setAttribute(ARIA_SELECTED, FALSE);
});
// Loop through panels.
childPanels.forEach((panel = getDomFallback()) => {
panel.setAttribute(ARIA_HIDDEN, TRUE);
});
}
// Set individual tab.
target.setAttribute(ARIA_SELECTED, boolTab);
// Set individual panel.
panel.setAttribute(ARIA_HIDDEN, boolPanel);
}
};
// ====================
// Add ARIA attributes.
// ====================
const addAriaAttributes = () => {
// Get elements.
const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR);
// Loop through.
allWrappers.forEach((wrapper = getDomFallback()) => {
// Get attributes.
const { id = '', classList } = wrapper;
const parentId = id || unique();
// Is accordion?
const isAccordion = classList.contains(ACCORDION);
// Get tabs & panels.
const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR);
const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR);
// Add ID?
if (!wrapper.getAttribute(ID)) {
wrapper.setAttribute(ID, parentId);
}
// Add multi?
if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) {
wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE);
}
// ===========================
// Loop through tabs & panels.
// ===========================
for (let index = 0; index < childTabs.length; index++) {
// Get elements.
const tab = childTabs[index] || getDomFallback();
const panel = childPanels[index] || getDomFallback();
// Get IDs.
const tabId = getTabId(parentId, index);
const panelId = getPanelId(parentId, index);
// ===================
// Add tab attributes.
// ===================
// Tab: add icon?
if (isAccordion) {
// Get icon.
let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR);
// Create icon?
if (!icon) {
icon = document.createElement(I);
icon.className = ACCORDION_ITEM_ICON;
tab.insertAdjacentElement(AFTER_BEGIN, icon);
}
// [aria-hidden="true"]
icon.setAttribute(ARIA_HIDDEN, TRUE);
}
// Tab: add id?
if (!tab.getAttribute(ID)) {
tab.setAttribute(ID, tabId);
}
// Tab: add controls?
if (!tab.getAttribute(ARIA_CONTROLS)) {
tab.setAttribute(ARIA_CONTROLS, panelId);
}
// Tab: add selected?
if (!tab.getAttribute(ARIA_SELECTED)) {
const bool = !isAccordion && index === 0;
tab.setAttribute(ARIA_SELECTED, bool);
}
// Tab: add role?
if (tab.getAttribute(ROLE) !== TAB) {
tab.setAttribute(ROLE, TAB);
}
// Tab: add tabindex?
if (tab.hasAttribute(DISABLED)) {
tab.removeAttribute(TABINDEX);
tab.setAttribute(ARIA_DISABLED, TRUE);
} else {
tab.setAttribute(TABINDEX, 0);
}
// Tab: first item?
if (index === 0) {
// Get parent.
const { parentNode = getDomFallback() } = tab;
/*
We do this here, instead of outside the loop.
The top level item isn't always the `tablist`.
The accordion UI only has `<dl>`, whereas
the tabs UI has both `<div>` and `<ul>`.
*/
if (parentNode.getAttribute(ROLE) !== TABLIST) {
parentNode.setAttribute(ROLE, TABLIST);
}
}
// =====================
// Add panel attributes.
// =====================
// Panel: add ID?
if (!panel.getAttribute(ID)) {
panel.setAttribute(ID, panelId);
}
// Panel: add hidden?
if (!panel.getAttribute(ARIA_HIDDEN)) {
const bool = isAccordion || index !== 0;
panel.setAttribute(ARIA_HIDDEN, bool);
}
// Panel: add labelled?
if (!panel.getAttribute(ARIA_LABELLEDBY)) {
panel.setAttribute(ARIA_LABELLEDBY, tabId);
}
// Panel: add role?
if (panel.getAttribute(ROLE) !== TABPANEL) {
panel.setAttribute(ROLE, TABPANEL);
}
}
});
};
// =====================
// Remove global events.
// =====================
const unbind = () => {
document.removeEventListener(CLICK, globalClick);
document.removeEventListener(KEYDOWN, globalClick);
};
// ==================
// Add global events.
// ==================
const init = () => {
// Add attributes.
addAriaAttributes();
// Prevent doubles.
unbind();
document.addEventListener(CLICK, globalClick);
document.addEventListener(KEYDOWN, globalClick);
};
// ==============
// Bundle object.
// ==============
const tablist = {
init,
unbind,
};
// =======
// Export.
// =======
export { tablist };
Function: getTabId
and getPanelId
These two functions are used to create individually unique IDs for elements in a loop, based on an existing (or generated) parent ID. This is helpful to ensure matching values for attributes like aria-controls="…"
and aria-labelledby="…"
. Think of those as the accessibility equivalents of <label for="…">
, telling the browser which elements are related to one another.
const getTabId = (id = '', index = 0) => {
return `tab_${id}_${index}`;
};
const getPanelId = (id = '', index = 0) => {
return `tabpanel_${id}_${index}`;
};
Function: globalClick
This is a click handler that is applied at the document
level. That means we are not having to manually add click handlers to a number of elements. Instead, we use event bubbling to listen for clicks further down in the document, and allow them to propagate up to the top. Conveniently, this is also how we can handle keyboard events such as the Enter
key being pressed. Both are necessary to have an accessible UI.
In the first part of the function, we destructure key
and target
from the incoming event
. Next, we destructure the parentNode
and tagName
from the target
.
Then, we attempt to get the wrapper element. This would be the one with either class="tabs"
or class="accordion"
. Because we might actually be clicking on the ancestor element highest in the DOM tree — which exists but possibly does not have the *.closest(…)
method — we do a typeof
check. If that function exists, we attempt to get the element. Even still, we might come up without a match. So we have one more getDomFallback
to be safe.
// Get target.
const { key = '', target = getDomFallback() } = event;
// Get parent.
const { parentNode = getDomFallback(), tagName = '' } = target;
// Set later.
let wrapper = getDomFallback();
/*
=====
NOTE:
=====
We test for this, because the method does
not exist on `document.documentElement`.
*/
if (typeof target.closest === FUNCTION) {
// Get wrapper.
wrapper = target.closest(TABLIST_CLASS_SELECTOR) || getDomFallback();
}
Then, we store whether or not the tag that was clicked is a <li>
. Likewise, we store a boolean about whether the wrapper element has aria-multiselectable="true"
. I will get back to that. We need this info later on.
We also interrogate the event a bit, to determine if it was triggered by the user pressing a key. If so, then we are only interested if that key was Enter
. We also determine if the click happened on a relevant target
. Remember, we are using event bubbling so really the user could have clicked anything.
We want to make sure it:
- Is not
disabled
- Has
role="tab"
- Has a parent element with
role="tablist"
Then we bundle up our event
and target
booleans into one, as isValidEvent
.
// Is `<li>`?
const isListItem = tagName.toLowerCase() === LI;
// Is multi?
const isMulti = wrapper.getAttribute(ARIA_MULTISELECTABLE) === TRUE;
// Valid key?
const isValidKey = !key || key.toLowerCase() === ENTER;
// Valid target?
const isValidTarget =
!target.hasAttribute(DISABLED) &&
target.getAttribute(ROLE) === TAB &&
parentNode.getAttribute(ROLE) === TABLIST;
// Valid event?
const isValidEvent = isValidKey && isValidTarget;
Assuming the event
is indeed valid, we make it past our next if
check. Now, we are concerned with getting the role="tabpanel"
element with an id
that matches our tab’s aria-controls="…"
.
Once we have got it, we check whether the panel is hidden, and if the tab is selected. Basically, we first presuppose that we are dealing with an accordion and flip the booleans to their opposites.
This is also where our earlier isListItem
boolean comes into play. If the user is clicking an <li>
then we know we are dealing with tabs, not an accordion. In which case, we want to flag our panel as being visible (via aria-hiddden="false"
) and our tab as being selected (via aria-selected="true"
).
Also, we want to ensure that either the wrapper has aria-multiselectable="false"
or is completely missing aria-multiselectable
. If that is the case, then we loop through all neighboring role="tab"
and all role="tabpanel"
elements and set them to their inactive states. Finally, we arrive at setting the previously determined booleans for the individual tab and panel pairing.
// Continue?
if (isValidEvent) {
// Get panel.
const panelId = target.getAttribute(ARIA_CONTROLS);
const panel = wrapper.querySelector(`#${panelId}`) || getDomFallback();
// Get booleans.
let boolPanel = panel.getAttribute(ARIA_HIDDEN) !== TRUE;
let boolTab = target.getAttribute(ARIA_SELECTED) !== TRUE;
// List item?
if (isListItem) {
boolPanel = FALSE;
boolTab = TRUE;
}
// [aria-multiselectable="false"]
if (!isMulti) {
// Get tabs & panels.
const childTabs = wrapper.querySelectorAll(TAB_SELECTOR);
const childPanels = wrapper.querySelectorAll(TABPANEL_SELECTOR);
// Loop through tabs.
childTabs.forEach((tab = getDomFallback()) => {
tab.setAttribute(ARIA_SELECTED, FALSE);
});
// Loop through panels.
childPanels.forEach((panel = getDomFallback()) => {
panel.setAttribute(ARIA_HIDDEN, TRUE);
});
}
// Set individual tab.
target.setAttribute(ARIA_SELECTED, boolTab);
// Set individual panel.
panel.setAttribute(ARIA_HIDDEN, boolPanel);
}
Function: addAriaAttributes
The astute reader might be thinking:
You said earlier that we start with the most bare possible markup, yet the
globalClick
function was looking for attributes that would not be there. Why would you lie!?
Or perhaps not, for the astute reader would have also noticed the function named addAriaAttributes
. Indeed, this function does exactly what it says on the tin. It breathes life into the base DOM structure, by adding all the requisite aria-*
and role
attributes.
This not only makes the UI inherently more accessible to assistive technologies, but it also ensures the functionality actually works. I prefer to build vanilla JS things this way, rather than pivoting on class="…"
for interactivity, because it forces me to think about the entirety of the user experience, beyond what I can see visually.
First off, we get all elements on the page that have class="tabs"
and/or class="accordion"
. Then we check if we have something to work with. If not, then we would exit our function here. Assuming we do have a list, we loop through each of the wrapping elements and pass them into the scope of our function as wrapper
.
// Get elements.
const allWrappers = document.querySelectorAll(TABLIST_CLASS_SELECTOR);
// Loop through.
allWrappers.forEach((wrapper = getDomFallback()) => {
/*
NOTE: Cut, for brevity.
*/
});
Inside the scope of our looping function, we destructure id
and classList
from wrapper
. If there is no ID, then we generate one via unique()
. We set a boolean flag, to identify if we are working with an accordion. This is used later.
We also get decendants of wrapper
that are tabs and panels, via their class name selectors.
Tabs:
class="tabs__item"
orclass="accordion__item"
Panels:
class="tabs__panel"
orclass="accordion__panel"
We then set the wrapper’s id
if it does not already have one.
If we are dealing with an accordion that lacks aria-multiselectable="false"
, we set its flag to true
. Reason being, if developers are reaching for an accordion UI paradigm — and also have tabs available to them, which are inherently mutually exclusive — then the safer assumption is that the accordion should support expanding and collapsing of several panels.
// Get attributes.
const { id = '', classList } = wrapper;
const parentId = id || unique();
// Is accordion?
const isAccordion = classList.contains(ACCORDION);
// Get tabs & panels.
const childTabs = wrapper.querySelectorAll(TAB_CLASS_SELECTOR);
const childPanels = wrapper.querySelectorAll(TABPANEL_CLASS_SELECTOR);
// Add ID?
if (!wrapper.getAttribute(ID)) {
wrapper.setAttribute(ID, parentId);
}
// Add multi?
if (isAccordion && wrapper.getAttribute(ARIA_MULTISELECTABLE) !== FALSE) {
wrapper.setAttribute(ARIA_MULTISELECTABLE, TRUE);
}
Next, we loop through tabs. Wherein, we also handle our panels.
You may be wondering why this is an old school for
loop, instead of a more modern *.forEach
. The reason is that we want to loop through two NodeList
instances: tabs and panels. Assuming they each map 1-to-1 we know they both have the same *.length
. This allows us to have one loop instead of two.
Let us peer inside of the loop. First, we get unique IDs for each tab and panel. These would look like one of the two following scenarios. These are used later on, to associate tabs with panels and vice versa.
tab_WRAPPER_ID_0
ortab_GENERATED_STRING_0
tabpanel_WRAPPER_ID_0
ortabpanel_GENERATED_STRING_0
for (let index = 0; index < childTabs.length; index++) {
// Get elements.
const tab = childTabs[index] || getDomFallback();
const panel = childPanels[index] || getDomFallback();
// Get IDs.
const tabId = getTabId(parentId, index);
const panelId = getPanelId(parentId, index);
/*
NOTE: Cut, for brevity.
*/
}
As we loop through, we first ensure that an expand/collapse icon exists. We create it if necessary, and set it to aria-hidden="true"
since it is purely decorative.
Next, we check on attributes for the current tab. If an id="…"
does not exist on the tab, we add it. Likewise, if aria-controls="…"
does not exist we add that as well, pointing to our newly created panelId
.
You will notice there is a little pivot here, checking if we do not have aria-selected
and then further determining if we are not in the context of an accordion and if the index
is 0
. In that case, we want to make our first tab look selected. The reason is that though an accordion can be fully collapsed, tabbed content cannot. There is always at least one panel visible.
Then we ensure that role="tab"
exists.
It is worth noting we do some extra work, based on whether the tab is disabled
. If so, we remove tabindex
so that the tab cannot receive :focus
. If the tab is not disabled, we add tabindex="0"
so that it can receive :focus
.
We also set aria-disabled="true"
, if need be. You might be wondering if that is redundant. But it is necessary to inform assistive technologies that the tab is not interactive. Since our tab is either a <div>
or <li>
, it technically cannot be disabled
like an <input>
. Our styles pivot on [disabled]
, so we get that for free. Plus, it is less cognitive overhead (as a developer creating HTML) to only worry about one attribute.
ℹ️ Fun Fact: It is also worth noting the use of hasAttribute(…)
to detect disabled
, instead of getAttribute(…)
. This is because the mere presence of disabled
will cause form elements to be disabled.
If the HTML is compiled, via tools such as Parcel…
- Markup like this:
<tag disabled>
- Is changed to this:
<tag disabled="">
In which case, getting the attribute is still a falsy string
.
In the days of XHTML, that would have been disabled="disabled"
. But really, it was only ever the existence of the attribute that mattered. Not its value. That is why we simply test if the element has the disabled
attribute.
Lastly, we check if we are on the first iteration of our loop where index
is 0
. If so, we go up one level to the parentNode
. If that element does not have role="tablist"
, then we add it.
We do this via parentNode
instead of wrapper
because in the context of tabs (not accordion) there is a <ul>
element around the tab <li>
that needs role="tablist"
. In the case of an accordion, it would be the outermost <div>
ancestor. This code accounts for both.
// Tab: add icon?
if (isAccordion) {
// Get icon.
let icon = tab.querySelector(ACCORDION_ITEM_ICON_SELECTOR);
// Create icon?
if (!icon) {
icon = document.createElement(I);
icon.className = ACCORDION_ITEM_ICON;
tab.insertAdjacentElement(AFTER_BEGIN, icon);
}
// [aria-hidden="true"]
icon.setAttribute(ARIA_HIDDEN, TRUE);
}
// Tab: add id?
if (!tab.getAttribute(ID)) {
tab.setAttribute(ID, tabId);
}
// Tab: add controls?
if (!tab.getAttribute(ARIA_CONTROLS)) {
tab.setAttribute(ARIA_CONTROLS, panelId);
}
// Tab: add selected?
if (!tab.getAttribute(ARIA_SELECTED)) {
const bool = !isAccordion && index === 0;
tab.setAttribute(ARIA_SELECTED, bool);
}
// Tab: add role?
if (tab.getAttribute(ROLE) !== TAB) {
tab.setAttribute(ROLE, TAB);
}
// Tab: add tabindex?
if (tab.hasAttribute(DISABLED)) {
tab.removeAttribute(TABINDEX);
tab.setAttribute(ARIA_DISABLED, TRUE);
} else {
tab.setAttribute(TABINDEX, 0);
}
// Tab: first item?
if (index === 0) {
// Get parent.
const { parentNode = getDomFallback() } = tab;
/*
We do this here, instead of outside the loop.
The top level item isn't always the `tablist`.
The accordion UI only has `<dl>`, whereas
the tabs UI has both `<div>` and `<ul>`.
*/
if (parentNode.getAttribute(ROLE) !== TABLIST) {
parentNode.setAttribute(ROLE, TABLIST);
}
}
Continuing within the earlier for
loop, we add attributes for each panel
. We add an id
if needed. We also set aria-hidden
to either true
or false
depending on the context of being an accordion (or not).
Likewise, we ensure that our panel points back to its tab trigger via aria-labelledby="…"
, and that role="tabpanel"
has been set.
// Panel: add ID?
if (!panel.getAttribute(ID)) {
panel.setAttribute(ID, panelId);
}
// Panel: add hidden?
if (!panel.getAttribute(ARIA_HIDDEN)) {
const bool = isAccordion || index !== 0;
panel.setAttribute(ARIA_HIDDEN, bool);
}
// Panel: add labelled?
if (!panel.getAttribute(ARIA_LABELLEDBY)) {
panel.setAttribute(ARIA_LABELLEDBY, tabId);
}
// Panel: add role?
if (panel.getAttribute(ROLE) !== TABPANEL) {
panel.setAttribute(ROLE, TABPANEL);
}
At the very end of the file, we have a few setup and teardown functions. As a way to play nicely with other JS that might be in the page, we provide an unbind
function that removes our global event listeners. It can be called by itself, via tablist.unbind()
but is mostly there so that we can unbind()
before (re-)binding. That way we prevent doubling up.
Inside our init
function, we call addAriaAttributes()
which modifies the DOM to be accessible. We then call unbind()
and then add our event listeners to the document
.
Finally, we bundle both methods into a parent object and export it under the name tablist
. That way, when dropping it into a flat HTML page, we can call tablist.init()
when we are ready to apply our functionality.
// =====================
// Remove global events.
// =====================
const unbind = () => {
document.removeEventListener(CLICK, globalClick);
document.removeEventListener(KEYDOWN, globalClick);
};
// ==================
// Add global events.
// ==================
const init = () => {
// Add attributes.
addAriaAttributes();
// Prevent doubles.
unbind();
document.addEventListener(CLICK, globalClick);
document.addEventListener(KEYDOWN, globalClick);
};
// ==============
// Bundle object.
// ==============
const tablist = {
init,
unbind,
};
// =======
// Export.
// =======
export { tablist };
React examples
There is a scene in Batman Begins where Lucius Fox (played by Morgan Freeman) explains to a recovering Bruce Wayne (Christian Bale) the scientific steps he took to save his life after being poisoned.
Lucius Fox: “I analyzed your blood, isolating the receptor compounds and the protein-based catalyst.”
Bruce Wayne: “Am I meant to understand any of that?”
Lucius Fox: “Not at all, I just wanted you to know how hard it was. Bottom line, I synthesized an antidote.”
↑ When working with a framework, I think in those terms.
Now that we know “hard” it is — not really, but humor me — to do raw DOM manipulation and event binding, we can better appreciate the existence of an antidote. React abstracts a lot of that complexity away, and handles it for us automatically.
File: Tabs.js
Now that we are diving into React examples, we will start with the <Tabs>
component.
// =============
// Used like so…
// =============
<Tabs>
<div label="Tab 1">
<p>
Tab 1 content
</p>
</div>
<div label="Tab 2">
<p>
Tab 2 content
</p>
</div>
</Tabs>
Here is the content from our Tabs.js
file. Note that in React parlance, it is standard practice to name the file with the same capitalization as its export default
component.
We start out with the same getTabId
and getPanelId
functions as in our vanilla JS approach, because we still need to make sure to accessibly map tabs to components. Take a look at the entirey of the code, and then we will continue to break it down.
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import cx from 'classnames';
// UI.
import Render from './Render';
// ===========
// Get tab ID.
// ===========
const getTabId = (id = '', index = 0) => {
return `tab_${id}_${index}`;
};
// =============
// Get panel ID.
// =============
const getPanelId = (id = '', index = 0) => {
return `tabpanel_${id}_${index}`;
};
// ==========
// Is active?
// ==========
const getIsActive = ({ activeIndex = null, index = null, list = [] }) => {
// Index matches?
const isMatch = index === parseFloat(activeIndex);
// Is first item?
const isFirst = index === 0;
// Only first item exists?
const onlyFirstItem = list.length === 1;
// Item doesn't exist?
const badActiveItem = !list[activeIndex];
// Flag as active?
const isActive = isMatch || onlyFirstItem || (isFirst && badActiveItem);
// Expose boolean.
return !!isActive;
};
getIsActive.propTypes = {
activeIndex: PropTypes.number,
index: PropTypes.number,
list: PropTypes.array,
};
// ================
// Get `<ul>` list.
// ================
const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {
// Build new list.
const newList = list.map((item = {}, index) => {
// =========
// Get data.
// =========
const { props: itemProps = {} } = item;
const { disabled = null, label = '' } = itemProps;
const idPanel = getPanelId(id, index);
const idTab = getTabId(id, index);
const isActive = getIsActive({ activeIndex, index, list });
// =======
// Events.
// =======
const handleClick = (event = {}) => {
const { key = '' } = event;
if (!disabled) {
// Early exit.
if (key && key.toLowerCase() !== 'enter') {
return;
}
setActiveIndex(index);
}
};
// ============
// Add to list.
// ============
return (
<li
aria-controls={idPanel}
aria-disabled={disabled}
aria-selected={isActive}
className="tabs__item"
disabled={disabled}
id={idTab}
key={idTab}
role="tab"
tabIndex={disabled ? null : 0}
// Events.
onClick={handleClick}
onKeyDown={handleClick}
>
{label || `${index + 1}`}
</li>
);
});
// ==========
// Expose UI.
// ==========
return (
<Render if={newList.length}>
<ul className="tabs__list" role="tablist">
{newList}
</ul>
</Render>
);
};
getTabsList.propTypes = {
activeIndex: PropTypes.number,
id: PropTypes.string,
list: PropTypes.array,
setActiveIndex: PropTypes.func,
};
// =================
// Get `<div>` list.
// =================
const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {
// Build new list.
const newList = list.map((item = {}, index) => {
// =========
// Get data.
// =========
const { props: itemProps = {} } = item;
const { children = '', className = null, style = null } = itemProps;
const idPanel = getPanelId(id, index);
const idTab = getTabId(id, index);
const isActive = getIsActive({ activeIndex, index, list });
// =============
// Get children.
// =============
let content = children || item;
if (typeof content === 'string') {
content = <p>{content}</p>;
}
// =================
// Build class list.
// =================
const classList = cx({
tabs__panel: true,
[String(className)]: className,
});
// ==========
// Expose UI.
// ==========
return (
<div
aria-hidden={!isActive}
aria-labelledby={idTab}
className={classList}
id={idPanel}
key={idPanel}
role="tabpanel"
style={style}
>
{content}
</div>
);
});
// ==========
// Expose UI.
// ==========
return newList;
};
getPanelsList.propTypes = {
activeIndex: PropTypes.number,
id: PropTypes.string,
list: PropTypes.array,
};
// ==========
// Component.
// ==========
const Tabs = ({
children = '',
className = null,
selected = 0,
style = null,
id: propsId = uuid(),
}) => {
// ===============
// Internal state.
// ===============
const [id] = useState(propsId);
const [activeIndex, setActiveIndex] = useState(selected);
// =================
// Build class list.
// =================
const classList = cx({
tabs: true,
[String(className)]: className,
});
// ===============
// Build UI lists.
// ===============
const list = Array.isArray(children) ? children : [children];
const tabsList = getTabsList({
activeIndex,
id,
list,
setActiveIndex,
});
const panelsList = getPanelsList({
activeIndex,
id,
list,
});
// ==========
// Expose UI.
// ==========
return (
<Render if={list[0]}>
<div className={classList} id={id} style={style}>
{tabsList}
{panelsList}
</div>
</Render>
);
};
Tabs.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
id: PropTypes.string,
selected: PropTypes.number,
style: PropTypes.object,
};
export default Tabs;
Function: getIsActive
Due to a <Tabs>
component always having something active and visible, this function contains some logic to determine whether an index
of a given tab should be the lucky winner. Essentially, in sentence form the logic goes like this.
This current tab is active if:
- Its
index
matches theactiveIndex
, or - The tabs UI has only one tab, or
- It is the first tab, and the
activeIndex
tab does not exist.
const getIsActive = ({ activeIndex = null, index = null, list = [] }) => {
// Index matches?
const isMatch = index === parseFloat(activeIndex);
// Is first item?
const isFirst = index === 0;
// Only first item exists?
const onlyFirstItem = list.length === 1;
// Item doesn't exist?
const badActiveItem = !list[activeIndex];
// Flag as active?
const isActive = isMatch || onlyFirstItem || (isFirst && badActiveItem);
// Expose boolean.
return !!isActive;
};
Function: getTabsList
This function generates the clickable <li role="tabs">
UI, and returns it wrapped in a parent <ul role="tablist">
. It assigns all the relevant aria-*
and role
attributes, and handles binding the onClick
and onKeyDown
events. When an event is triggered, setActiveIndex
is called. This updates the component’s internal state.
It is noteworthy how the content of the <li>
is derived. That is passed in as <div label="…">
children of the parent <Tabs>
component. Though this is not a real concept in flat HTML, it is a handy way to think about the relationship of the content. The children
of that <div>
become the the innards of our role="tabpanel"
later.
const getTabsList = ({ activeIndex = null, id = '', list = [], setActiveIndex = () => {} }) => {
// Build new list.
const newList = list.map((item = {}, index) => {
// =========
// Get data.
// =========
const { props: itemProps = {} } = item;
const { disabled = null, label = '' } = itemProps;
const idPanel = getPanelId(id, index);
const idTab = getTabId(id, index);
const isActive = getIsActive({ activeIndex, index, list });
// =======
// Events.
// =======
const handleClick = (event = {}) => {
const { key = '' } = event;
if (!disabled) {
// Early exit.
if (key && key.toLowerCase() !== 'enter') {
return;
}
setActiveIndex(index);
}
};
// ============
// Add to list.
// ============
return (
<li
aria-controls={idPanel}
aria-disabled={disabled}
aria-selected={isActive}
className="tabs__item"
disabled={disabled}
id={idTab}
key={idTab}
role="tab"
tabIndex={disabled ? null : 0}
// Events.
onClick={handleClick}
onKeyDown={handleClick}
>
{label || `${index + 1}`}
</li>
);
});
// ==========
// Expose UI.
// ==========
return (
<Render if={newList.length}>
<ul className="tabs__list" role="tablist">
{newList}
</ul>
</Render>
);
};
Function: getPanelsList
This function parses the incoming children
of the top level component and extracts the content. It also makes use of getIsActive
to determine whether (or not) to apply aria-hidden="true"
. As one might expect by now, it adds all the other relevant aria-*
and role
attributes too. It also applies any extra className
or style
that was passed in.
It also is “smart” enough to wrap any string
content — anything lacking a wrapping tag already — in <p>
tags for consistency.
const getPanelsList = ({ activeIndex = null, id = '', list = [] }) => {
// Build new list.
const newList = list.map((item = {}, index) => {
// =========
// Get data.
// =========
const { props: itemProps = {} } = item;
const { children = '', className = null, style = null } = itemProps;
const idPanel = getPanelId(id, index);
const idTab = getTabId(id, index);
const isActive = getIsActive({ activeIndex, index, list });
// =============
// Get children.
// =============
let content = children || item;
if (typeof content === 'string') {
content = <p>{content}</p>;
}
// =================
// Build class list.
// =================
const classList = cx({
tabs__panel: true,
[String(className)]: className,
});
// ==========
// Expose UI.
// ==========
return (
<div
aria-hidden={!isActive}
aria-labelledby={idTab}
className={classList}
id={idPanel}
key={idPanel}
role="tabpanel"
style={style}
>
{content}
</div>
);
});
// ==========
// Expose UI.
// ==========
return newList;
};
Function: Tabs
This is the main component. It sets an internal state for an id
, to essentially cache any generated uuid()
so that it does not change during the lifecycle of the component. React is finicky about its key
attributes (in the previous loops) changing dynamically, so this ensures they remain static once set.
We also employ useState
to track the currently selected tab, and pass down a setActiveIndex
function to each <li>
to monitor when they are clicked. After that, it is pretty straightfowrard. We call getTabsList
and getPanelsList
to build our UI, and then wrap it all up in <div role="tablist">
.
It accepts any wrapper level className
or style
, in case anyone wants further tweaks during implementation. Providing other developers (as consumers) this flexibility means that the likelihood of needing to make further edits to the core component is lower. Lately, I have been doing this as a “best practice” for all components I create.
const Tabs = ({
children = '',
className = null,
selected = 0,
style = null,
id: propsId = uuid(),
}) => {
// ===============
// Internal state.
// ===============
const [id] = useState(propsId);
const [activeIndex, setActiveIndex] = useState(selected);
// =================
// Build class list.
// =================
const classList = cx({
tabs: true,
[String(className)]: className,
});
// ===============
// Build UI lists.
// ===============
const list = Array.isArray(children) ? children : [children];
const tabsList = getTabsList({
activeIndex,
id,
list,
setActiveIndex,
});
const panelsList = getPanelsList({
activeIndex,
id,
list,
});
// ==========
// Expose UI.
// ==========
return (
<Render if={list[0]}>
<div className={classList} id={id} style={style}>
{tabsList}
{panelsList}
</div>
</Render>
);
};
If you are curious about the <Render>
function, you can read more about that in this example.
File: Accordion.js
// =============
// Used like so…
// =============
<Accordion>
<div label="Tab 1">
<p>
Tab 1 content
</p>
</div>
<div label="Tab 2">
<p>
Tab 2 content
</p>
</div>
</Accordion>
As you may have deduced — due to the vanilla JS example handling both tabs and accordion — this file has quite a few similarities to how Tabs.js
works.
Rather than belabor the point, I will simply provide the file’s contents for completeness and then speak about the specific areas in which the logic differs. So, take a gander at the contents and I will explain what makes <Accordion>
quirky.
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { v4 as uuid } from 'uuid';
import cx from 'classnames';
// UI.
import Render from './Render';
// ===========
// Get tab ID.
// ===========
const getTabId = (id = '', index = 0) => {
return `tab_${id}_${index}`;
};
// =============
// Get panel ID.
// =============
const getPanelId = (id = '', index = 0) => {
return `tabpanel_${id}_${index}`;
};
// ==============================
// Get `tab` and `tabpanel` list.
// ==============================
const getTabsAndPanelsList = ({
activeItems = {},
id = '',
isMulti = true,
list = [],
setActiveItems = () => {},
}) => {
// Build new list.
const newList = [];
// Loop through.
list.forEach((item = {}, index) => {
// =========
// Get data.
// =========
const { props: itemProps = {} } = item;
const {
children = '',
className = null,
disabled = null,
label = '',
style = null,
} = itemProps;
const idPanel = getPanelId(id, index);
const idTab = getTabId(id, index);
const isActive = !!activeItems[index];
// =======
// Events.
// =======
const handleClick = (event = {}) => {
const { key = '' } = event;
if (!disabled) {
// Early exit.
if (key && key.toLowerCase() !== 'enter') {
return;
}
// Keep active items?
const state = isMulti ? activeItems : null;
// Update active item.
const newState = {
...state,
[index]: !activeItems[index],
};
// Set active item.
setActiveItems(newState);
}
};
// =============
// Get children.
// =============
let content = children || item;
if (typeof content === 'string') {
content = <p>{content}</p>;
}
// =================
// Build class list.
// =================
const classList = cx({
accordion__panel: true,
[String(className)]: className,
});
// ========
// Add tab.
// ========
newList.push(
<div
aria-controls={idPanel}
aria-disabled={disabled}
aria-selected={isActive}
className="accordion__item"
disabled={disabled}
id={idTab}
key={idTab}
role="tab"
tabIndex={disabled ? null : 0}
// Events.
onClick={handleClick}
onKeyDown={handleClick}
>
<i aria-hidden="true" className="accordion__item__icon" />
{label || `${index + 1}`}
</div>
);
// ==========
// Add panel.
// ==========
newList.push(
<div
aria-hidden={!isActive}
aria-labelledby={idTab}
className={classList}
id={idPanel}
key={idPanel}
role="tabpanel"
style={style}
>
{content}
</div>
);
});
// ==========
// Expose UI.
// ==========
return newList;
};
getTabsAndPanelsList.propTypes = {
activeItems: PropTypes.object,
id: PropTypes.string,
isMulti: PropTypes.bool,
list: PropTypes.array,
setActiveItems: PropTypes.func,
};
// ==========
// Component.
// ==========
const Accordion = ({
children = '',
className = null,
isMulti = true,
selected = {},
style = null,
id: propsId = uuid(),
}) => {
// ===============
// Internal state.
// ===============
const [id] = useState(propsId);
const [activeItems, setActiveItems] = useState(selected);
// =================
// Build class list.
// =================
const classList = cx({
accordion: true,
[String(className)]: className,
});
// ===============
// Build UI lists.
// ===============
const list = Array.isArray(children) ? children : [children];
const tabsAndPanelsList = getTabsAndPanelsList({
activeItems,
id,
isMulti,
list,
setActiveItems,
});
// ==========
// Expose UI.
// ==========
return (
<Render if={list[0]}>
<div
aria-multiselectable={isMulti}
className={classList}
id={id}
role="tablist"
style={style}
>
{tabsAndPanelsList}
</div>
</Render>
);
};
Accordion.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
id: PropTypes.string,
isMulti: PropTypes.bool,
selected: PropTypes.object,
style: PropTypes.object,
};
export default Accordion;
Function: handleClick
While most of our <Accordion>
logic is similar to <Tabs>
, it differs in how it stores the currently active tab.
Since <Tabs>
are always mutually exclusive, we only really need a single numeric index
. Easy peasy.
However, because an <Accordion>
can have concurrently visible panels — or be used in a mutually exclusive manner — we need to represent that to useState
in a way that could handle both.
If you were beginning to think…
“I would store that in an object.”
…then congrats. You are right!
This function does a quick check to see if isMulti
has been set to true
. If so, we use the spread syntax to apply the existing activeItems
to our newState
object. We then set the current index
to its boolean opposite.
const handleClick = (event = {}) => {
const { key = '' } = event;
if (!disabled) {
// Early exit.
if (key && key.toLowerCase() !== 'enter') {
return;
}
// Keep active items?
const state = isMulti ? activeItems : null;
// Update active item.
const newState = {
...state,
[index]: !activeItems[index],
};
// Set active item.
setActiveItems(newState);
}
};
For reference, here is how our activeItems
object looks if only the first accordion panel is active and a user clicks the second. Both indexes would be set to true
. This allows for viewing two expanded role="tabpanel"
simultaneously.
/*
Internal representation
of `activeItems` state.
*/
{
0: true,
1: true,
}
Whereas if we were not operating in isMulti
mode — when the wrapper has aria-multiselectable="false"
— then activeItems
would only ever contain one key/value pair.
Because rather than spreading the current activeItems
, we would be spreading null
. That effectively wipes the slate clean, before recording the currently active tab.
/*
Internal representation
of `activeItems` state.
*/
{
1: true,
}
Conclusion
Still here? Awesome.
Hopefully you found this article informative, and maybe even learned a bit more about accessibility and JS(X) along the way. For review, let us look one more time at our flat HTML example and and the React usage of our <Tabs>
component. Here is a comparison of the markup we would write in a vanilla JS approach, versus the JSX it takes to generate the same thing.
I am not saying that one is better than the other, but you can see how React makes it possible to distill things down into a mental model. Working directly in HTML, you always have to be aware of every tag.
HTML
<div class="tabs">
<ul class="tabs__list">
<li class="tabs__item">
Tab 1
</li>
<li class="tabs__item">
Tab 2
</li>
</ul>
<div class="tabs__panel">
<p>
Tab 1 content
</p>
</div>
<div class="tabs__panel">
<p>
Tab 2 content
</p>
</div>
</div>
JSX
<Tabs>
<div label="Tab 1">
Tab 1 content
</div>
<div label="Tab 2">
Tab 2 content
</div>
</Tabs>
↑ One of these probably looks preferrable, depending on your point of view.
Writing code closer to the metal means more direct control, but also more tedium. Using a framework like React means you get more functionality “for free,” but also it can be a black box.
That is, unless you understand the underlying nuances already. Then you can fluidly operate in either realm. Because you can see The Matrix for what it really is: Just JavaScript™. Not a bad place to be, no matter where you find yourself.
The post The Anatomy of a Tablist Component in Vanilla JavaScript Versus React appeared first on CSS-Tricks.