UNPKG

@salesforce/design-system-react

Version:

Salesforce Lightning Design System for React

418 lines (358 loc) 11.2 kB
/* 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'; // ### shortid // `shortid` is a short, non-sequential, url-friendly, unique id generator. It is used here to provide unique strings for the HTML attribute `id` on the Tabs components. It is only used if the `id` prop is not provided on the man <Tabs /> component. import shortid from 'shortid'; // ### 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'; // ### 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.getAttribute('role') === 'tab') ); } // Determine if a tab node is disabled function isTabDisabled(node) { 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 scopped, defaults to false */ variant: PropTypes.oneOf(['default', 'scoped']), /** * 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 = []; } componentWillMount() { // 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 = shortid.generate(); this.flavor = this.getVariant(); this.setState({ selectedIndex: this.props.defaultSelectedIndex, }); } componentWillUnmount() { this.setState({ focus: false, }); } 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 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 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 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 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 === 'scoped' ? 'scoped' : '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 */ do { if (this.isTabFromContainer(node)) { if (isTabDisabled(node)) { return; } const index = [].slice.call(node.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} > {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', }, 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;