diff --git a/e2e/components/Tabs/Tabs-test.e2e.js b/e2e/components/Tabs/Tabs-test.e2e.js index b3c1c2988dcb..9548d2112a0c 100644 --- a/e2e/components/Tabs/Tabs-test.e2e.js +++ b/e2e/components/Tabs/Tabs-test.e2e.js @@ -70,6 +70,14 @@ test.describe('Tabs', () => { }); }); + test('contained fullWidth @vrt', async ({ page }) => { + await snapshotStory(page, { + component: 'Tabs', + id: 'components-tabs--contained-full-width', + theme, + }); + }); + test('contained with secondary labels @vrt', async ({ page }) => { await snapshotStory(page, { component: 'Tabs', diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index f40e745dde10..0344673b85b6 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -7605,6 +7605,9 @@ Map { "contained": Object { "type": "bool", }, + "fullWidth": Object { + "type": "bool", + }, "iconSize": Object { "args": Array [ Array [ diff --git a/packages/react/src/components/Tabs/Tabs-test.js b/packages/react/src/components/Tabs/Tabs-test.js index 8ac857ef54e3..393b29235e99 100644 --- a/packages/react/src/components/Tabs/Tabs-test.js +++ b/packages/react/src/components/Tabs/Tabs-test.js @@ -3,10 +3,16 @@ import { Tabs, Tab, TabPanel, TabPanels, TabList } from './Tabs'; import { act } from 'react-dom/test-utils'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import * as hooks from '../../internal/useMatchMedia'; const prefix = 'cds'; describe('Tabs', () => { + beforeEach(() => { + jest.resetModules(); + jest.spyOn(hooks, 'useMatchMedia').mockImplementation(() => true); + }); + it('should update selected index based on the default provided', () => { render( @@ -53,6 +59,11 @@ describe('Tabs', () => { }); describe('Tab', () => { + beforeEach(() => { + jest.resetModules(); + jest.spyOn(hooks, 'useMatchMedia').mockImplementation(() => true); + }); + it('should set a className from props on outermost element in Tab', () => { render( @@ -437,6 +448,11 @@ describe('Tab', () => { }); describe('TabPanel', () => { + beforeEach(() => { + jest.resetModules(); + jest.spyOn(hooks, 'useMatchMedia').mockImplementation(() => true); + }); + it('should have a className if provided by props', () => { render( @@ -536,3 +552,93 @@ describe('TabPanel', () => { expect(screen.getByText('Tab Panel 1')).toHaveAttribute('tabIndex', '0'); }); }); + +describe('TabList', () => { + it('should span fullWidth if lg and fullWidth prop is passed in', () => { + jest.spyOn(hooks, 'useMatchMedia').mockImplementation(() => true); + const { container } = render( + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + + + ); + + expect(container.firstChild).toHaveClass(`${prefix}--tabs--full-width`); + }); + + it('should ignore fullWidth prop if screen smaller than lg', () => { + jest.spyOn(hooks, 'useMatchMedia').mockImplementation(() => false); + const { container } = render( + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + + + ); + + expect(container.firstChild).not.toHaveClass(`${prefix}--tabs--full-width`); + }); + + it('should ignore fullWidth prop if tabs are not contained', () => { + jest.spyOn(hooks, 'useMatchMedia').mockImplementation(() => true); + const { container } = render( + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + + + ); + + expect(container.firstChild).not.toHaveClass(`${prefix}--tabs--full-width`); + }); + + it('should not be fullWidth in default state', () => { + jest.spyOn(hooks, 'useMatchMedia').mockImplementation(() => true); + const { container } = render( + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + + Tab Panel 1 + + Tab Panel 2 + Tab Panel 3 + + + ); + + expect(container.firstChild).not.toHaveClass(`${prefix}--tabs--full-width`); + }); +}); diff --git a/packages/react/src/components/Tabs/Tabs.mdx b/packages/react/src/components/Tabs/Tabs.mdx index 498ce7f4c2cf..311f0908bbe2 100644 --- a/packages/react/src/components/Tabs/Tabs.mdx +++ b/packages/react/src/components/Tabs/Tabs.mdx @@ -1,5 +1,6 @@ -import { Props, Preview, Story } from '@storybook/addon-docs'; +import { Props, Preview, Story, Canvas } from '@storybook/addon-docs'; import { Tabs, TabList, Tab, TabPanels, TabPanel } from './Tabs'; +import { Grid, Column } from '../Grid' # Tabs @@ -9,6 +10,8 @@ import { Tabs, TabList, Tab, TabPanels, TabPanel } from './Tabs';  |  [Accessibility](https://www.carbondesignsystem.com/components/tabs/accessibility) + + ## Table of Contents - [Overview](#overview) @@ -18,12 +21,15 @@ import { Tabs, TabList, Tab, TabPanels, TabPanel } from './Tabs'; - [Dismissable Tabs](#dismissable-tabs) - [Component API](#component-api) - [Tab - render content on click](#tab---render-content-on-click) + - [Tabs and the Grid - fullWidth prop](#tabs-and-the-grid---fullwidth-prop) - [V11](#v11) - [Tabs composition](#tabs-composition) - [Various updates](#various-updates) - [Max width](#max-width) - [Feedback](#feedback) + + ## Overview Use tabs to allow users to navigate easily between views within the same @@ -201,6 +207,158 @@ loaded when the Tab is clicked. In v11, to do this, you can this by setting ``` +### Tabs and the Grid - fullWidth prop + +By default, a `Tab` component is only as wide as it's content. This posses difficulties when trying to align tabs to the grid. +Alternatively, you may choose to use the `fullWidth` prop to allow `Tab` elements to grow as wide as their container allows. + +Note that this feature is *only available* for `contained` tabs in large and extra large screen sizes. +The prop is a no-op for smaller screens and will also be ignored for `TabList`s with more than 8 tabs. +`fullWidth` paired up with a wrapping `Grid` component will allow for "grid-aware" tabs: + + + + + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + + + + + + +```jsx + + + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + + + + +``` + +Using the `fullWidth` prop alone within a `Grid` makes it so that the `Tabs` container aligns to the `Grid`, but not the individual `Tab` items; +to have each individual `Tab` take up exactly one or many columns within the `Grid`, you must specify the number of columns as a multiple of the number of `Tab` items within the `TabList`. + +For example, to have 5 tabs and each tab span exactly two columns: + + + + + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 + Tab Label 5 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + + + + + +```jsx + + + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 + Tab Label 5 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + + + +``` + +Or, to have 5 tabs and each tab span exactly three columns: + + + + + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 + Tab Label 5 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + + + + + +```jsx + + + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 + Tab Label 5 + + + Tab Panel 1 + Tab Panel 2 + Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + + + + +``` + + + + + + + ## V11 ### Tabs composition diff --git a/packages/react/src/components/Tabs/Tabs.stories.js b/packages/react/src/components/Tabs/Tabs.stories.js index 362f4119916e..9061f1333f6e 100644 --- a/packages/react/src/components/Tabs/Tabs.stories.js +++ b/packages/react/src/components/Tabs/Tabs.stories.js @@ -10,6 +10,7 @@ import { Tabs, TabList, Tab, TabPanels, TabPanel, IconTab } from './Tabs'; import TextInput from '../TextInput'; import Checkbox from '../Checkbox'; import Button from '../Button'; +import { Grid, Column } from '../Grid'; import mdx from './Tabs.mdx'; import TabsSkeleton from './Tabs.Skeleton'; @@ -485,6 +486,50 @@ export const ContainedWithSecondaryLabelsAndIcons = () => ( ); +export const ContainedFullWidth = () => ( + + + + + Tab Label 1 + Tab Label 2 + Tab Label 3 + Tab Label 4 with a very long long title + Tab Label 5 + Tab Label 6 + Tab Label 7 + Tab Label 8 + + + Tab Panel 1 + +
+ Validation example + + + + +
+ Tab Panel 3 + Tab Panel 4 + Tab Panel 5 + Tab Panel 6 + Tab Panel 7 + Tab Panel 8 +
+
+
+
+); + export const Skeleton = () => { return (
diff --git a/packages/react/src/components/Tabs/Tabs.tsx b/packages/react/src/components/Tabs/Tabs.tsx index 452a9e2b327d..2d4d18795b34 100644 --- a/packages/react/src/components/Tabs/Tabs.tsx +++ b/packages/react/src/components/Tabs/Tabs.tsx @@ -6,6 +6,7 @@ */ import { ChevronLeft, ChevronRight } from '@carbon/icons-react'; +import { breakpoints } from '@carbon/layout'; import cx from 'classnames'; import debounce from 'lodash.debounce'; import PropTypes from 'prop-types'; @@ -39,6 +40,7 @@ import { usePressable } from './usePressable'; import deprecate from '../../prop-types/deprecate'; import { Close } from '@carbon/icons-react'; import { useEvent } from '../../internal/useEvent'; +import { useMatchMedia } from '../../internal/useMatchMedia'; // Used to manage the overall state of the Tabs type TabsContextType = { @@ -72,6 +74,8 @@ const TabContext = React.createContext<{ hasSecondaryLabel: false, }); +const lgMediaQuery = `(min-width: ${breakpoints.lg.width})`; + // Used to keep track of position in a list of tab panels const TabPanelContext = React.createContext(0); @@ -254,6 +258,11 @@ export interface TabListProps extends DivAttributes { */ contained?: boolean; + /** + * Used for tabs within a grid, this makes it so tabs span the full container width and have the same width. Only available on contained tabs with <9 children + */ + fullWidth?: boolean; + /** * If using `IconTab`, specify the size of the icon being used. */ @@ -295,6 +304,7 @@ function TabList({ children, className: customClassName, contained = false, + fullWidth = false, iconSize, leftOverflowButtonProps, light, @@ -323,6 +333,15 @@ function TabList({ return isElement(child) && !!child.props.secondaryLabel; }); } + + const isLg = useMatchMedia(lgMediaQuery); + + const distributeWidth = + fullWidth && + contained && + isLg && + React.Children.toArray(children).length < 9; + const className = cx( `${prefix}--tabs`, { @@ -331,6 +350,7 @@ function TabList({ [`${prefix}--tabs__icon--default`]: iconSize === 'default', [`${prefix}--tabs__icon--lg`]: iconSize === 'lg', [`${prefix}--tabs--tall`]: hasSecondaryLabelTabs, + [`${prefix}--tabs--full-width`]: distributeWidth, }, customClassName ); @@ -614,6 +634,11 @@ TabList.propTypes = { */ contained: PropTypes.bool, + /** + * Used for tabs within a grid, this makes it so tabs span the full container width and have the same width. Only available on contained tabs with <9 children + */ + fullWidth: PropTypes.bool, + /** * If using `IconTab`, specify the size of the icon being used. */ @@ -876,7 +901,11 @@ const Tab = forwardRef(function Tab( {}
)} - {children} + + {children} + {/* always rendering dismissIcon so we don't lose reference to it, otherwise events do not work when switching from/to dismissable state */}
(function Tab(
{hasSecondaryLabel && ( -
+
{secondaryLabel}
)} diff --git a/packages/styles/scss/components/tabs/_tabs.scss b/packages/styles/scss/components/tabs/_tabs.scss index cf2e27ffefb9..e9a08bf45fbe 100644 --- a/packages/styles/scss/components/tabs/_tabs.scss +++ b/packages/styles/scss/components/tabs/_tabs.scss @@ -609,3 +609,25 @@ $icon-tab-size: custom-property.get-var('icon-tab-size', rem(40px)); @include high-contrast-mode('disabled'); } } + +//----------------------------- +// Grid contained tabs +//----------------------------- + +.#{$prefix}--tabs.#{$prefix}--tabs--contained.#{$prefix}--tabs--full-width + .#{$prefix}--tab--list { + display: grid; + width: 100%; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + + .#{$prefix}--tabs__nav-link { + .#{$prefix}--tabs__nav-item-label, + .#{$prefix}--tabs__nav-item-secondary-label { + overflow: hidden; + text-overflow: ellipsis; + } + .#{$prefix}--tabs__nav-item--icon { + margin-left: auto; + } + } +}