UNPKG

@salesforce/design-system-react

Version:

Salesforce Lightning Design System for React

423 lines (364 loc) 11.6 kB
/* eslint-disable no-else-return */ /* Copyright (c) 2015-present, salesforce.com, inc. All rights reserved */ /* Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license */ // # Tabs Component // Implements the [Tabs design pattern](https://www.lightningdesignsystem.com/components/tabs/) in React. // ## Dependencies // ### React import React from 'react'; import PropTypes from 'prop-types'; // ### classNames import classNames from 'classnames'; // ### isFunction import isFunction from 'lodash.isfunction'; // Child components import TabsList from './private/tabs-list'; import Tab from './private/tab'; import TabPanel from './private/tab-panel'; // ## Constants import { TABS } from '../../utilities/constants'; import generateId from '../../utilities/generate-id'; // ### Event Helpers import KEYS from '../../utilities/key-code'; import EventUtil from '../../utilities/event'; // Determine if a node from event.target is a Tab element function isTabNode(node) { return ( (node.nodeName === 'A' && node.getAttribute('role') === 'tab') || (node.nodeName === 'LI' && (node.classList.contains('slds-tabs_default__item') || node.classList.contains('slds-tabs_scoped__item') || node.classList.contains('slds-vertical-tabs__nav-item'))) ); } // Determine if a tab node is disabled function isTabDisabled(node) { if (node.classList && node.classList.contains('slds-disabled')) { return true; } else if (node.getAttribute) { return node.getAttribute('aria-disabled') === 'true'; } return !!node.props.disabled; } /** * Tabs keeps related content in a single container that is shown and hidden through navigation. */ const displayName = TABS; const propTypes = { /** * HTML `id` attribute of primary element that has `.slds-tabs_default` on it. Optional: If one is not supplied, a `shortid` will be created. */ id: PropTypes.string, /** * The `children` are the actual tabs and panels to be displayed. * * Note that the structure of the `<Tabs />` component **does not** correspond to the DOM structure that is rendered. The `<Tabs />` component requires one or more children of type `<TabsPanel />`, which themselves require a `label` property which will be what shows in the `<Tab />` and has `children`, which end up being the _contents of the tab's corresponding panel_. * * The component iterates through each `<TabsPanel />` and rendering one `<Tab />` and one `<TabPanel />` for each of them. The tab(s) end up being children of the `<TabsList />`. * * ``` * <Tabs> * <TabsPanel label="Tab 1"> * <div> * <h2 className="slds-text-heading_medium">This is my tab 1 contents!</h2> * <p>They show when you click the first tab.</p> * </div> * </TabsPanel> * <TabsPanel label="Tab 2"> * <div> * <h2 className="slds-text-heading_medium">This is my tab 2 contents!</h2> * <p>They show when you click the second tab.</p> * </div> * </TabsPanel> * </Tabs> * ``` */ children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node, PropTypes.element, ]).isRequired, /** * Class names to be added to the container element and is passed along to its children. */ className: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * The Tab (and corresponding TabPanel) that is selected when the component first renders. Defaults to `0`. */ defaultSelectedIndex: PropTypes.number, /** * This function triggers when a tab is selected. */ onSelect: PropTypes.func, /** * If the Tabs should be scoped, vertical, or default (default value) */ variant: PropTypes.oneOf(['default', 'scoped', 'vertical']), /** * The Tab (and corresponding TabPanel) that is currently selected. */ selectedIndex: PropTypes.number, }; const defaultProps = { defaultSelectedIndex: 0, variant: 'default', }; /** * A tab keeps related content in a single container that is shown and hidden through navigation. */ class Tabs extends React.Component { constructor(props) { super(props); this.tabs = []; // If no `id` is supplied in the props we generate one. An HTML ID is _required_ for several elements in a tabs component in order to leverage ARIA attributes for accessibility. this.generatedId = generateId(); this.flavor = this.getVariant(); this.state = { selectedIndex: props.defaultSelectedIndex, }; } getNextTab(index) { const count = this.getTabsCount(); // Look for non-disabled tab from index to the last tab on the right // eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus, fp/no-loops for (let i = index + 1; i < count; i++) { const tab = this.getTab(i); if (!isTabDisabled(tab)) { return i; } } // If no tab found, continue searching from first on left to index // eslint-disable-next-line no-plusplus, fp/no-loops for (let i = 0; i < index; i++) { const tab = this.getTab(i); if (!isTabDisabled(tab)) { return i; } } // No tabs are disabled, return index return index; } getPanelsCount() { return this.props.children ? React.Children.count(this.props.children) : 0; } getPrevTab(index) { let i = index; // Look for non-disabled tab from index to first tab on the left // eslint-disable-next-line fp/no-loops, no-plusplus while (i--) { const tab = this.getTab(i); if (!isTabDisabled(tab)) { return i; } } // If no tab found, continue searching from last tab on right to index i = this.getTabsCount(); // eslint-disable-next-line fp/no-loops, no-plusplus while (i-- > index) { const tab = this.getTab(i); if (!isTabDisabled(tab)) { return i; } } // No tabs are disabled, return index return index; } getSelectedIndex() { return Number.isInteger(this.props.selectedIndex) ? this.props.selectedIndex : this.state.selectedIndex; } getTab(index) { return this.tabs[index].tab; } getTabNode(index) { return this.tabs[index].node; } getTabsCount() { return this.props.children ? React.Children.count(this.props.children) : 0; } getVariant() { return this.props.variant || 'default'; } setSelected(index, focus) { // Check index boundary if (index < 0 || index >= this.getTabsCount()) { return; } // Keep reference to last index for event handler const last = this.getSelectedIndex(); /** * This is a temporary solution that could be broken in the future without notification, * since this component is not a controlled component and only relies on internal state. * If this breaks in the future an alternative way to control the state from outside the * component should be present. * */ let shouldContinue; // Call change event handler if (isFunction(this.props.onSelect)) { shouldContinue = this.props.onSelect(index, last); } // Don't update the state if nothing has changed if (shouldContinue !== false && index !== this.state.selectedIndex) { this.setState({ selectedIndex: index, focus: focus === true }); } } handleClick = (e) => { let node = e.target; /* eslint-disable no-cond-assign, fp/no-loops */ do { if (this.isTabFromContainer(node)) { if (isTabDisabled(node)) { return; } let parentNode = node.parentNode; // eslint-disable-line prefer-destructuring if (parentNode.nodeName === 'LI') { node = node.parentNode; parentNode = node.parentNode; // eslint-disable-line prefer-destructuring } const index = [].slice.call(parentNode.children).indexOf(node); this.setSelected(index); return; } } while ((node = node.parentNode) !== null); /* eslint-enable no-cond-assign */ }; handleKeyDown = (event) => { if (this.isTabFromContainer(event.target)) { let index = this.getSelectedIndex(); let preventDefault = false; if (event.keyCode === KEYS.LEFT || event.keyCode === KEYS.UP) { // Select next tab to the left index = this.getPrevTab(index); preventDefault = true; } else if (event.keyCode === KEYS.RIGHT || event.keyCode === KEYS.DOWN) { // Select next tab to the right index = this.getNextTab(index); preventDefault = true; } // Prevent any dumn scrollbars from moving around as we type. if (preventDefault) { EventUtil.trap(event); } this.setSelected(index, true); } }; /** * Determine if a node from event.target is a Tab element for the current Tabs container. * If the clicked element is not a Tab, it returns false. * If it finds another Tabs container between the Tab and `this`, it returns false. */ isTabFromContainer(node) { // Return immediately if the clicked element is not a Tab. This prevents tab panel content from selecting a tab. if (!isTabNode(node)) { return false; } // Check if the first occurrence of a Tabs container is `this` one. let nodeAncestor = node.parentElement; do { if (nodeAncestor === this.tabsNode) return true; else if (nodeAncestor.getAttribute('data-tabs')) break; nodeAncestor = nodeAncestor.parentElement; } while (nodeAncestor); return false; } renderTabPanels(parentId) { const children = React.Children.toArray(this.props.children); const selectedIndex = this.getSelectedIndex(); let result = null; result = children.map((child, index) => { const tabId = `${parentId}-slds-tabs_tab-${index}`; const id = `${parentId}-slds-tabs_panel-${index}`; const selected = selectedIndex === index; const variant = this.getVariant(); return ( <TabPanel key={child.key} selected={selected} id={id} tabId={tabId} variant={variant} > {children[index]} </TabPanel> ); }); return result; } renderTabsList(parentId) { const children = React.Children.toArray(this.props.children); return ( // `parentId` gets consumed by TabsList, adding a suffix of `-tabs__nav` <TabsList id={parentId} variant={this.getVariant()}> {children.map((child, index) => { const id = `${parentId}-slds-tabs_tab-${index}`; const panelId = `${parentId}-slds-tabs_panel-${index}`; const selected = this.getSelectedIndex() === index; const focus = selected && this.state.focus; const variant = this.getVariant(); return ( <Tab key={child.key} ref={(node) => { this.tabs[index] = { tab: child, node }; if (this.state.focus) { this.setState({ focus: false }); } }} focus={focus} selected={selected} id={id} panelId={panelId} disabled={child.props.disabled} variant={variant} hasError={child.props.hasError} assistiveText={child.props.assistiveText} > {child.props.label} </Tab> ); })} </TabsList> ); } render() { const { className, id = this.generatedId, variant = this.getVariant, } = this.props; return ( /* eslint-disable jsx-a11y/no-static-element-interactions */ <div id={id} className={classNames( { 'slds-tabs_default': variant === 'default', 'slds-tabs_scoped': variant === 'scoped', 'slds-vertical-tabs': variant === 'vertical', }, className )} onClick={this.handleClick} onKeyDown={this.handleKeyDown} data-tabs ref={(node) => { this.tabsNode = node; }} > {/* eslint-enable jsx-a11y/no-static-element-interactions */} {this.renderTabsList(id)} {this.renderTabPanels(id)} </div> ); } } Tabs.displayName = displayName; Tabs.propTypes = propTypes; Tabs.defaultProps = defaultProps; export default Tabs;