From ab061bd84a3fa11912435f7ade83c5d2e2ad03d3 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Tue, 7 Dec 2021 15:54:04 -0600 Subject: [PATCH] feat(react): update Tabs API and delete `next/Tab` (#10225) * chore: check in progress * chore: check in progress * chore: check in progress * refactor(styles): remove unused tabs styles * fix(styles): remove tabs scrollable class and replace with tabs * chore(react): remove outdated comments * chore(react): remove outdated comments * chore(react): remove outdated comments * chore: check in progress * feat(react): update tabs api and delete tab next * fix(react): remove unnecessary ContainedTab export * fix(react): remove contained tab export * fix(react): update snapshots and add tests to new Tabs * feat(react): remove unstable containedTabs --- .../__snapshots__/PublicAPI-test.js.snap | 103 +-- packages/react/src/__tests__/index-test.js | 5 +- packages/react/src/components/Tab/index.js | 5 +- .../src/components/Tab/next/ContainedTab.js | 155 ---- .../react/src/components/Tab/next/Tab-test.js | 204 ----- packages/react/src/components/Tab/next/Tab.js | 155 ---- .../react/src/components/Tabs/Tabs-story.js | 2 +- packages/react/src/components/Tabs/index.js | 5 +- .../src/components/Tabs/next/ContainedTabs.js | 601 ------------- .../src/components/Tabs/next/Tabs-test.js | 320 +++---- .../src/components/Tabs/next/Tabs.Skeleton.js | 6 +- .../react/src/components/Tabs/next/Tabs.js | 817 +++++++----------- .../src/components/Tabs/next/Tabs.stories.js | 106 ++- packages/react/src/index.js | 7 +- .../src/internal/useNoInteractiveChildren.js | 2 +- .../styles/scss/components/tabs/_tabs.scss | 189 ++-- 16 files changed, 630 insertions(+), 2052 deletions(-) delete mode 100644 packages/react/src/components/Tab/next/ContainedTab.js delete mode 100644 packages/react/src/components/Tab/next/Tab-test.js delete mode 100644 packages/react/src/components/Tab/next/Tab.js delete mode 100644 packages/react/src/components/Tabs/next/ContainedTabs.js diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index a8df06ae14a5..232d90310ec7 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -5942,51 +5942,6 @@ Map { }, }, }, - "unstable_ContainedTab" => Object { - "$$typeof": Symbol(react.forward_ref), - "propTypes": Object { - "className": Object { - "type": "string", - }, - "disabled": Object { - "type": "bool", - }, - "handleTabClick": Object { - "type": "func", - }, - "handleTabKeyDown": Object { - "type": "func", - }, - "id": Object { - "type": "string", - }, - "index": Object { - "type": "number", - }, - "label": Object { - "type": "node", - }, - "onClick": Object { - "type": "func", - }, - "onKeyDown": Object { - "type": "func", - }, - "renderButton": Object { - "type": "func", - }, - "renderContent": Object { - "type": "func", - }, - "selected": Object { - "type": "bool", - }, - "tabIndex": Object { - "type": "number", - }, - }, - "render": [Function], - }, "TabContent" => Object { "defaultProps": Object { "selected": false, @@ -6085,7 +6040,7 @@ Map { }, }, }, - "unstable_ContainedTabs" => Object { + "unstable_TabPanel" => Object { "$$typeof": Symbol(react.forward_ref), "propTypes": Object { "children": Object { @@ -6094,32 +6049,19 @@ Map { "className": Object { "type": "string", }, - "hidden": Object { - "type": "bool", - }, - "leftOverflowButtonProps": Object { - "type": "object", - }, - "light": [Function], - "onClick": Object { - "type": "func", - }, - "onKeyDown": Object { - "type": "func", - }, - "onSelectionChange": Object { - "type": "func", - }, - "rightOverflowButtonProps": Object { - "type": "object", - }, - "scrollIntoView": Object { - "type": "bool", - }, - "selected": Object { - "type": "number", + }, + "render": [Function], + }, + "unstable_TabPanels" => Object { + "propTypes": Object { + "children": Object { + "type": "node", }, - "selectionMode": Object { + }, + }, + "unstable_TabList" => Object { + "propTypes": Object { + "activation": Object { "args": Array [ Array [ "automatic", @@ -6128,11 +6070,26 @@ Map { ], "type": "oneOf", }, - "tabContentClassName": Object { + "aria-label": Object { + "isRequired": true, + "type": "string", + }, + "children": Object { + "type": "node", + }, + "className": Object { "type": "string", }, + "contained": Object { + "type": "bool", + }, + "light": Object { + "type": "bool", + }, + "scrollIntoView": Object { + "type": "bool", + }, }, - "render": [Function], }, "Tag" => Object { "propTypes": Object { diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index f9f725768662..1bd44ec9f69b 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -199,8 +199,6 @@ Array [ "TooltipDefinition", "TooltipIcon", "UnorderedList", - "unstable_ContainedTab", - "unstable_ContainedTabs", "unstable_FeatureFlags", "unstable_HStack", "unstable_Heading", @@ -219,6 +217,9 @@ Array [ "unstable_ProgressBar", "unstable_Section", "unstable_Stack", + "unstable_TabList", + "unstable_TabPanel", + "unstable_TabPanels", "unstable_Theme", "unstable_Tooltip", "unstable_TreeNode", diff --git a/packages/react/src/components/Tab/index.js b/packages/react/src/components/Tab/index.js index 19d47aeda570..6e0e9f938ad1 100644 --- a/packages/react/src/components/Tab/index.js +++ b/packages/react/src/components/Tab/index.js @@ -6,12 +6,9 @@ */ import * as FeatureFlags from '@carbon/feature-flags'; -import { default as TabNext } from './next/Tab'; +import { Tab as TabNext } from '../Tabs/next/Tabs'; import { default as TabClassic } from './Tab'; -import ContainedTab from './next/ContainedTab'; const Tab = FeatureFlags.enabled('enable-v11-release') ? TabNext : TabClassic; -export { ContainedTab }; - export default Tab; diff --git a/packages/react/src/components/Tab/next/ContainedTab.js b/packages/react/src/components/Tab/next/ContainedTab.js deleted file mode 100644 index 8e444f629a74..000000000000 --- a/packages/react/src/components/Tab/next/ContainedTab.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2018 - * - * This source code is licensed under the Apache-2.0 license found in the - * LICENSE file in the root directory of this source tree. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; -import classNames from 'classnames'; -import { usePrefix } from '../../../internal/usePrefix'; - -const ContainedTab = React.forwardRef(function ContainedTab( - { - className, - disabled, - handleTabClick, - handleTabKeyDown, - id, - index, - label = 'provide a label', - onClick = () => {}, - onKeyDown = () => {}, - renderButton, - renderContent, // eslint-disable-line no-unused-vars - selected = false, - tabIndex = 0, - ...other - }, - ref -) { - const prefix = usePrefix(); - - const classes = classNames(className, `${prefix}--tabs__nav-item`, { - [`${prefix}--tabs__nav-item--disabled`]: disabled, - [`${prefix}--tabs__nav-item--selected`]: selected, - }); - - const buttonProps = { - ['aria-selected']: selected, - ['aria-disabled']: disabled, - ['aria-controls']: id && `${id}__panel`, - id, - className: `${prefix}--tabs__nav-link`, - tabIndex: !disabled ? tabIndex : -1, - ref: ref, - }; - - return ( -
  • { - if (disabled) { - return; - } - if (handleTabClick) { - handleTabClick(index, evt); - } - onClick(evt); - }} - onKeyDown={(evt) => { - if (disabled) { - return; - } - if (handleTabKeyDown) { - handleTabKeyDown(index, evt); - } - onKeyDown(evt); - }} - role="presentation"> - {renderButton ? ( - renderButton(buttonProps) - ) : ( - - )} -
  • - ); -}); - -ContainedTab.propTypes = { - /** - * Specify an optional className to be added to your Tab - */ - className: PropTypes.string, - - /** - * Whether your Tab is disabled. - */ - disabled: PropTypes.bool, - - /** - * A handler that is invoked when a user clicks on the control. - * Reserved for usage in Tabs - */ - handleTabClick: PropTypes.func, - - /** - * A handler that is invoked on the key down event for the control. - * Reserved for usage in Tabs - */ - handleTabKeyDown: PropTypes.func, - - /** - * The element ID for the top-level element. - */ - id: PropTypes.string, - - /** - * The index of your Tab in your Tabs. Reserved for usage in Tabs - */ - index: PropTypes.number, - - /** - * Provide the contents of your Tab - */ - label: PropTypes.node, - - /** - * Provide a handler that is invoked when a user clicks on the control - */ - onClick: PropTypes.func, - - /** - * Provide a handler that is invoked on the key down event for the control - */ - onKeyDown: PropTypes.func, - - /* - * An optional parameter to allow overriding the anchor rendering. - * Useful for using Tab along with react-router or other client - * side router libraries. - **/ - renderButton: PropTypes.func, - - /* - * An optional parameter to allow overriding the content rendering. - **/ - renderContent: PropTypes.func, - - /** - * Whether your Tab is selected. - * Reserved for usage in Tabs - */ - selected: PropTypes.bool, - - /** - * Specify the tab index of the ` - )} - - ); -}); - -Tab.propTypes = { - /** - * Specify an optional className to be added to your Tab - */ - className: PropTypes.string, - - /** - * Whether your Tab is disabled. - */ - disabled: PropTypes.bool, - - /** - * A handler that is invoked when a user clicks on the control. - * Reserved for usage in Tabs - */ - handleTabClick: PropTypes.func, - - /** - * A handler that is invoked on the key down event for the control. - * Reserved for usage in Tabs - */ - handleTabKeyDown: PropTypes.func, - - /** - * The element ID for the top-level element. - */ - id: PropTypes.string, - - /** - * The index of your Tab in your Tabs. Reserved for usage in Tabs - */ - index: PropTypes.number, - - /** - * Provide the contents of your Tab - */ - label: PropTypes.node, - - /** - * Provide a handler that is invoked when a user clicks on the control - */ - onClick: PropTypes.func, - - /** - * Provide a handler that is invoked on the key down event for the control - */ - onKeyDown: PropTypes.func, - - /* - * An optional parameter to allow overriding the anchor rendering. - * Useful for using Tab along with react-router or other client - * side router libraries. - **/ - renderButton: PropTypes.func, - - /* - * An optional parameter to allow overriding the content rendering. - **/ - renderContent: PropTypes.func, - - /** - * Whether your Tab is selected. - * Reserved for usage in Tabs - */ - selected: PropTypes.bool, - - /** - * Specify the tab index of the ` - {!leftOverflowNavButtonHidden && ( -
    - )} - - {!rightOverflowNavButtonHidden && ( -
    - )} - -
    - {tabContentWithProps} - - ); -}); - -ContainedTabs.propTypes = { - /** - * Pass in a collection of children to be rendered depending on the - * currently selected tab - */ - children: PropTypes.node, - - /** - * Provide a className that is applied to the root
    component for the - * - */ - className: PropTypes.string, - - /** - * Specify whether the Tab content is hidden - */ - hidden: PropTypes.bool, - - /** - * Provide the props that describe the left overflow button - */ - leftOverflowButtonProps: PropTypes.object, - - /** - * Specify whether or not to use the light component variant - */ - light: deprecate( - PropTypes.bool, - 'The light prop has been deprecated in v11 in favor of our new layering model that uses the Layer component' - ), - - /** - * Optionally provide an `onClick` handler that is invoked when a is - * clicked - */ - onClick: PropTypes.func, - - /** - * Optionally provide an `onKeyDown` handler that is invoked when keyed - * navigation is triggered - */ - onKeyDown: PropTypes.func, - - /** - * Provide an optional handler that is called whenever the selection - * changes. This method is called with the index of the tab that was - * selected - */ - onSelectionChange: PropTypes.func, - - /** - * Provide the props that describe the right overflow button - */ - rightOverflowButtonProps: PropTypes.object, - - /** - * Choose whether or not to automatically scroll to newly selected tabs - * on component rerender - */ - scrollIntoView: PropTypes.bool, - - /** - * Optionally provide an index for the currently selected - */ - selected: PropTypes.number, - - /** - * Choose whether or not to automatically change selection on focus - */ - selectionMode: PropTypes.oneOf(['automatic', 'manual']), - - /** - * Provide a className that is applied to the components - */ - tabContentClassName: PropTypes.string, - - // /** - // * Provide the type of Tab - // */ - // type: PropTypes.oneOf(['default', 'container']), -}; - -export default ContainedTabs; diff --git a/packages/react/src/components/Tabs/next/Tabs-test.js b/packages/react/src/components/Tabs/next/Tabs-test.js index c95e6731711a..83566e243f22 100644 --- a/packages/react/src/components/Tabs/next/Tabs-test.js +++ b/packages/react/src/components/Tabs/next/Tabs-test.js @@ -1,264 +1,178 @@ import React from 'react'; -import { default as Tabs } from './Tabs'; -import { default as Tab } from '../../Tab/next/Tab'; +import { Tabs, Tab, TabPanel, TabPanels, TabList } from './Tabs'; import { render, screen } from '@testing-library/react'; -import { fireEvent } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; describe('Tabs', () => { - it('adds extra classes that are passed via className prop', async () => { + it('should update selected index based on the default provided', () => { render( - - content1 - content2 + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tabs = screen.getByTestId('tabs-test'); - await expect(tabs.classList.contains('custom-class')).toBe(true); - }); - - it('renders
      with tablist role by default', async () => { - render( - - content1 - content2 - - ); - - const tablist = screen.getByRole('tablist'); - await expect(tablist).toBeTruthy(); - }); -}); - -describe('Children tabs', () => { - it('renders children', async () => { - render( - - content1 - content2 - + expect(screen.getByText('Tab Label 2')).toHaveAttribute( + 'aria-selected', + 'true' ); - - const tabArray = screen.getAllByRole('presentation'); - await expect(tabArray.length).toEqual(2); }); - it('first tab is selected by default', async () => { + it('should set a className from props on outermost element in TabList', () => { render( - - - content1 - - content2 - - ); - - const firstTab = screen.getByTestId('first-tab'); - await expect( - firstTab.classList.contains('bx--tabs__nav-item--selected') - ).toBe(true); - }); - - it('overrides default selected tab when selected prop is provided', async () => { - render( - - - content1 - - - content2 - + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const firstTab = screen.getByTestId('first-tab'); - const secondTab = screen.getByTestId('second-tab'); - - await expect( - firstTab.classList.contains('bx--tabs__nav-item--selected') - ).toBe(false); - await expect( - secondTab.classList.contains('bx--tabs__nav-item--selected') - ).toBe(true); + expect(screen.getByRole('tablist')).toHaveClass('custom-class'); }); }); -describe('Children tab content', () => { - it('renders correct number of children content as expected', async () => { +describe('Tab', () => { + it('should set a className from props on outermost element in Tab', () => { render( - - content1 - content2 + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const contentArray = screen.getAllByRole('tabpanel', { hidden: true }); - await expect(contentArray.length).toEqual(2); + expect(screen.getByText('Tab Label 2')).toHaveClass('custom-class'); }); - it('only shows one content tabpanel at a time', async () => { + it('should not select a disabled tab and select next tab', () => { render( - - content1 - content2 - content3 + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const contentArray = screen.getAllByRole('tabpanel'); - await expect(contentArray.length).toEqual(1); - }); - - it('adds extra classes that are passed via tabContentClassName prop', async () => { - render( - - content1 - content2 - + expect(screen.getByText('Tab Label 1')).toHaveAttribute( + 'aria-selected', + 'false' ); - const content = screen.getByRole('tabpanel'); - await expect(content.classList.contains('content-class')).toBe(true); - }); - - it('renders unselected tab content with hidden attribute', async () => { - render( - - content1 - content2 - + // By default, if a Tab is disabled, the next Tab should be selected + expect(screen.getByText('Tab Label 2')).toHaveAttribute( + 'aria-selected', + 'true' ); - - const contentArray = screen.getAllByRole('tabpanel', { hidden: true }); - await expect(contentArray[1]).toHaveAttribute('hidden'); }); -}); -describe('Keyboard events', () => { - it('updates selected tab and content, and loops from first tab to last tab when pressing left arrow key', async () => { + it('should provide a custom element to render instead of default button if provided', () => { render( - - content1 - - - content2 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - - const tab1 = screen.getByTestId('tab1'); - const tab2 = screen.getByTestId('tab2'); - - const tabContent = screen.getAllByRole('tabpanel'); - const tab1Content = tabContent[0]; - fireEvent.keyDown(tab1, { - key: 'ArrowLeft', - code: 'ArrowLeft', - charCode: 37, - }); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - await expect(tab1Content).toHaveAttribute('hidden'); + expect(screen.getByText('Tab Label 1').tagName).toBe('DIV'); }); - it('updates selected tab and content when pressing right arrow key', async () => { + it('should call onClick from props if provided', () => { + const onClick = jest.fn(); render( - - content1 - - - content2 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tab1 = screen.getByTestId('tab1'); - const tab2 = screen.getByTestId('tab2'); - const tabContent = screen.getAllByRole('tabpanel'); - const tab1Content = tabContent[0]; + userEvent.click(screen.getByText('Tab Label 1')); - fireEvent.keyDown(tab1, { - key: 'ArrowRight', - code: 'ArrowRight', - charCode: 39, - }); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - await expect(tab1Content).toHaveAttribute('hidden'); + expect(onClick).toHaveBeenCalled(); }); - it('ignores disabled tabs', async () => { + it('should call onKeyDown from props if provided', () => { + const onKeyDown = jest.fn(); render( - - content1 - - - content2 - - - content3 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tab1 = screen.getByTestId('tab1'); - const tab3 = screen.getByTestId('tab3'); - fireEvent.keyDown(tab1, { - key: 'ArrowRight', - code: 'ArrowRight', - charCode: 39, - }); - await expect(tab3.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - }); -}); + userEvent.type(screen.getByText('Tab Label 1'), 'enter'); -describe('Click events', () => { - it('updates selected tab and content on click', async () => { - render( - - - content1 - - - content2 - - - ); - const tab2 = screen.getByTestId('tab2'); - const tabContent = screen.getAllByRole('tabpanel'); - const tab1Content = tabContent[0]; - fireEvent.click(tab2); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - true - ); - await expect(tab1Content).toHaveAttribute('hidden'); + expect(onKeyDown).toHaveBeenCalled(); }); +}); - it('ignores disabled tab on click', async () => { +describe('TabPanel', () => { + it('should have a className if provided by props', () => { render( - - content1 - - - content2 - + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); - const tab2 = screen.getByTestId('tab2'); - const tabContent = screen.getAllByRole('tabpanel', { hidden: true }); - const tab2Content = tabContent[1]; - fireEvent.click(tab2); - await expect(tab2.classList.contains('bx--tabs__nav-item--selected')).toBe( - false - ); - await expect(tab2Content).toHaveAttribute('hidden'); + expect(screen.getByText('Tab Panel 1')).toHaveClass('custom-class'); }); }); diff --git a/packages/react/src/components/Tabs/next/Tabs.Skeleton.js b/packages/react/src/components/Tabs/next/Tabs.Skeleton.js index b251865d8982..86a56b0065a4 100644 --- a/packages/react/src/components/Tabs/next/Tabs.Skeleton.js +++ b/packages/react/src/components/Tabs/next/Tabs.Skeleton.js @@ -21,10 +21,10 @@ function Tab() { ); } -function TabsSkeleton({ className, type, ...rest }) { +function TabsSkeleton({ className, contained, ...rest }) { const prefix = usePrefix(); const tabClasses = cx(className, `${prefix}--tabs`, `${prefix}--skeleton`, { - [`${prefix}--tabs--container`]: type === 'container', + [`${prefix}--tabs--contained`]: contained, }); return (
      @@ -48,7 +48,7 @@ TabsSkeleton.propTypes = { /** * Provide the type of Tab */ - type: PropTypes.oneOf(['', 'default', 'container']), + contained: PropTypes.bool, }; export default TabsSkeleton; diff --git a/packages/react/src/components/Tabs/next/Tabs.js b/packages/react/src/components/Tabs/next/Tabs.js index 62b989444950..22f10b9f09af 100644 --- a/packages/react/src/components/Tabs/next/Tabs.js +++ b/packages/react/src/components/Tabs/next/Tabs.js @@ -6,586 +6,409 @@ */ import PropTypes from 'prop-types'; -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import classNames from 'classnames'; -import { ChevronLeft16, ChevronRight16 } from '@carbon/icons-react'; -import debounce from 'lodash.debounce'; +import React, { useState, useRef, useEffect } from 'react'; +import cx from 'classnames'; import { keys, match, matches } from '../../../internal/keyboard'; -import TabContent from '../../TabContent'; -import deprecate from '../../../prop-types/deprecate'; import { usePrefix } from '../../../internal/usePrefix'; +import { useId } from '../../../internal/useId'; +import { getInteractiveContent } from '../../../internal/useNoInteractiveChildren'; +import { useControllableState } from '../../ContentSwitcher/next/useControllableState'; +import { useMergedRefs } from '../../../internal/useMergedRefs'; + +// Used to manage the overall state of the Tabs +const TabsContext = React.createContext(); + +// Used to keep track of position in a tablist +const TabContext = React.createContext(); + +// Used to keep track of position in a list of tab panels +const TabPanelContext = React.createContext(); +function Tabs({ + children, + defaultSelectedIndex = 0, + onChange, + selectedIndex: controlledSelectedIndex, +}) { + const baseId = useId('ccs'); + // The active index is used to track the element which has focus in our tablist + const [activeIndex, setActiveIndex] = useState(defaultSelectedIndex); + // The selected index is used for the tab/panel pairing which is "visible" + const [selectedIndex, setSelectedIndex] = useControllableState({ + value: controlledSelectedIndex, + defaultValue: defaultSelectedIndex, + onChange: (value) => { + if (onChange) { + onChange({ selectedIndex: value }); + } + }, + }); -const Tabs = React.forwardRef(function Tabs( - { - children, - className, - leftOverflowButtonProps, - light = false, - onSelectionChange, - rightOverflowButtonProps, - scrollIntoView = true, - selected = 0, - selectionMode = 'automatic', - tabContentClassName, - ...other - }, - ref -) { - const prefix = usePrefix(); - - //refs - const tablist = useRef(); - const leftOverflowNavButton = useRef(); - const rightOverflowNavButton = useRef(); - const tabs = useRef([]); + const value = { + baseId, + activeIndex, + defaultSelectedIndex, + setActiveIndex, + selectedIndex, + setSelectedIndex, + }; - //states - const [horizontalOverflow, setHorizontalOverflow] = useState(false); - const [tablistClientWidth, setTablistClientWidth] = useState(null); - const [tablistScrollWidth, setTablistScrollWidth] = useState(null); - const [tablistScrollLeft, setTablistScrollLeft] = useState(null); - const [isSelected, setIsSelected] = useState(selected); - const [prevSelected, setPrevSelected] = useState(isSelected); + return {children}; +} +Tabs.propTypes = { /** - * prop + state alignment - getDerivedStateFromProps - * only update if selected prop changes + * Provide child elements to be rendered inside of the `Tabs`. + * These elements should render either `TabsList` or `TabsPanels` */ - useEffect(() => { - if (selected !== prevSelected) { - setIsSelected(selected); - setPrevSelected(selected); - } - }, [selected]); //eslint-disable-line react-hooks/exhaustive-deps - - // width of the overflow buttons - let OVERFLOW_BUTTON_OFFSET = 40; + children: PropTypes.node, /** - * `scroll` event handler to save tablist clientWidth, scrollWidth, and - * scrollLeft + * Specify which content tab should be initially selected when the component + * is first rendered */ - const handleScroll = () => { - if (!tablist?.current) { - return; - } - const { clientWidth, scrollLeft, scrollWidth } = tablist.current; - - setTablistClientWidth(clientWidth); - setTablistScrollWidth(scrollWidth); - setTablistScrollLeft(scrollLeft); - setHorizontalOverflow(scrollWidth > clientWidth); - }; + defaultSelectedIndex: PropTypes.number, /** - * The debounced version of the `resize` event handler. - * @type {Function} - * @private + * Provide an optional function which is called whenever the state of the + * `Tabs` changes */ - const _debouncedHandleWindowResize = useRef(); - - const _handleWindowResize = handleScroll; + onChange: PropTypes.func, /** - * returns all tabs that are not disabled - * used for keyboard navigation + * Control which content panel is currently selected. This puts the component + * in a controlled mode and should be used along with `onChange` */ - const getEnabledTabs = () => - React.Children.toArray(children).reduce( - (enabledTabs, tab, index) => - !tab.props.disabled ? enabledTabs.concat(index) : enabledTabs, - [] - ); + selectedIndex: PropTypes.number, +}; - /** - * returns the index of the next tab we are going to when navigating L/R arrow keys (i.e. 0, 1, 2) - * used in handleTabKeyDown to get the next index after keyboard arrow evt, which then updates selected tab - */ - const getNextIndex = (index, direction) => { - const enabledTabs = getEnabledTabs(); - const nextIndex = Math.max( - enabledTabs.indexOf(index) + direction, - // For `tab` not found in `enabledTabs` - -1 - ); - const nextIndexLooped = - nextIndex >= 0 && nextIndex < enabledTabs.length - ? nextIndex - : nextIndex - Math.sign(nextIndex) * enabledTabs.length; - return enabledTabs[nextIndexLooped]; - }; +function useEffectOnce(callback) { + const savedCallback = useRef(callback); + const effectGuard = useRef(false); - /** - * used as second argument for getNextIndex(i,d) - * returns -1, 1 or 0 depending on arrow key - * number is then used in math calculations to find the index of the next tab we are navigating to - */ - const getDirection = (evt) => { - if (match(evt, keys.ArrowLeft)) { - return -1; - } - if (match(evt, keys.ArrowRight)) { - return 1; + useEffect(() => { + savedCallback.current = callback; + }); + + useEffect(() => { + if (effectGuard.current !== true) { + effectGuard.current = true; + savedCallback.current(); } + }, []); +} + +/** + * Get the next index for a given keyboard event given a count of the total + * items and the current index + * @param {Event} event + * @param {number} total + * @param {number} index + * @returns {number} + */ +function getNextIndex(event, total, index) { + if (match(event, keys.ArrowRight)) { + return (index + 1) % total; + } else if (match(event, keys.ArrowLeft)) { + return (total + index - 1) % total; + } else if (match(event, keys.Home)) { return 0; - }; + } else if (match(event, keys.End)) { + return total - 1; + } +} + +function TabList({ + activation = 'automatic', + 'aria-label': label, + children, + className: customClassName, + light, + scrollIntoView, + contained = false, + ...rest +}) { + const { + activeIndex, + selectedIndex, + setSelectedIndex, + setActiveIndex, + } = React.useContext(TabsContext); + const prefix = usePrefix(); + const ref = useRef(null); + const className = cx(`${prefix}--tabs`, customClassName, { + [`${prefix}--tabs--contained`]: contained, + [`${prefix}--tabs--light`]: light, + }); - const getTabAt = useCallback( - (index) => tabs.current[index] || React.Children.toArray(children)[index], - [tabs, children] - ); + const tabs = []; - const scrollTabIntoView = (event, { index }) => { - const tab = getTabAt(index); + function onKeyDown(event) { if ( - matches(event, [keys.ArrowLeft, keys.ArrowRight]) || - event.type === 'click' + matches(event, [keys.ArrowRight, keys.ArrowLeft, keys.Home, keys.End]) ) { - const currentScrollLeft = tablistScrollLeft; - tab?.tabAnchor?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', + const activeTabs = tabs.filter((tab) => { + return !tab.current.disabled; }); - tab?.tabAnchor?.focus(); - const newScrollLeft = tablist.current.scrollLeft; - if (newScrollLeft > currentScrollLeft) { - tablist.current.scrollLeft += OVERFLOW_BUTTON_OFFSET; + + const currentIndex = activeTabs.indexOf( + tabs[activation === 'automatic' ? selectedIndex : activeIndex] + ); + const nextIndex = tabs.indexOf( + activeTabs[getNextIndex(event, activeTabs.length, currentIndex)] + ); + + if (activation === 'automatic') { + setSelectedIndex(nextIndex); + } else if (activation === 'manual') { + setActiveIndex(nextIndex); } + + tabs[nextIndex].current.focus(); } - }; + } - /** - * selecting tab on click and on keyboard nav - * index = tab to be selected, returned in handleTabKeyDown - * onSelectionChange = optional prop for event handler - */ - const selectTabAt = (event, { index, onSelectionChange }) => { - scrollTabIntoView(event, { index }); - if (isSelected !== index) { - setIsSelected(index); - setPrevSelected(index); - if (typeof onSelectionChange === 'function') { - onSelectionChange(index); - } + useEffectOnce(() => { + const tab = tabs[selectedIndex]; + if (scrollIntoView && tab) { + tab.current.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); } - }; + }); - /** - * keyboard event handler - */ - const handleTabKeyDown = (onSelectionChange) => { - return (index, evt) => { - if (matches(evt, [keys.Enter, keys.Space])) { - selectTabAt(evt, { index, onSelectionChange }); - } + useEffectOnce(() => { + if (tabs[selectedIndex].current.disabled) { + const activeTabs = tabs.filter((tab) => { + return !tab.current.disabled; + }); - const nextIndex = (() => { - if (matches(evt, [keys.ArrowLeft, keys.ArrowRight])) { - return getNextIndex(index, getDirection(evt)); - } - if (match(evt, keys.Home)) { - return 0; - } - if (match(evt, keys.End)) { - return getEnabledTabs().pop(); - } - })(); - const tab = getTabAt(nextIndex); - - // updating selected tab - if ( - matches(evt, [keys.ArrowLeft, keys.ArrowRight, keys.Home, keys.End]) - ) { - evt.preventDefault(); - if (selectionMode !== 'manual') { - selectTabAt(evt, { index: nextIndex, onSelectionChange }); - } else { - scrollTabIntoView(evt, { index: nextIndex }); - } - tab?.focus(); + if (activeTabs.length > 0) { + const tab = activeTabs[0]; + setSelectedIndex(tabs.indexOf(tab)); } - }; - }; + } + }); - const getTabs = () => React.Children.map(children, (tab) => tab); + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus +
      + {React.Children.map(children, (child, index) => { + const ref = React.createRef(); + tabs.push(ref); + return ( + + {React.cloneElement(child, { + ref, + })} + + ); + })} +
      + ); +} +TabList.propTypes = { /** - * click handler - * passed down to Tab children as a prop in `tabsWithProps` - * following functions (handle*) are Props on Tab.js, see Tab.js for parameters + * Specify whether the content tab should be activated automatically or + * manually */ - const handleTabClick = (onSelectionChange) => (index, evt) => { - evt.preventDefault(); - selectTabAt(evt, { index, onSelectionChange }); - }; + activation: PropTypes.oneOf(['automatic', 'manual']), /** - * creates an array of all the child tab items + * Provide an accessible label to be read when a user interacts with this + * component */ - const setTabAt = (index, tabRef) => { - tabs.current[index] = tabRef; - }; - - let overflowNavInterval = null; + 'aria-label': PropTypes.string.isRequired, /** - * group - overflow scroll - * scrolling via overflow btn click - * click handler for scrollable tabs L/R arrow buttons + * Provide child elements to be rendered inside of `ContentTabs`. + * These elements should render a `ContentTab` */ - const handleOverflowNavClick = (_, { direction, multiplier = 10 }) => { - // account for overflow button appearing and causing tablist width change - const { clientWidth, scrollLeft, scrollWidth } = tablist?.current; - if (direction === 1 && !scrollLeft) { - tablist.current.scrollLeft += OVERFLOW_BUTTON_OFFSET; - } - - tablist.current.scrollLeft += direction * multiplier; - - const leftEdgeReached = - direction === -1 && scrollLeft < OVERFLOW_BUTTON_OFFSET; - - const rightEdgeReached = - direction === 1 && - scrollLeft + clientWidth >= scrollWidth - OVERFLOW_BUTTON_OFFSET; - - if (leftEdgeReached || rightEdgeReached) { - if (leftEdgeReached) { - rightOverflowNavButton?.current?.focus(); - } - if (rightEdgeReached) { - leftOverflowNavButton?.current?.focus(); - } - } - }; + children: PropTypes.node, /** - * group - overflow scroll - * scrolling w/ mouse event - * mousedown handler for scrollable tabs + * Specify an optional className to be added to the container node */ - const handleOverflowNavMouseDown = (event, { direction }) => { - // disregard mouse buttons aside from LMB - if (event.buttons !== 1) { - return; - } - - overflowNavInterval = setInterval(() => { - const { clientWidth, scrollLeft, scrollWidth } = tablist?.current; - - // clear interval if scroll reaches left or right edge - const leftEdgeReached = - direction === -1 && scrollLeft < OVERFLOW_BUTTON_OFFSET; - - const rightEdgeReached = - direction === 1 && - scrollLeft + clientWidth >= scrollWidth - OVERFLOW_BUTTON_OFFSET; - - if (leftEdgeReached || rightEdgeReached) { - clearInterval(overflowNavInterval); - } - - // account for overflow button appearing and causing tablist width change - handleOverflowNavClick(event, { direction }); - }); - }; - + className: PropTypes.string, /** - * group - overflow scroll - * scrolling w/ mouse event - * mouseup handler for scrollable tabs + * Specify whether component is contained type */ - const handleOverflowNavMouseUp = () => { - clearInterval(overflowNavInterval); - }; + contained: PropTypes.bool, /** - * only run once - component did mount equivalent + * Specify whether or not to use the light component variant */ - useEffect(() => { - _debouncedHandleWindowResize.current = debounce(_handleWindowResize, 200); - - _handleWindowResize(); - window.addEventListener('resize', _debouncedHandleWindowResize.current); - - // scroll selected tab into view on mount - const { clientWidth, scrollLeft, scrollWidth } = tablist?.current || {}; - - setTablistClientWidth(clientWidth); - setTablistScrollWidth(scrollWidth); - setTablistScrollLeft(scrollLeft); - - const tab = getTabAt(isSelected); - const horizontalOverflow = scrollWidth > clientWidth; - - if (horizontalOverflow) { - const leftOverflowNavButtonHidden = - tab?.tabAnchor?.getBoundingClientRect().right < - tab?.tabAnchor?.offsetParent.getBoundingClientRect().right; - - const rightOverflowNavButtonHidden = - scrollLeft + clientWidth === scrollWidth; - scrollIntoView && - tab?.tabAnchor?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', - }); - - // account for overflow buttons in scroll position on mount - if (!leftOverflowNavButtonHidden && !rightOverflowNavButtonHidden) { - tablist.current.scrollLeft += OVERFLOW_BUTTON_OFFSET * 2; - } - } - - //component will unmount equivalent - return () => { - if (_debouncedHandleWindowResize.current) { - _debouncedHandleWindowResize.current.cancel(); - } - window.removeEventListener( - 'resize', - _debouncedHandleWindowResize.current - ); - }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps - + light: PropTypes.bool, /** - * component did update equivalent + * Choose whether or not to automatically scroll to newly selected tabs + * on component rerender */ - useEffect(() => { - // compare current tablist properties to current state - const { - clientWidth: currentTablistClientWidth, - scrollLeft: currentTablistScrollLeft, - scrollWidth: currentTablistScrollWidth, - } = tablist.current; - - if ( - currentTablistClientWidth !== tablistClientWidth || - currentTablistScrollLeft !== tablistScrollLeft || - currentTablistScrollWidth !== tablistScrollWidth - ) { - setTablistClientWidth(currentTablistClientWidth); - setTablistScrollWidth(currentTablistScrollWidth); - setTablistScrollLeft(currentTablistScrollLeft); - setHorizontalOverflow( - currentTablistScrollWidth > currentTablistClientWidth - ); - } + scrollIntoView: PropTypes.bool, +}; - if (scrollIntoView && prevSelected !== isSelected) { - getTabAt(isSelected)?.tabAnchor?.scrollIntoView({ - block: 'nearest', - inline: 'nearest', - }); +const Tab = React.forwardRef(function Tab( + { + as: BaseComponent = 'button', + children, + className: customClassName, + disabled, + onClick, + onKeyDown, + ...rest + }, + ref +) { + const prefix = usePrefix(); + const { selectedIndex, setSelectedIndex, baseId } = React.useContext( + TabsContext + ); + const index = React.useContext(TabContext); + const id = `${baseId}-tab-${index}`; + const panelId = `${baseId}-tabpanel-${index}`; + const className = cx( + `${prefix}--tabs__nav-item`, + `${prefix}--tabs__nav-link`, + customClassName, + { + [`${prefix}--tabs__nav-item--selected`]: selectedIndex === index, + [`${prefix}--tabs__nav-item--disabled`]: disabled, } - }, [ - isSelected, - prevSelected, - scrollIntoView, - tablistClientWidth, - tablistScrollLeft, - tablistScrollWidth, - getTabAt, - ]); - - /** - * The tab panel acts like a tab panel when the screen is wider, but acts - * like a select list when the screen is narrow. In the wide case we want - * to allow the user to use the tab key to set the focus in the tab panel - * and then use the left and right arrow keys to navigate the tabs. In the - * narrow case we want to use the tab key to select different options in - * the list. - * - * We set the tab index based on the different states so the browser will treat - * the whole tab panel as a single focus component when it looks like a tab - * panel and separate components when it looks like a select list. - */ - const tabsWithProps = getTabs().map((tab, index) => { - const tabIndex = index === isSelected ? 0 : -1; - const newTab = React.cloneElement(tab, { - index, - selected: index === isSelected, - handleTabClick: handleTabClick(onSelectionChange), - tabIndex, - ref: (e) => { - setTabAt(index, e); - }, - handleTabKeyDown: handleTabKeyDown(onSelectionChange), - }); - - return newTab; - }); - - const tabContentWithProps = React.Children.map(tabsWithProps, (tab) => { - const { - id: tabId, - children, - selected, - renderContent: Content = TabContent, - } = tab.props; - - return ( - - ); - }); - - const leftOverflowNavButtonHidden = !horizontalOverflow || !tablistScrollLeft; - - const rightOverflowNavButtonHidden = - !horizontalOverflow || - tablistScrollLeft + tablistClientWidth === tablistScrollWidth; - - const classes = { - tabs: classNames(className, `${prefix}--tabs`, { - [`${prefix}--tabs--light`]: light, - }), - tablist: classNames(`${prefix}--tabs__nav`), - leftOverflowButtonClasses: classNames({ - [`${prefix}--tab--overflow-nav-button`]: horizontalOverflow, - [`${prefix}--tab--overflow-nav-button--hidden`]: leftOverflowNavButtonHidden, - }), - rightOverflowButtonClasses: classNames({ - [`${prefix}--tab--overflow-nav-button`]: horizontalOverflow, - [`${prefix}--tab--overflow-nav-button--hidden`]: rightOverflowNavButtonHidden, - }), - }; + ); return ( - <> -
      - - {!leftOverflowNavButtonHidden && ( -
      - )} -
        - {tabsWithProps} -
      - {!rightOverflowNavButtonHidden && ( -
      - )} - -
      - {tabContentWithProps} - + { + if (disabled) { + return; + } + setSelectedIndex(index); + if (onClick) { + onClick(evt); + } + }} + onKeyDown={onKeyDown} + tabIndex={selectedIndex === index ? '0' : '-1'} + type="button"> + {children} + ); }); -Tabs.propTypes = { - /** - * Pass in a collection of children to be rendered depending on the - * currently selected tab - */ - children: PropTypes.node, - +Tab.propTypes = { /** - * Provide a className that is applied to the root
      component for the - * + * Provide a custom element to render instead of the default button */ - className: PropTypes.string, - + as: PropTypes.oneOfType([PropTypes.string, PropTypes.elementType]), /** - * Specify whether the Tab content is hidden + * Provide child elements to be rendered inside of `Tab`. */ - hidden: PropTypes.bool, - + children: PropTypes.node, /** - * Provide the props that describe the left overflow button + * Specify an optional className to be added to your Tab */ - leftOverflowButtonProps: PropTypes.object, - + className: PropTypes.string, /** - * Specify whether or not to use the light component variant + * Whether your Tab is disabled. */ - light: deprecate( - PropTypes.bool, - 'The light prop has been deprecated in v11 in favor of our new layering model that uses the Layer component' - ), - + disabled: PropTypes.bool, /** - * Optionally provide an `onClick` handler that is invoked when a is - * clicked + * Provide a handler that is invoked when a user clicks on the control */ onClick: PropTypes.func, - /** - * Optionally provide an `onKeyDown` handler that is invoked when keyed - * navigation is triggered + * Provide a handler that is invoked on the key down event for the control */ onKeyDown: PropTypes.func, + /* + * An optional parameter to allow overriding the anchor rendering. + * Useful for using Tab along with react-router or other client + * side router libraries. + **/ + renderButton: PropTypes.func, +}; - /** - * Provide an optional handler that is called whenever the selection - * changes. This method is called with the index of the tab that was - * selected - */ - onSelectionChange: PropTypes.func, +const TabPanel = React.forwardRef(function TabPanel( + { children, className: customClassName, ...rest }, + forwardRef +) { + const prefix = usePrefix(); + const panel = useRef(null); + const ref = useMergedRefs([forwardRef, panel]); - /** - * Provide the props that describe the right overflow button - */ - rightOverflowButtonProps: PropTypes.object, + const [tabIndex, setTabIndex] = useState('0'); + const { selectedIndex, baseId } = React.useContext(TabsContext); + const index = React.useContext(TabPanelContext); + const id = `${baseId}-tabpanel-${index}`; + const tabId = `${baseId}-tab-${index}`; + const className = cx(`${prefix}--tab-content`, customClassName); - /** - * Choose whether or not to automatically scroll to newly selected tabs - * on component rerender - */ - scrollIntoView: PropTypes.bool, + // tabindex should only be 0 if no interactive content in children + useEffect(() => { + const interactiveContent = getInteractiveContent(panel.current); + if (interactiveContent) { + setTabIndex('-1'); + } + }, []); + + return ( + + ); +}); +TabPanel.propTypes = { /** - * Optionally provide an index for the currently selected + * Provide child elements to be rendered inside of `TabPanel`. */ - selected: PropTypes.number, - + children: PropTypes.node, /** - * Choose whether or not to automatically change selection on focus + * Specify an optional className to be added to TabPanel. */ - selectionMode: PropTypes.oneOf(['automatic', 'manual']), + className: PropTypes.string, +}; +function TabPanels({ children }) { + return React.Children.map(children, (child, index) => { + return ( + {child} + ); + }); +} + +TabPanels.propTypes = { /** - * Provide a className that is applied to the components + * Provide child elements to be rendered inside of `TabPanels`. */ - tabContentClassName: PropTypes.string, + children: PropTypes.node, }; -export default Tabs; +export { Tabs, Tab, TabPanel, TabPanels, TabList }; + +// TO DO: implement horizontal scroll and the following props: +// leftOverflowButtonProps +// rightOverflowButtonProps diff --git a/packages/react/src/components/Tabs/next/Tabs.stories.js b/packages/react/src/components/Tabs/next/Tabs.stories.js index 33457ffb304e..a5ec931fc9e6 100644 --- a/packages/react/src/components/Tabs/next/Tabs.stories.js +++ b/packages/react/src/components/Tabs/next/Tabs.stories.js @@ -6,13 +6,11 @@ */ import React from 'react'; -import { Button } from 'carbon-components-react'; +import { Tabs, TabList, Tab, TabPanels, TabPanel } from './Tabs'; +import Button from '../../Button'; -import ContainedTab from '../../Tab/next/ContainedTab'; -import ContainedTabs from './ContainedTabs'; import TabsSkeleton from './Tabs.Skeleton'; -import Tab from '../../Tab/next/Tab'; -import Tabs from './Tabs'; +import { Monster20, Corn20, Bat20 } from '@carbon/icons-react'; import { unstable_FeatureFlags as FeatureFlags } from 'carbon-components-react'; @@ -28,57 +26,75 @@ export default { parameters: { component: Tabs, subcomponents: { + TabList, Tab, + TabPanels, + TabPanel, }, }, }; export const Default = () => ( - -

      Content for first tab goes here.

      -
      - -

      Content for second tab goes here.

      - -
      - -

      Content for third tab goes here.

      -
      - -

      Content for fourth tab goes here.

      -
      - Custom Label
      }> -

      Content for fifth tab goes here.

      -
      + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 with a very long long label + Tab Label 5 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + +); + +export const IconOnly = () => ( + + + + + + + + + + + + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + ); export const Contained = () => ( - - -

      Content for first tab goes here.

      -
      - -

      Content for second tab goes here.

      - -
      - -

      Content for third tab goes here.

      -
      - -

      Content for fourth tab goes here.

      -
      - Custom Label
      }> -

      Content for fifth tab goes here.

      - - + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 with a very long long title + Tab Label 5 + + + Tab Panel 1 + + Tab Panel 2 + + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + ); export const Skeleton = () => { diff --git a/packages/react/src/index.js b/packages/react/src/index.js index d3fba943902d..3f1ca6198c6b 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -239,12 +239,15 @@ export { Stack as unstable_Stack, VStack as unstable_VStack, } from './components/Stack'; -export { ContainedTab as unstable_ContainedTab } from './components/Tab'; -export { ContainedTabs as unstable_ContainedTabs } from './components/Tabs'; export { Tooltip as unstable_Tooltip } from './components/Tooltip/next'; export unstable_TreeView, { TreeNode as unstable_TreeNode, } from './components/TreeView'; +export { + TabPanel as unstable_TabPanel, + TabPanels as unstable_TabPanels, + TabList as unstable_TabList, +} from './components/Tabs'; export { usePrefix as unstable_usePrefix } from './internal/usePrefix'; export { Theme as unstable_Theme, diff --git a/packages/react/src/internal/useNoInteractiveChildren.js b/packages/react/src/internal/useNoInteractiveChildren.js index f0e6eb4680d7..46005d99bcfd 100644 --- a/packages/react/src/internal/useNoInteractiveChildren.js +++ b/packages/react/src/internal/useNoInteractiveChildren.js @@ -32,7 +32,7 @@ export function useNoInteractiveChildren( * @param {HTMLElement} node * @returns {HTMLElement} */ -function getInteractiveContent(node) { +export function getInteractiveContent(node) { if (isFocusable(node)) { return node; } diff --git a/packages/styles/scss/components/tabs/_tabs.scss b/packages/styles/scss/components/tabs/_tabs.scss index 2435ec7c4716..74df93d2f43f 100644 --- a/packages/styles/scss/components/tabs/_tabs.scss +++ b/packages/styles/scss/components/tabs/_tabs.scss @@ -33,7 +33,6 @@ /// @access public /// @group tabs @mixin tabs { - // TODO: remove namespace and suffix in next major release .#{$prefix}--tabs { @include reset; @include type-style('body-short-01'); @@ -44,7 +43,7 @@ min-height: rem(40px); color: $text-primary; - &.#{$prefix}--tabs--container { + &.#{$prefix}--tabs--contained { min-height: rem(48px); } @@ -95,11 +94,11 @@ background-image: linear-gradient(to right, transparent, $layer); } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__overflow-indicator--left { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--left { background-image: linear-gradient(to left, transparent, $layer-accent); } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__overflow-indicator--right { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--right { background-image: linear-gradient(to right, transparent, $layer-accent); } @@ -124,7 +123,7 @@ ); } - &.#{$prefix}--tabs--container + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--left { background-image: linear-gradient( to left, @@ -133,7 +132,7 @@ ); } - &.#{$prefix}--tabs--container + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__overflow-indicator--right { background-image: linear-gradient( to right, @@ -162,7 +161,7 @@ display: none; } - &.#{$prefix}--tabs--container .#{$prefix}--tab--overflow-nav-button { + &.#{$prefix}--tabs--contained .#{$prefix}--tab--overflow-nav-button { width: $spacing-09; margin: 0; background-color: $layer-accent; @@ -189,11 +188,11 @@ margin-left: rem(1px); } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item { background-color: $layer-accent; } - &.#{$prefix}--tabs--container + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item + .#{$prefix}--tabs__nav-item { margin-left: 0; @@ -201,87 +200,12 @@ box-shadow: rem(-1px) 0 0 0 $border-strong; } - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item - + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--selected, - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--selected - + .#{$prefix}--tabs__nav-item { - box-shadow: none; - } - .#{$prefix}--tabs__nav-item .#{$prefix}--tabs__nav-link { transition: color $duration-fast-01 motion(standard, productive), border-bottom-color $duration-fast-01 motion(standard, productive), outline $duration-fast-01 motion(standard, productive); } - //----------------------------- - // Item Hover - //----------------------------- - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item:hover { - background-color: $layer-selected-hover; - } - - //--------------------------------------------- - // Item Disabled - //--------------------------------------------- - .#{$prefix}--tabs__nav-item--disabled, - .#{$prefix}--tabs__nav-item--disabled:hover { - background-color: transparent; - cursor: not-allowed; - outline: none; - } - - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled, - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled:hover { - background-color: button.$button-disabled; - } - - //----------------------------- - // Item Selected - //----------------------------- - .#{$prefix}--tabs__nav-item--selected { - transition: color $duration-fast-01 motion(standard, productive); - } - - .#{$prefix}--tabs__nav-item--selected .#{$prefix}--tabs__nav-link, - .#{$prefix}--tabs__nav-item--selected .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-item--selected .#{$prefix}--tabs__nav-link:active { - @include type-style('productive-heading-01'); - - border-bottom: 2px solid $border-interactive; - color: $text-primary; - } - - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item--selected, - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item--selected:hover { - background-color: $layer; - - .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-link:active { - box-shadow: none; - } - } - - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected - .#{$prefix}--tabs__nav-link { - // Draws the border without affecting the inner-content - box-shadow: inset 0 2px 0 0 $border-interactive; - // height - vertical padding - line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); - } - - &.#{$prefix}--tabs--light.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected, - &.#{$prefix}--tabs--light.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected:hover { - background-color: $background; - } - //----------------------------- // Link //----------------------------- @@ -295,10 +219,10 @@ } overflow: hidden; + max-width: 10rem; padding: $spacing-04 $spacing-05 $spacing-03; border-bottom: $tab-underline-color; color: $text-secondary; - text-align: left; text-decoration: none; text-overflow: ellipsis; transition: border $duration-fast-01 motion(standard, productive), @@ -311,12 +235,70 @@ } } - &.#{$prefix}--tabs--container .#{$prefix}--tabs__nav-link { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-link { height: rem(48px); padding: $spacing-03 $spacing-05; border-bottom: 0; // height - vertical padding line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); + text-align: left; + } + + //----------------------------- + // Item Hover + //----------------------------- + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item:hover { + background-color: $layer-selected-hover; + } + + //----------------------------- + // Item Selected + //----------------------------- + .#{$prefix}--tabs__nav-item--selected { + border-bottom: 2px solid $border-interactive; + transition: color $duration-fast-01 motion(standard, productive); + } + + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item--selected + + .#{$prefix}--tabs__nav-item { + box-shadow: none; + } + + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--selected { + // Draws the border without affecting the inner-content + box-shadow: inset 0 2px 0 0 $border-interactive; + } + + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--selected, + .#{$prefix}--tabs__nav-item--selected, + .#{$prefix}--tabs__nav-item--selected:focus + .#{$prefix}--tabs__nav-link:focus, + .#{$prefix}--tabs__nav-item--selected:active + .#{$prefix}--tabs__nav-link:active { + @include type-style('productive-heading-01'); + + color: $text-primary; + } + + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--selected, + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--selected:hover { + background-color: $layer; + // height - vertical padding + line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); + + .#{$prefix}--tabs__nav-link:focus, + .#{$prefix}--tabs__nav-link:active { + box-shadow: none; + } + } + + &.#{$prefix}--tabs--light.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item--selected, + &.#{$prefix}--tabs--light.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item--selected:hover { + background-color: $background; } //----------------------------- @@ -327,31 +309,35 @@ color: $text-primary; } - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item - .#{$prefix}--tabs__nav-link { - border-bottom: none; - } - //----------------------------- - // Link Disabled + // Item Disabled //----------------------------- - .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link { + + .#{$prefix}--tabs__nav-item--disabled { border-bottom: $tab-underline-disabled; + background-color: transparent; color: $tab-text-disabled; + outline: none; } - .#{$prefix}--tabs__nav-item--disabled:hover .#{$prefix}--tabs__nav-link { + .#{$prefix}--tabs__nav-item--disabled:hover { border-bottom: $tab-underline-disabled; color: $tab-text-disabled; cursor: not-allowed; - pointer-events: none; } - .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link:active { + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled, + &.#{$prefix}--tabs--contained + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled:hover { + background-color: button.$button-disabled; + } + + .#{$prefix}--tabs__nav-item--disabled:focus, + .#{$prefix}--tabs__nav-item--disabled:active { border-bottom: $tab-underline-disabled; outline: none; + pointer-events: none; } .#{$prefix}--tabs--light @@ -375,9 +361,7 @@ border-bottom-color: $border-subtle; } - &.#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--disabled - .#{$prefix}--tabs__nav-link { + &.#{$prefix}--tabs--contained .#{$prefix}--tabs__nav-item--disabled { border-bottom: none; color: $text-on-color-disabled; } @@ -401,7 +385,7 @@ pointer-events: none; } - .#{$prefix}--skeleton.#{$prefix}--tabs:not(.#{$prefix}--tabs--container) + .#{$prefix}--skeleton.#{$prefix}--tabs:not(.#{$prefix}--tabs--contained) .#{$prefix}--tabs__nav-link { border-bottom: 2px solid $skeleton-element; } @@ -429,7 +413,6 @@ @include high-contrast-mode('focus'); } - // stylelint-disable-next-line no-duplicate-selectors .#{$prefix}--tabs .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link {