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

STCOM-922: Add tabbed interface components #1718

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions index.js
Expand Up @@ -61,6 +61,13 @@ export {
expandAllFunction
} from './lib/Accordion';

export {
Tabs,
Tab,
TabList,
TabPanel
} from './lib/Tabs';

/* misc */
export { default as Icon } from './lib/Icon';
export { default as IconButton } from './lib/IconButton';
Expand Down
62 changes: 62 additions & 0 deletions lib/Tabs/Tab.js
@@ -0,0 +1,62 @@
import React, { useContext, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';

import { TabsContext } from './TabsContext';

import css from './Tabs.css';

const Tab = (props) => {
const {
children,
index
} = props;

const thisTab = useRef(null);

const {
selectedTabIndex,
setSelectedTabIndex
} = useContext(TabsContext);

// Ensure the correct tab has focus
useEffect(() => {
if (selectedTabIndex === index) {
thisTab.current.focus();
}
// Having index as a dep makes no sense, it's never
// going to change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTabIndex]);

const activeStyle = selectedTabIndex === index ? css.primary : css.default;
const finalStyles = [css.tab, activeStyle].join(' ');

return (
// Keyboard based interactivity with the tabs is handled in TabList
// so we don't need a onKey* handler here
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<li
ref={thisTab}
tabIndex={selectedTabIndex === index ? 0 : -1}
onClick={() => setSelectedTabIndex(index)}
aria-selected={selectedTabIndex === index}
aria-controls={`tabpanel-${index}`}
className={finalStyles}
id={`tab-${index}`}
role="tab"
>
{children}
</li>
);
};

Tab.propTypes = {
children: PropTypes.oneOfType([
PropTypes.element,
PropTypes.array,
PropTypes.string
]),
index: PropTypes.number
};

export default Tab;
75 changes: 75 additions & 0 deletions lib/Tabs/TabList.js
@@ -0,0 +1,75 @@
import React, { useContext, cloneElement } from 'react';
import PropTypes from 'prop-types';

import { TabsContext } from './TabsContext';

import css from './Tabs.css';

const TabList = (props) => {
const {
ariaLabel,
children
} = props;

const {
selectedTabIndex,
setSelectedTabIndex
} = useContext(TabsContext);

// Add the index to each child, which will allow us to ensure the current
// tab is styled correctly and has focus etc.
const childrenArray = Array.isArray(children) ? children : [children];
const childrenWithIndex = childrenArray.map((child, index) => cloneElement(child, { index, key: index }));

// Handle setting of the next index when navigating
// by keyboard
const calculateNextIndex = (action) => {
if (action === 'increase') {
const maxIndex = children.length - 1;
return selectedTabIndex < maxIndex ?
selectedTabIndex + 1 :
selectedTabIndex;
}
if (action === 'decrease') {
return selectedTabIndex > 0 ?
selectedTabIndex - 1 :
selectedTabIndex;
}
return selectedTabIndex;
};

// Handle the right and left cursor keys for navigating
// via keyboard.
const handleKeyDown = (e) => {
switch (e.keyCode) {
case 39: // Right arrow
setSelectedTabIndex(calculateNextIndex('increase'));
break;
case 37: // Left arrow
setSelectedTabIndex(calculateNextIndex('decrease'));
break;
default:
}
};

return (
<ul
onKeyDown={handleKeyDown}
aria-label={ariaLabel}
className={css.tabList}
role="tablist"
>
{childrenWithIndex}
</ul>
);
};

TabList.propTypes = {
ariaLabel: PropTypes.string.isRequired,
children: PropTypes.oneOfType([
PropTypes.element,
PropTypes.array
])
};

export default TabList;
35 changes: 35 additions & 0 deletions lib/Tabs/TabPanel.js
@@ -0,0 +1,35 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';

import { TabsContext } from './TabsContext';

import css from './Tabs.css';

const TabPanel = (props) => {
const {
children,
index
} = props;

const { selectedTabIndex } = useContext(TabsContext);

return selectedTabIndex === index && (
<div
tabIndex={selectedTabIndex === index ? 0 : -1}
id={`tabpanel-${index}`}
className={css.tabPanel}
role="tabpanel"
>
{children}
</div>
);
};

TabPanel.propTypes = {
children: PropTypes.oneOfType([
PropTypes.element,
PropTypes.string
]),
index: PropTypes.number
};
export default TabPanel;
42 changes: 42 additions & 0 deletions lib/Tabs/Tabs.css
@@ -0,0 +1,42 @@
@import '../variables.css';

ul {
margin: 0;
}

.tabList {
display: flex;
list-style-type: none;
padding: 0;
}

.tab {
padding: 8px;
cursor: pointer;
text-align: center;
font-weight: var(--text-weight-button);
font-size: var(--font-size-medium);
transition: background-color 0.25s, color 0.25s, opacity 0.07s;
border: 1px solid var(--primary);
border-right: 0;
border-bottom: 0;
}
.tab:last-child {
border-right: 1px solid var(--primary);
}
.tab.default {
background-color: transparent;
color: var(--primary);
}
.tab.primary {
background-color: var(--primary);
color: #fff;
}
.tab:hover {
opacity: 0.9;
}

.tabPanel {
border: 1px solid var(--primary);
padding: 15px;
}
39 changes: 39 additions & 0 deletions lib/Tabs/Tabs.js
@@ -0,0 +1,39 @@
import React, { cloneElement } from 'react';
import PropTypes from 'prop-types';

import { TabsContextProvider } from './TabsContext';

const Tabs = (props) => {
const { children } = props;

const childrenArray = Array.isArray(children) ? children : [children];
const childIndexes = {};
const childrenWithIndex = childrenArray.flat().map((child, index) => {
// children can consist of <TabList> & <TabPanel> components,
// ensure that the children of different types get correct indexes
const current = childIndexes[child.type.name];
childIndexes[child.type.name] = current >= 0 ? current + 1 : 0;
return cloneElement(
child,
{
index: childIndexes[child.type.name],
key: index
}
);
});

return (
<TabsContextProvider>
{childrenWithIndex}
</TabsContextProvider>
);
};

Tabs.propTypes = {
children: PropTypes.oneOfType([
PropTypes.element,
PropTypes.array
])
};

export default Tabs;
31 changes: 31 additions & 0 deletions lib/Tabs/TabsContext.js
@@ -0,0 +1,31 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';

const TabsContext = React.createContext();

const TabsContextProvider = ({ children }) => {
const [selectedTabIndex, setSelectedTabIndex] = useState(0);

const defaultContext = {
selectedTabIndex,
setSelectedTabIndex
};

return (
<TabsContext.Provider value={defaultContext}>
{children}
</TabsContext.Provider>
);
};

TabsContextProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.element,
PropTypes.array
])
};

export {
TabsContext,
TabsContextProvider
};
4 changes: 4 additions & 0 deletions lib/Tabs/index.js
@@ -0,0 +1,4 @@
export { default as Tabs } from './Tabs';
export { default as TabList } from './TabList';
export { default as Tab } from './Tab';
export { default as TabPanel } from './TabPanel';