diff --git a/index.js b/index.js index 2ecae29e4..af5e675a7 100644 --- a/index.js +++ b/index.js @@ -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'; diff --git a/lib/Tabs/Tab.js b/lib/Tabs/Tab.js new file mode 100644 index 000000000..83ad3472e --- /dev/null +++ b/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 +
  • setSelectedTabIndex(index)} + aria-selected={selectedTabIndex === index} + aria-controls={`tabpanel-${index}`} + className={finalStyles} + id={`tab-${index}`} + role="tab" + > + {children} +
  • + ); +}; + +Tab.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.array, + PropTypes.string + ]), + index: PropTypes.number +}; + +export default Tab; diff --git a/lib/Tabs/TabList.js b/lib/Tabs/TabList.js new file mode 100644 index 000000000..0f65b8361 --- /dev/null +++ b/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 ( + + ); +}; + +TabList.propTypes = { + ariaLabel: PropTypes.string.isRequired, + children: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.array + ]) +}; + +export default TabList; diff --git a/lib/Tabs/TabPanel.js b/lib/Tabs/TabPanel.js new file mode 100644 index 000000000..bf3235e9e --- /dev/null +++ b/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 && ( +
    + {children} +
    + ); +}; + +TabPanel.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.string + ]), + index: PropTypes.number +}; +export default TabPanel; diff --git a/lib/Tabs/Tabs.css b/lib/Tabs/Tabs.css new file mode 100644 index 000000000..2912f2f3e --- /dev/null +++ b/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; +} diff --git a/lib/Tabs/Tabs.js b/lib/Tabs/Tabs.js new file mode 100644 index 000000000..1672741fd --- /dev/null +++ b/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 & 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 ( + + {childrenWithIndex} + + ); +}; + +Tabs.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.array + ]) +}; + +export default Tabs; diff --git a/lib/Tabs/TabsContext.js b/lib/Tabs/TabsContext.js new file mode 100644 index 000000000..47f836650 --- /dev/null +++ b/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 ( + + {children} + + ); +}; + +TabsContextProvider.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.array + ]) +}; + +export { + TabsContext, + TabsContextProvider +}; diff --git a/lib/Tabs/index.js b/lib/Tabs/index.js new file mode 100644 index 000000000..232dca97a --- /dev/null +++ b/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'; diff --git a/lib/Tabs/tests/Tabs-test.js b/lib/Tabs/tests/Tabs-test.js new file mode 100644 index 000000000..948547f97 --- /dev/null +++ b/lib/Tabs/tests/Tabs-test.js @@ -0,0 +1,94 @@ +import React from 'react'; +import { + describe, + beforeEach, + it, +} from 'mocha'; + +import { + TabList as TabListInteractor, + Tab as TabInteractor, + TabPanel as TabPanelInteractor, + including, + Keyboard +} from '@folio/stripes-testing'; + +import { mount } from '../../../tests/helpers'; + +import Tabs from '../Tabs'; +import TabList from '../TabList'; +import Tab from '../Tab'; +import TabPanel from '../TabPanel'; + +const doMount = () => { + return mount( + + + Tab 0 + Tab 1 + Tab 2 + + <>Panel 0 + <>Panel 1 + <>Panel 2 + + ); +}; + +describe('Tabs', () => { + beforeEach(async () => { + await doMount(); + }); + const tabList = TabListInteractor(); + const tab = TabInteractor('Tab 1'); + it('should render ul element', async () => { + await tabList.exists(); + }); + it('ul element should have aria-label attribute containing passed prop', async () => { + await tabList.has({ ariaLabel: 'My test aria label' }); + }); + it('renders correct number of tabs', async () => { + await tabList.has({ tabsLength: 3 }); + }); + it('clicking a tab displays the appropriate tab panel', async () => { + const tabPanel = TabPanelInteractor('Panel 1'); + await tab.click(); + await tabPanel.exists(); + }); + it('clicking a tab highlights it', async () => { + await tab.click(); + await tab.has({ className: including('primary') }); + }); + it('clicking a tab gives it focus', async () => { + await tab.click(); + await tab.has({ focused: true }); + }); + it('pressing right arrow when a tab has focus displays the next tab panel', async () => { + const tabPanel2 = await TabPanelInteractor('Panel 2'); + await tab.click(); + await Keyboard.arrowRight(); + await tabPanel2.exists(); + }); + it('pressing left arrow when a tab has focus displays the previous tab panel', async () => { + const tabPanel0 = await TabPanelInteractor('Panel 0'); + await tab.click(); + await Keyboard.arrowLeft(); + await tabPanel0.exists(); + }); + it('pressing left arrow when the first tab has focus displays does nothing', async () => { + const tab0 = TabInteractor('Tab 0'); + const tabPanel0 = await TabPanelInteractor('Panel 0'); + await tab0.click(); + await Keyboard.arrowLeft(); + await tab0.has({ focused: true }); + await tabPanel0.exists(); + }); + it('pressing right arrow when the last tab has focus displays does nothing', async () => { + const tab2 = TabInteractor('Tab 2'); + const tabPanel2 = await TabPanelInteractor('Panel 2'); + await tab2.click(); + await Keyboard.arrowRight(); + await tab2.has({ focused: true }); + await tabPanel2.exists(); + }); +});