Skip to content

Instantly share code, notes, and snippets.

@reecelucas
Last active December 3, 2021 09:50
Show Gist options
  • Save reecelucas/6f9e2e79be35a66e67030ccb705bf412 to your computer and use it in GitHub Desktop.
Save reecelucas/6f9e2e79be35a66e67030ccb705bf412 to your computer and use it in GitHub Desktop.
React version of Heydon Pickering's "Tabbed Interface" inclusive component. https://inclusive-components.design/tabbed-interfaces/.
const whiteListAttributePatterns = /^(aria-|data-|role|name|tabIndex|className|autoFocus)/;
export default (props = {}) =>
Object.keys(props)
.filter(prop => whiteListAttributePatterns.test(prop))
.reduce((acc, key) => {
acc[key] = props[key];
return acc;
}, {});
import Tab from './Tab';
import TabList from './TabList';
import TabPanel from './TabPanel';
import TabPanels from './TabPanels';
import Tabs from './Tabs';
export { Tabs, TabList, Tab, TabPanels, TabPanel };
import React, { useContext, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import TabContext from './TabContext';
import getAttributeProps from './getAttributeProps';
const propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired,
/**
* `id` is injected by `TabList`, using `React.cloneElement`.
* Don't pass it in manually. This PropType is here to keep
* eslint happy.
*/
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
active: PropTypes.bool
};
const Tab = ({ children, id, active, ...rest }) => {
const domRef = useRef();
const { activeId, setActiveId } = useContext(TabContext);
const attributes = getAttributeProps(rest);
useEffect(() => {
if (active) {
setActiveId(id);
}
}, []);
useEffect(
() => {
if (isActive()) {
domRef.current.focus();
}
},
[activeId]
);
const onClick = event => {
event.preventDefault();
setActiveId(id);
};
const isActive = () => id === activeId;
return (
<li role="presentation" {...attributes}>
<a
ref={domRef}
href={`#section-${id}`}
id={`tab-${id}`}
role="tab"
tabIndex={isActive() ? null : '-1'}
aria-controls={`section-${id}`}
aria-selected={isActive() ? 'true' : null}
onClick={onClick}
>
{children}
</a>
</li>
);
};
Tab.propTypes = propTypes;
export default Tab;
import React from 'react';
const TabContext = React.createContext({});
export default TabContext;
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import TabContext from './TabContext';
import getAttributeProps from '../../helpers/getAttributeProps';
const propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
};
const TabList = ({ children, ...rest }) => {
const [tabsLength, setTabsLength] = useState(0);
const { activeId, setActiveId } = useContext(TabContext);
useEffect(() => {
setTabsLength(React.Children.count(children) - 1); // 0-based `Children` length
}, []);
const onKeyDown = ({ key }) => {
const isLeftArrow = key === 'ArrowLeft';
const isRightArrow = key === 'ArrowRight';
if (!isLeftArrow && !isRightArrow) return;
const nextId = isLeftArrow ? activeId - 1 : activeId + 1;
const nextIdLeft = nextId >= 0 ? nextId : 0;
const nextIdRight = nextId <= tabsLength ? nextId : tabsLength;
setActiveId(isLeftArrow ? nextIdLeft : nextIdRight);
};
return (
<ul role="tablist" onKeyDown={onKeyDown} {...getAttributeProps(rest)}>
{React.Children.map(children, (child, index) =>
// Inject`id` prop into each child component
React.cloneElement(child, { id: index })
)}
</ul>
);
};
TabList.propTypes = propTypes;
export default TabList;
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import TabContext from './TabContext';
const propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired,
/**
* `id` is injected by `TabPanels`, using `React.cloneElement`.
* Don't pass it in manually. This PropType is here to keep
* eslint happy.
*/
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
};
const TabPanel = ({ id, children }) => {
const { activeId } = useContext(TabContext);
const show = () => id === activeId;
return (
<section
id={`section-${id}`}
role="tabpanel"
tabIndex="-1"
aria-labelledby={`tab-${id}`}
hidden={show() ? false : true}
>
{children}
</section>
);
};
TabPanel.propTypes = propTypes;
export default TabPanel;
import PropTypes from 'prop-types';
import React from 'react';
const propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
};
const TabPanels = ({ children }) => {
return React.Children.map(children, (child, index) =>
// Inject`id` prop into each child component
React.cloneElement(child, { id: index })
);
};
TabPanels.propTypes = propTypes;
export default TabPanels;
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import TabContext from './TabContext';
const propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node
]).isRequired
};
const Tabs = ({ children }) => {
const [currentId, setCurrentId] = useState(0);
const setActiveId = id => {
setCurrentId(id);
};
return (
<TabContext.Provider
value={{
activeId: currentId,
setActiveId
}}
>
{children}
</TabContext.Provider>
);
};
Tabs.propTypes = propTypes;
export default Tabs;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment