From cdc96da636a3f8c622283c04a13f1b2acb0b39c1 Mon Sep 17 00:00:00 2001 From: emyarod Date: Thu, 27 Aug 2020 17:38:31 -0500 Subject: [PATCH] feat(Tabs): replace mobile dropdown tabs with overflow nav buttons (#6410) * fix(tabs): remove md breakpoint queries * refactor(tabs): replace positioning rules * refactor(tabs): remove duplicate rules * fix(tabs): set disabled tab background color * refactor(tabs): remove CSS :not selectors * refactor(tabs): reduce selector specificity * refactor(tabs): consolidate tab link styles * refactor(tabs): change cursor value * refactor(tabs): add container tab line height token * fix(Tabs): remove listbox code * feat(Tabs): add overflow nav buttons * feat(Tabs): render overflow nav buttons based on scroll/resize * feat(Tabs): implement tab switching on overflow nav button click * feat(tabs): add overflow nav button focus styles * feat(tabs): add container overflow nav button styles * fix(tabs): use color token for chevrons * feat(Tabs): add fade effect to indicate overflow * feat(Tabs): add support for light prop * chore: update snapshots * chore: prettier * feat(Tabs): add support for light container tabs * docs(theme-tokens): move token comment and set default value * test(Tabs): remove outdated tests * fix(Tabs): add NPE check on tabs ref * refactor(tabs): remove theme token * fix(tabs): use different token for disabled light tabs * chore: resolve merge conflict * fix(tabs): hide scrollbars and defer to nav buttons * fix(tabs): reduce overflow fade effect * fix(Tabs): scroll tab into view when navigating with keyboard * fix(Tabs): scroll selected tab into view on navigation * feat(Tabs): scroll tabs into view on focus * refactor(Tabs): declare methods before calling * feat(Tabs): scroll tablist on overflow nav click * feat(Tabs): add overflow nav button click handler * test(Tabs): mock Element.prototype.scrollIntoView * feat(Tabs): focus selected tab when overflow nav button unrendered * fix(Tabs): remove unused props and improve story performance * fix(Tabs): increase scroll speed * fix(TabContent): add aria-live attribute * chore: rebase * chore: format * feat(Tabs): place focus on opposite nav button when edge reached * chore: resolve ESLint errors * docs(Tabs): update story format * fix(tabs): update skeleton state Co-authored-by: Alison Joseph Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> Co-authored-by: TJ Egan --- .../components/src/components/tabs/_tabs.scss | 415 ++++++++---------- .../__snapshots__/PublicAPI-test.js.snap | 15 +- .../src/components/TabContent/TabContent.js | 3 +- .../react/src/components/Tabs/Tabs-story.js | 180 ++++---- .../react/src/components/Tabs/Tabs-story.scss | 13 + .../react/src/components/Tabs/Tabs-test.js | 81 +--- packages/react/src/components/Tabs/Tabs.js | 326 +++++++++----- 7 files changed, 507 insertions(+), 526 deletions(-) diff --git a/packages/components/src/components/tabs/_tabs.scss b/packages/components/src/components/tabs/_tabs.scss index b577382c5610..db536a75fc0f 100644 --- a/packages/components/src/components/tabs/_tabs.scss +++ b/packages/components/src/components/tabs/_tabs.scss @@ -22,126 +22,131 @@ @include reset; @include type-style('body-short-01'); - position: relative; + display: flex; width: 100%; height: auto; + min-height: rem(40px); color: $text-01; - @include carbon--breakpoint(md) { - min-height: rem(40px); - background: none; - } } .#{$prefix}--tabs--container { - @include carbon--breakpoint(md) { - min-height: rem(48px); - } + min-height: rem(48px); } - .#{$prefix}--tabs-trigger { + .#{$prefix}--tabs__nav { display: flex; - align-items: center; - justify-content: space-between; - height: rem(40px); - padding: 0 $spacing-09 0 $spacing-05; - color: $text-01; - background-color: $field-01; - border-bottom: 1px solid $ui-04; - outline: 2px solid transparent; - cursor: pointer; - @include carbon--breakpoint(md) { + flex-direction: row; + width: auto; + max-width: 100%; + margin: 0; + padding: 0; + overflow: auto hidden; + list-style: none; + transition: max-height $duration--fast-01 motion(standard, productive); + + // hide scrollbars + scrollbar-width: none; + + &::-webkit-scrollbar { display: none; } } - .#{$prefix}--tabs-trigger:focus, - .#{$prefix}--tabs-trigger:active { - @include focus-outline('outline'); + //----------------------------- + // Overflow Nav Buttons + //----------------------------- + .#{$prefix}--tabs__overflow-indicator--left, + .#{$prefix}--tabs__overflow-indicator--right { + z-index: 1; + flex: 1 0 auto; + width: $carbon--spacing-03; } - .#{$prefix}--tabs-trigger svg { - position: absolute; - right: $spacing-05; - transition: transform $duration--fast-01 motion(standard, productive); - fill: $ui-05; + .#{$prefix}--tabs__overflow-indicator--left { + margin-right: -$carbon--spacing-03; + background-image: linear-gradient(to left, transparent, $ui-background); } - .#{$prefix}--tabs-trigger--open:focus, - .#{$prefix}--tabs-trigger--open:active { - @include focus-outline('reset'); - - transition: outline $duration--fast-01 motion(standard, productive); + .#{$prefix}--tabs__overflow-indicator--right { + margin-left: -$carbon--spacing-03; + background-image: linear-gradient(to right, transparent, $ui-background); } - .#{$prefix}--tabs-trigger--open { - background: $ui-03; + .#{$prefix}--tabs--light .#{$prefix}--tabs__overflow-indicator--left { + background-image: linear-gradient(to left, transparent, $ui-01); } - .#{$prefix}--tabs-trigger--open svg { - @include rotate(-180deg, $duration--fast-01, 50% 45%); + .#{$prefix}--tabs--light .#{$prefix}--tabs__overflow-indicator--right { + background-image: linear-gradient(to right, transparent, $ui-01); } - // There is only a difference in tab color when in mobile/dropdown view - .#{$prefix}--tabs--light.#{$prefix}--tabs-trigger { - background-color: $field-02; + .#{$prefix}--tabs--container .#{$prefix}--tabs__overflow-indicator--left { + background-image: linear-gradient(to left, transparent, $ui-03); } - .#{$prefix}--tabs-trigger-text { - padding-top: 2px; - overflow: hidden; - color: $text-01; - font-weight: 400; - white-space: nowrap; - text-decoration: none; - text-overflow: ellipsis; + .#{$prefix}--tabs--container .#{$prefix}--tabs__overflow-indicator--right { + background-image: linear-gradient(to right, transparent, $ui-03); } - .#{$prefix}--tabs-trigger-text:hover { - color: $text-01; - } + // Safari-only media query + // won't appear correctly with CSS custom properties + // see: code snippet and modal overflow indicators + @media not all and (min-resolution: 0.001dpcm) { + @supports (-webkit-appearance: none) and (stroke-color: transparent) { + .#{$prefix}--tabs__overflow-indicator--left { + margin-right: -$carbon--spacing-05; + background-image: linear-gradient( + to left, + rgba($ui-background, 0), + $ui-background + ); + } - .#{$prefix}--tabs-trigger-text:focus { - outline: none; + .#{$prefix}--tabs__overflow-indicator--right { + margin-left: -$carbon--spacing-05; + background-image: linear-gradient( + to right, + rgba($ui-background, 0), + $ui-background + ); + } + + .#{$prefix}--tabs--container .#{$prefix}--tabs__overflow-indicator--left { + background-image: linear-gradient(to left, rgba($ui-03, 0), $ui-03); + } + .#{$prefix}--tabs--container + .#{$prefix}--tabs__overflow-indicator--right { + background-image: linear-gradient(to right, rgba($ui-03, 0), $ui-03); + } + } } - .#{$prefix}--tabs__nav { - @include box-shadow; + .#{$prefix}--tab--overflow-nav-button { + @include button-reset; - position: absolute; - z-index: z('dropdown'); display: flex; - flex-direction: column; - width: 100%; - max-height: 600px; - - margin: 0; - padding: 0; - list-style: none; - background: $ui-01; - transition: max-height $duration--fast-01 motion(standard, productive); + flex-shrink: 0; + align-items: center; + justify-content: center; + width: $carbon--spacing-08; - @include carbon--breakpoint(md) { - z-index: auto; - flex-direction: row; - width: auto; - background: none; - box-shadow: none; - transition: inherit; + &:focus { + @include focus-outline('outline'); } } - .#{$prefix}--tabs__nav--hidden { - max-height: 0; - overflow: hidden; - transition: max-height $duration--fast-01 motion(standard, productive); + .#{$prefix}--tab--overflow-nav-button--hidden { + display: none; + } - @include carbon--breakpoint(md) { - display: flex; - max-width: 100%; - max-height: none; - overflow-x: auto; - transition: inherit; - } + .#{$prefix}--tabs--container .#{$prefix}--tab--overflow-nav-button { + width: $carbon--spacing-09; + margin: 0; + background-color: $ui-03; + } + + .#{$prefix}--tab--overflow-nav-button svg { + fill: $icon-01; } //----------------------------- @@ -151,38 +156,34 @@ @include reset; display: flex; - width: 100%; - height: rem(40px); padding: 0; - background-color: $ui-01; cursor: pointer; transition: background-color $duration--fast-01 motion(standard, productive); + } - @include carbon--breakpoint(md) { - height: auto; - background: transparent; - - + .#{$prefix}--tabs__nav-item { - margin-left: rem(1px); - } - } + .#{$prefix}--tabs__nav-item + .#{$prefix}--tabs__nav-item { + margin-left: rem(1px); } .#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item { - @include carbon--breakpoint(md) { - background-color: $ui-03; + background-color: $ui-03; + } - + .#{$prefix}--tabs__nav-item { - margin-left: 0; - // Draws the border without affecting the inner-content - box-shadow: -1px 0 0 0 $ui-04; - } + .#{$prefix}--tabs--container + .#{$prefix}--tabs__nav-item + + .#{$prefix}--tabs__nav-item { + margin-left: 0; + // Draws the border without affecting the inner-content + box-shadow: rem(-1px) 0 0 0 $ui-04; + } - + .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--selected, - &.#{$prefix}--tabs__nav-item--selected + .#{$prefix}--tabs__nav-item { - box-shadow: none; - } - } + .#{$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 { @@ -194,30 +195,8 @@ //----------------------------- // Item Hover //----------------------------- - .#{$prefix}--tabs__nav-item:hover:not(.#{$prefix}--tabs__nav-item--selected) { - @include carbon--breakpoint(md) { - background: transparent; - } - } - - .#{$prefix}--tabs__nav-item:hover:not(.#{$prefix}--tabs__nav-item--disabled) { - background-color: $hover-ui; - box-shadow: 0 -1px 0 $hover-ui; - - @include carbon--breakpoint(md) { - background-color: transparent; - - + .#{$prefix}--tabs__nav-item { - box-shadow: none; - } - } - } - - .#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item:hover:not(.#{$prefix}--tabs__nav-item--disabled) { - @include carbon--breakpoint(md) { - background-color: $hover-selected-ui; - } + .#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item:hover { + background-color: $hover-selected-ui; } //--------------------------------------------- @@ -225,6 +204,7 @@ //--------------------------------------------- .#{$prefix}--tabs__nav-item--disabled, .#{$prefix}--tabs__nav-item--disabled:hover { + background-color: transparent; outline: none; cursor: not-allowed; } @@ -233,138 +213,94 @@ .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled, .#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item.#{$prefix}--tabs__nav-item--disabled:hover { - @include carbon--breakpoint(md) { - background-color: $disabled-02; - } - } - - .#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--disabled - .#{$prefix}--tabs__nav-link { - @include carbon--breakpoint(md) { - color: $disabled-03; - border-bottom: none; - } + background-color: $disabled-02; } //----------------------------- // Item Selected //----------------------------- - .#{$prefix}--tabs__nav-item--selected:not(.#{$prefix}--tabs__nav-item--disabled) { - display: none; - border: none; + .#{$prefix}--tabs__nav-item--selected { transition: color $duration--fast-01 motion(standard, productive); + } - @include carbon--breakpoint(md) { - display: flex; - .#{$prefix}--tabs__nav-link, - .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-link:active { - @include type-style('productive-heading-01'); + .#{$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'); - color: $text-01; - border-bottom: 2px solid $interactive-04; - } + color: $text-01; + border-bottom: 2px solid $interactive-04; + } + + .#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item--selected, + .#{$prefix}--tabs--container .#{$prefix}--tabs__nav-item--selected:hover { + background-color: $ui-01; + + .#{$prefix}--tabs__nav-link:focus, + .#{$prefix}--tabs__nav-link:active { + box-shadow: none; } } .#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected:not(.#{$prefix}--tabs__nav-item--disabled), - .#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item--selected:hover:not(.#{$prefix}--tabs__nav-item--disabled) { - @include carbon--breakpoint(md) { - background-color: $ui-01; - - .#{$prefix}--tabs__nav-link { - padding: $spacing-03 $spacing-05; - // height - vertical padding - // Draws the border without affecting the inner-content - line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); - border-bottom: none; - box-shadow: inset 0 2px 0 0 $interactive-04; - } + .#{$prefix}--tabs__nav-item--selected + .#{$prefix}--tabs__nav-link { + // height - vertical padding + line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); + // Draws the border without affecting the inner-content + box-shadow: inset 0 2px 0 0 $interactive-04; + } - .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-link:active { - box-shadow: none; - } - } + .#{$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: $ui-background; } //----------------------------- // Link //----------------------------- - a.#{$prefix}--tabs__nav-link { + .#{$prefix}--tabs__nav-link { @include focus-outline('reset'); - display: inline-block; - width: calc(100% - 32px); - height: rem(40px); - margin: 0 $spacing-05; - padding: $spacing-04 0; + width: rem(160px); + padding: $spacing-04 $spacing-05 $spacing-03; overflow: hidden; color: $text-02; - font-weight: 400; - line-height: 1rem; white-space: nowrap; text-decoration: none; text-overflow: ellipsis; - border-bottom: 1px solid $ui-03; + border-bottom: $tab-underline-color; transition: border $duration--fast-01 motion(standard, productive), outline $duration--fast-01 motion(standard, productive); &:focus, &:active { @include focus-outline('outline'); - - width: 100%; - margin: 0; - padding-left: 16px; - } - - @include carbon--breakpoint(md) { - width: rem(160px); - margin: 0; - padding: $spacing-04 $spacing-05 $spacing-03; - line-height: inherit; - border-bottom: $tab-underline-color; - - &:focus, - &:active { - width: rem(160px); - border-bottom: 2px; - } } } - .#{$prefix}--tabs--container a.#{$prefix}--tabs__nav-link { - @include carbon--breakpoint(md) { - height: rem(48px); - padding: $spacing-03 $spacing-05; - // Height - vertical padding - line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); - border-bottom: none; - } + .#{$prefix}--tabs--container .#{$prefix}--tabs__nav-link { + height: rem(48px); + padding: $spacing-03 $spacing-05; + // height - vertical padding + line-height: calc(#{rem(48px)} - (#{$spacing-03} * 2)); + border-bottom: 0; } //----------------------------- // Link Hover //----------------------------- - .#{$prefix}--tabs__nav-item:hover:not(.#{$prefix}--tabs__nav-item--selected):not(.#{$prefix}--tabs__nav-item--disabled) - .#{$prefix}--tabs__nav-link { + .#{$prefix}--tabs__nav-item:hover .#{$prefix}--tabs__nav-link { color: $text-01; - @include carbon--breakpoint(md) { - color: $text-01; - border-bottom: $tab-underline-color-hover; - } + border-bottom: $tab-underline-color-hover; } .#{$prefix}--tabs--container - .#{$prefix}--tabs__nav-item:hover:not(.#{$prefix}--tabs__nav-item--selected):not(.#{$prefix}--tabs__nav-item--disabled) + .#{$prefix}--tabs__nav-item .#{$prefix}--tabs__nav-link { - @include carbon--breakpoint(md) { - border-bottom: none; - } + border-bottom: none; } //----------------------------- @@ -373,28 +309,47 @@ .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link { color: $tab-text-disabled; border-bottom: $tab-underline-disabled; - pointer-events: none; } .#{$prefix}--tabs__nav-item--disabled:hover .#{$prefix}--tabs__nav-link { + color: $tab-text-disabled; border-bottom: $tab-underline-disabled; - cursor: no-drop; + cursor: not-allowed; + pointer-events: none; } .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-item--disabled a.#{$prefix}--tabs__nav-link:active { + .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link:active { border-bottom: $tab-underline-disabled; outline: none; } - //----------------------------- - // Link Focus - //----------------------------- - .#{$prefix}--tabs__nav-item:not(.#{$prefix}--tabs__nav-item--selected):not(.#{$prefix}--tabs__nav-item--disabled):not(.#{$prefix}--tabs__nav-item--selected) + .#{$prefix}--tabs--light + .#{$prefix}--tabs__nav-item--disabled + .#{$prefix}--tabs__nav-link { + border-bottom-color: $ui-03; + } + + .#{$prefix}--tabs--light + .#{$prefix}--tabs__nav-item--disabled:hover + .#{$prefix}--tabs__nav-link { + border-bottom-color: $ui-03; + } + + .#{$prefix}--tabs--light + .#{$prefix}--tabs__nav-item--disabled .#{$prefix}--tabs__nav-link:focus, - .#{$prefix}--tabs__nav-item:not(.#{$prefix}--tabs__nav-item--selected):not(.#{$prefix}--tabs__nav-item--disabled):not(.#{$prefix}--tabs__nav-item--selected) - a.#{$prefix}--tabs__nav-link:active { - color: $text-02; + .#{$prefix}--tabs--light + .#{$prefix}--tabs__nav-item--disabled + .#{$prefix}--tabs__nav-link:active { + border-bottom-color: $ui-03; + } + + .#{$prefix}--tabs--container + .#{$prefix}--tabs__nav-item--disabled + .#{$prefix}--tabs__nav-link { + color: $disabled-03; + border-bottom: none; } //----------------------------- @@ -416,13 +371,13 @@ @include skeleton; width: rem(75px); - height: rem(12px); } .#{$prefix}--tabs.#{$prefix}--skeleton .#{$prefix}--tabs-trigger { @include skeleton; - width: rem(100px); + width: rem(75px); + margin-right: rem(1px); } .#{$prefix}--tabs.#{$prefix}--skeleton .#{$prefix}--tabs-trigger svg { diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 7aaaca309f3a..967f1dadc11c 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -4919,18 +4919,12 @@ Map { }, "Tabs" => Object { "defaultProps": Object { - "ariaLabel": "listbox", - "iconDescription": "show menu options", "role": "navigation", "selected": 0, "selectionMode": "automatic", - "triggerHref": "#", "type": "default", }, "propTypes": Object { - "ariaLabel": Object { - "type": "string", - }, "children": Object { "type": "node", }, @@ -4940,9 +4934,8 @@ Map { "hidden": Object { "type": "bool", }, - "iconDescription": Object { - "isRequired": true, - "type": "string", + "light": Object { + "type": "bool", }, "onClick": Object { "type": "func", @@ -4972,10 +4965,6 @@ Map { "tabContentClassName": Object { "type": "string", }, - "triggerHref": Object { - "isRequired": true, - "type": "string", - }, "type": Object { "args": Array [ Array [ diff --git a/packages/react/src/components/TabContent/TabContent.js b/packages/react/src/components/TabContent/TabContent.js index abe8140d73d3..4337659dc368 100644 --- a/packages/react/src/components/TabContent/TabContent.js +++ b/packages/react/src/components/TabContent/TabContent.js @@ -23,7 +23,8 @@ const TabContent = (props) => { {...other} className={tabContentClasses} selected={selected} - hidden={!selected}> + hidden={!selected} + aria-live="polite"> {children} ); diff --git a/packages/react/src/components/Tabs/Tabs-story.js b/packages/react/src/components/Tabs/Tabs-story.js index eee3f2799c91..a2f00c551478 100644 --- a/packages/react/src/components/Tabs/Tabs-story.js +++ b/packages/react/src/components/Tabs/Tabs-story.js @@ -32,19 +32,12 @@ const { prefix } = settings; const props = { tabs: () => ({ className: 'some-class', + light: boolean('Light variant (light)', false), selected: number('The index of the selected tab (selected in )', 1), - triggerHref: text( - 'The href of trigger button for narrow mode (triggerHref in )', - '#' - ), role: text('ARIA role (role in )', 'navigation'), - iconDescription: text( - 'The description of the trigger icon for narrow mode (iconDescription in )', - 'show menu options' - ), - // Disabling action logger for `` for now given it seems to be significantly slowing down Storybook + // Disabling action logger for `` for now given it seems to be significantly slowing down Storybook // onClick: action('onClick'), - onKeyDown: action('onKeyDown'), + // onKeyDown: action('onKeyDown'), onSelectionChange: action('onSelectionChange'), tabContentClassName: text( 'The className for the child `` components', @@ -112,10 +105,8 @@ const TabContentRenderedOnlyWhenSelected = ({ export default { title: 'Tabs', decorators: [withKnobs], - parameters: { component: Tabs, - subcomponents: { Tab, TabsSkeleton, @@ -124,47 +115,49 @@ export default { }; 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.

-

- This example uses the  - renderContent prop to - re-render content when the tab is selected. -

- -
-
- }> -
-

Content for fifth tab goes here.

-
-
-
+
+ + +
+

Content for first tab goes here.

+
+
+ +
+

Content for second tab goes here.

+
+
+ +
+

Content for third tab goes here.

+
+
+ +
+

Content for fourth tab goes here.

+

+ This example uses the  + renderContent prop to + re-render content when the tab is selected. +

+ +
+
+ }> +
+

Content for fifth tab goes here.

+
+
+
+
); Default.parameters = { @@ -177,43 +170,48 @@ Default.parameters = { }; export const Container = () => ( - - -
-

Content for first tab goes here.

-
-
- -
-

Content for second tab goes here.

-
-
- -
-

Content for third tab goes here.

-

- This example uses the  - renderContent prop to - re-render content when the tab is selected. -

- -
-
- }> -
-

Content for fourth tab goes here.

- -
-
-
+
+ + +
+

Content for first tab goes here.

+
+
+ +
+

Content for second tab goes here.

+
+
+ +
+

Content for third tab goes here.

+

+ This example uses the  + renderContent prop to + re-render content when the tab is selected. +

+ +
+
+ }> +
+

Content for fourth tab goes here.

+ +
+
+
+
); Container.parameters = { @@ -226,9 +224,7 @@ Container.parameters = { }; export const Skeleton = () => ; - Skeleton.storyName = 'skeleton'; - Skeleton.parameters = { info: { text: ` diff --git a/packages/react/src/components/Tabs/Tabs-story.scss b/packages/react/src/components/Tabs/Tabs-story.scss index 5beaad641d13..6a3d79b15e79 100644 --- a/packages/react/src/components/Tabs/Tabs-story.scss +++ b/packages/react/src/components/Tabs/Tabs-story.scss @@ -13,3 +13,16 @@ $css--reset: false; min-height: 320px; background-color: $ui-01; } + +.bx--tabs--container.bx--tabs--light ~ div { + background-color: $ui-background; +} + +.tabs-story-wrapper--light { + background-color: $ui-01; +} + +.container-tabs-story-wrapper--light { + padding: 2rem 1rem; + background-color: $ui-01; +} diff --git a/packages/react/src/components/Tabs/Tabs-test.js b/packages/react/src/components/Tabs/Tabs-test.js index 69c0937e5ed9..93c43bfba130 100644 --- a/packages/react/src/components/Tabs/Tabs-test.js +++ b/packages/react/src/components/Tabs/Tabs-test.js @@ -6,7 +6,6 @@ */ import React from 'react'; -import { ChevronDown16 } from '@carbon/icons-react'; import { settings } from 'carbon-components'; import { shallow, mount } from 'enzyme'; import Tabs from '../Tabs'; @@ -15,16 +14,7 @@ import TabsSkeleton from '../Tabs/Tabs.Skeleton'; const { prefix } = settings; -window.matchMedia = jest.fn().mockImplementation((query) => ({ - matches: true, - media: query, - onchange: null, - addListener: jest.fn(), // deprecated - removeListener: jest.fn(), // deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), -})); +Element.prototype.scrollIntoView = jest.fn(); describe('Tabs', () => { describe('renders as expected', () => { @@ -79,36 +69,6 @@ describe('Tabs', () => { }); }); - describe('Trigger (
)', () => { - const wrapper = shallow( - - content1 - content2 - - ); - - const trigger = wrapper.find(`div.${prefix}--tabs-trigger`); - const tablist = wrapper.find('ul'); - - it('renders default className for trigger', () => { - expect(trigger.hasClass(`${prefix}--tabs-trigger`)).toBe(true); - }); - - it('renders hidden className by default', () => { - expect(tablist.hasClass(`${prefix}--tabs__nav--hidden`)).toBe(true); - }); - - it('renders default className for triggerText', () => { - expect(trigger.find('a').hasClass(`${prefix}--tabs-trigger-text`)).toBe( - true - ); - }); - - it('renders ', () => { - expect(trigger.find(ChevronDown16).length).toBe(1); - }); - }); - describe('Children ()', () => { const wrapper = shallow( @@ -172,39 +132,6 @@ describe('Tabs', () => { }); describe('events', () => { - describe('click', () => { - const wrapper = mount( - - - content1 - - - content2 - - - ); - - describe('state: dropdownHidden', () => { - it('toggles dropdownHidden state after trigger is clicked', () => { - const trigger = wrapper.find(`.${prefix}--tabs-trigger`); - - trigger.simulate('click'); - expect(wrapper.state().dropdownHidden).toEqual(false); - trigger.simulate('click'); - expect(wrapper.state().dropdownHidden).toEqual(true); - }); - - it('toggles hidden state after trigger-text is clicked', () => { - const triggerText = wrapper.find(`.${prefix}--tabs-trigger-text`); - - triggerText.simulate('click'); - expect(wrapper.state().dropdownHidden).toEqual(false); - triggerText.simulate('click'); - expect(wrapper.state().dropdownHidden).toEqual(true); - }); - }); - }); - describe('keydown', () => { const leftKey = 37; const rightKey = 39; @@ -341,12 +268,6 @@ describe('Tabs', () => { ); - describe('dropdownHidden', () => { - it('should be true', () => { - expect(wrapper.state().dropdownHidden).toEqual(true); - }); - }); - describe('selected', () => { it('should be 0', () => { expect(wrapper.state().selected).toEqual(0); diff --git a/packages/react/src/components/Tabs/Tabs.js b/packages/react/src/components/Tabs/Tabs.js index 7c3774e2112e..75a308e897df 100644 --- a/packages/react/src/components/Tabs/Tabs.js +++ b/packages/react/src/components/Tabs/Tabs.js @@ -8,20 +8,15 @@ import PropTypes from 'prop-types'; import React from 'react'; import classNames from 'classnames'; -import { ChevronDown16 } from '@carbon/icons-react'; import { settings } from 'carbon-components'; +import { ChevronLeft16, ChevronRight16 } from '@carbon/icons-react'; +import debounce from 'lodash.debounce'; import { keys, match, matches } from '../../internal/keyboard'; const { prefix } = settings; export default class Tabs extends React.Component { static propTypes = { - /** - * Specify the text to be read by screen-readers when visiting the - * component - */ - ariaLabel: PropTypes.string, - /** * Pass in a collection of children to be rendered depending on the * currently selected tab @@ -40,10 +35,9 @@ export default class Tabs extends React.Component { hidden: PropTypes.bool, /** - * Provide a description that is read out when a user visits the caret icon - * for the dropdown menu of items + * Specify whether or not to use the light component variant */ - iconDescription: PropTypes.string.isRequired, + light: PropTypes.bool, /** * Optionally provide an `onClick` handler that is invoked when a is @@ -85,11 +79,6 @@ export default class Tabs extends React.Component { */ tabContentClassName: PropTypes.string, - /** - * Provide a string that represents the `href` for the triggered - */ - triggerHref: PropTypes.string.isRequired, - /** * Provide the type of Tab */ @@ -97,19 +86,22 @@ export default class Tabs extends React.Component { }; static defaultProps = { - iconDescription: 'show menu options', role: 'navigation', type: 'default', - triggerHref: '#', selected: 0, - ariaLabel: 'listbox', selectionMode: 'automatic', }; state = { - dropdownHidden: true, + horizontalOverflow: false, }; + tablist = React.createRef(); + leftOverflowNavButton = React.createRef(); + rightOverflowNavButton = React.createRef(); + // width of the overflow buttons + OVERFLOW_BUTTON_OFFSET = 40; + static getDerivedStateFromProps({ selected }, state) { const { prevSelected } = state; return prevSelected === selected @@ -120,37 +112,74 @@ export default class Tabs extends React.Component { }; } - getTabs() { - return React.Children.map(this.props.children, (tab) => tab); + /** + * `scroll` event handler to save tablist clientWidth, scrollWidth, and + * scrollLeft + */ + handleScroll = () => { + if (!this.tablist?.current) { + return; + } + const { + clientWidth: tablistClientWidth, + scrollLeft: tablistScrollLeft, + scrollWidth: tablistScrollWidth, + } = this.tablist.current; + this.setState({ + tablistClientWidth, + horizontalOverflow: tablistScrollWidth > tablistClientWidth, + tablistScrollWidth, + tablistScrollLeft, + }); + }; + + /** + * The debounced version of the `resize` event handler. + * @type {Function} + * @private + */ + _debouncedHandleWindowResize = null; + + _handleWindowResize = this.handleScroll; + + componentDidMount() { + if (!this._debouncedHandleWindowResize) { + this._debouncedHandleWindowResize = debounce( + this._handleWindowResize, + 200 + ); + } + + this._handleWindowResize(); + window.addEventListener('resize', this._debouncedHandleWindowResize); + } + + componentWillUnmount() { + if (this._debouncedHandleWindowResize) { + this._debouncedHandleWindowResize.cancel(); + } + window.removeEventListener('resize', this._debouncedHandleWindowResize); } getEnabledTabs = () => React.Children.toArray(this.props.children).reduce( - (acc, tab, index) => (!tab.props.disabled ? acc.concat(index) : acc), + (enabledTabs, tab, index) => + !tab.props.disabled ? enabledTabs.concat(index) : enabledTabs, [] ); - getTabAt = (index, useFresh) => { - return ( - (!useFresh && this[`tab${index}`]) || - React.Children.toArray(this.props.children)[index] + getNextIndex = (index, direction) => { + const enabledTabs = this.getEnabledTabs(); + const nextIndex = Math.max( + enabledTabs.indexOf(index) + direction, + // For `tab` not found in `enabledTabs` + -1 ); - }; - - setTabAt = (index, tabRef) => { - this[`tab${index}`] = tabRef; - }; - - // following functions (handle*) are Props on Tab.js, see Tab.js for parameters - handleTabClick = (onSelectionChange) => { - return (index, evt) => { - evt.preventDefault(); - - this.selectTabAt(index, onSelectionChange); - this.setState({ - dropdownHidden: true, - }); - }; + const nextIndexLooped = + nextIndex >= 0 && nextIndex < enabledTabs.length + ? nextIndex + : nextIndex - Math.sign(nextIndex) * enabledTabs.length; + return enabledTabs[nextIndexLooped]; }; getDirection = (evt) => { @@ -163,69 +192,123 @@ export default class Tabs extends React.Component { return 0; }; - getNextIndex = (index, direction) => { - const enabledTabs = this.getEnabledTabs(); - const nextIndex = Math.max( - enabledTabs.indexOf(index) + direction, - -1 /* For `tab` not found in `enabledTabs` */ - ); - const nextIndexLooped = - nextIndex >= 0 && nextIndex < enabledTabs.length - ? nextIndex - : nextIndex - Math.sign(nextIndex) * enabledTabs.length; - return enabledTabs[nextIndexLooped]; + getTabAt = (index, useFresh) => + (!useFresh && this[`tab${index}`]) || + React.Children.toArray(this.props.children)[index]; + + scrollTabIntoView = (event, { index }) => { + const tab = this.getTabAt(index); + if ( + matches(event, [keys.ArrowLeft, keys.ArrowRight]) || + event.type === 'click' + ) { + const currentScrollLeft = this.state.tablistScrollLeft; + tab?.tabAnchor?.scrollIntoView({ inline: 'nearest' }); + const newScrollLeft = this.tablist.current.scrollLeft; + if (newScrollLeft > currentScrollLeft) { + this.tablist.current.scrollLeft += this.OVERFLOW_BUTTON_OFFSET; + } + } + }; + + selectTabAt = (event, { index, onSelectionChange }) => { + this.scrollTabIntoView(event, { index }); + if (this.state.selected !== index) { + this.setState({ + selected: index, + }); + if (typeof onSelectionChange === 'function') { + onSelectionChange(index); + } + } }; handleTabKeyDown = (onSelectionChange) => { return (index, evt) => { if (matches(evt, [keys.Enter, keys.Space])) { - this.selectTabAt(index, onSelectionChange); - this.setState({ - dropdownHidden: true, - }); + this.selectTabAt(evt, { index, onSelectionChange }); } - - if (window.matchMedia('(min-width: 42rem)').matches) { - const nextIndex = this.getNextIndex(index, this.getDirection(evt)); - const tab = this.getTabAt(nextIndex); - if (tab && matches(evt, [keys.ArrowLeft, keys.ArrowRight])) { - evt.preventDefault(); - if (this.props.selectionMode !== 'manual') { - this.selectTabAt(nextIndex, onSelectionChange); - } - if (tab.tabAnchor) { - tab.tabAnchor.focus(); - } + const nextIndex = this.getNextIndex(index, this.getDirection(evt)); + const tab = this.getTabAt(nextIndex); + if (matches(evt, [keys.ArrowLeft, keys.ArrowRight])) { + evt.preventDefault(); + if (this.props.selectionMode !== 'manual') { + this.selectTabAt(evt, { index: nextIndex, onSelectionChange }); + } else { + this.scrollTabIntoView(evt, { index: nextIndex }); } + tab?.tabAnchor?.focus(); } }; }; - handleDropdownClick = () => { - this.setState({ - dropdownHidden: !this.state.dropdownHidden, - }); + getTabs = () => React.Children.map(this.props.children, (tab) => tab); + + // following functions (handle*) are Props on Tab.js, see Tab.js for parameters + handleTabClick = (onSelectionChange) => (index, evt) => { + evt.preventDefault(); + this.selectTabAt(evt, { index, onSelectionChange }); }; - selectTabAt = (index, onSelectionChange) => { - if (this.state.selected !== index) { - this.setState({ - selected: index, - }); - if (typeof onSelectionChange === 'function') { - onSelectionChange(index); + setTabAt = (index, tabRef) => { + this[`tab${index}`] = tabRef; + }; + + overflowNavInterval = null; + + handleOverflowNavClick = (_, { direction, multiplier = 15 }) => { + // account for overflow button appearing and causing tablist width change + const { clientWidth, scrollLeft, scrollWidth } = this.tablist?.current; + if (direction === 1 && !scrollLeft) { + this.tablist.current.scrollLeft += this.OVERFLOW_BUTTON_OFFSET; + } + + this.tablist.current.scrollLeft += direction * multiplier; + + const leftEdgeReached = + direction === -1 && scrollLeft < this.OVERFLOW_BUTTON_OFFSET; + const rightEdgeReached = + direction === 1 && + scrollLeft + clientWidth >= scrollWidth - this.OVERFLOW_BUTTON_OFFSET; + if (leftEdgeReached || rightEdgeReached) { + if (leftEdgeReached) { + this.rightOverflowNavButton?.current?.focus(); + } + if (rightEdgeReached) { + this.leftOverflowNavButton?.current?.focus(); } } }; + handleOverflowNavMouseDown = (_, { direction }) => { + this.overflowNavInterval = setInterval(() => { + const { clientWidth, scrollLeft, scrollWidth } = this.tablist?.current; + + // clear interval if scroll reaches left or right edge + const leftEdgeReached = + direction === -1 && scrollLeft < this.OVERFLOW_BUTTON_OFFSET; + const rightEdgeReached = + direction === 1 && + scrollLeft + clientWidth >= scrollWidth - this.OVERFLOW_BUTTON_OFFSET; + if (leftEdgeReached || rightEdgeReached) { + clearInterval(this.overflowNavInterval); + } + + // account for overflow button appearing and causing tablist width change + this.handleOverflowNavClick(_, { direction }); + }); + }; + + handleOverflowNavMouseUp = () => { + clearInterval(this.overflowNavInterval); + }; + render() { const { - ariaLabel, - iconDescription, className, - triggerHref, role, type, + light, onSelectionChange, selectionMode, // eslint-disable-line no-unused-vars tabContentClassName, @@ -245,8 +328,7 @@ export default class Tabs extends React.Component { * panel and separate components when it looks like a select list. */ const tabsWithProps = this.getTabs().map((tab, index) => { - const tabPanelIndex = index === this.state.selected ? 0 : -1; - const tabIndex = !this.state.dropdownHidden ? 0 : tabPanelIndex; + const tabIndex = index === this.state.selected ? 0 : -1; const newTab = React.cloneElement(tab, { index, selected: index === this.state.selected, @@ -282,42 +364,66 @@ export default class Tabs extends React.Component { ); }); + const leftOverflowNavButtonHidden = + !this.state.horizontalOverflow || !this.state.tablistScrollLeft; + const rightOverflowNavButtonHidden = + !this.state.horizontalOverflow || + this.state.tablistScrollLeft + this.state.tablistClientWidth === + this.state.tablistScrollWidth; const classes = { tabs: classNames(`${prefix}--tabs`, className, { [`${prefix}--tabs--container`]: type === 'container', + [`${prefix}--tabs--light`]: light, }), - tablist: classNames(`${prefix}--tabs__nav`, { - [`${prefix}--tabs__nav--hidden`]: this.state.dropdownHidden, + tablist: classNames(`${prefix}--tabs__nav`), + leftOverflowButtonClasses: classNames({ + [`${prefix}--tab--overflow-nav-button`]: this.state.horizontalOverflow, + [`${prefix}--tab--overflow-nav-button--hidden`]: leftOverflowNavButtonHidden, + }), + rightOverflowButtonClasses: classNames({ + [`${prefix}--tab--overflow-nav-button`]: this.state.horizontalOverflow, + [`${prefix}--tab--overflow-nav-button--hidden`]: rightOverflowNavButtonHidden, }), }; - const selectedTab = this.getTabAt(this.state.selected, true); - const selectedLabel = selectedTab ? selectedTab.props.label : ''; - return ( <> -
-
- - {selectedLabel} - - -
-
    +
    + + {!leftOverflowNavButtonHidden && ( +
    + )} +
      {tabsWithProps}
    + {!rightOverflowNavButtonHidden && ( +
    + )} +
    {tabContentWithProps}