diff --git a/.changeset/lovely-poems-remember.md b/.changeset/lovely-poems-remember.md new file mode 100644 index 00000000000..1a6eba3363d --- /dev/null +++ b/.changeset/lovely-poems-remember.md @@ -0,0 +1,13 @@ +--- +'@itwin/itwinui-react': minor +'@itwin/itwinui-css': minor +--- + +- Added data attribute 'data-iui-overflow' - when true it adds styling for overflow tabs +- Added property 'overflowOptions' - contains `useOverflow`, which when true enables tabs to scroll if there's overflow + +```typescript + + {tabs} + +``` diff --git a/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow (Scroll end).png b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow (Scroll end).png new file mode 100755 index 00000000000..3ff2a9323f6 Binary files /dev/null and b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow (Scroll end).png differ diff --git a/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow (Scroll start).png b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow (Scroll start).png new file mode 100755 index 00000000000..513eb2f83a4 Binary files /dev/null and b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow (Scroll start).png differ diff --git a/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow.png b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow.png new file mode 100644 index 00000000000..3c8b7ecb6c5 Binary files /dev/null and b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Horizontal Overflow.png differ diff --git a/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Vertical Overflow.png b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Vertical Overflow.png new file mode 100755 index 00000000000..eed4eda932c Binary files /dev/null and b/apps/storybook/cypress-visual-screenshots/baseline/Tabs.test.ts-Vertical Overflow.png differ diff --git a/apps/storybook/src/Tabs.stories.tsx b/apps/storybook/src/Tabs.stories.tsx index aa857e6b0f9..cee934b901b 100644 --- a/apps/storybook/src/Tabs.stories.tsx +++ b/apps/storybook/src/Tabs.stories.tsx @@ -5,8 +5,7 @@ import SvgStar from '@itwin/itwinui-icons-react/cjs/icons/Star'; import { Meta, Story } from '@storybook/react/'; import React from 'react'; -import { Button, Tab } from '@itwin/itwinui-react'; -import { Tabs, TabsProps } from '@itwin/itwinui-react/esm/core/Tabs/Tabs'; +import { Button, Tab, Tabs, TabsProps } from '@itwin/itwinui-react'; export default { title: 'Core/Tabs', @@ -187,6 +186,200 @@ SublabelsAndIcons.args = { type: 'borderless', }; +export const HorizontalOverflow: Story> = (args) => { + const [activeIndex, setActiveIndex] = React.useState(10); + const getContent = () => { + switch (activeIndex) { + case 0: + return 'Tab Content One'; + case 1: + return 'Tab Content Two'; + case 2: + return 'Tab Content Three'; + case 3: + return 'Tab Content Four'; + case 4: + return 'Tab Content Five'; + case 5: + return 'Tab Content Six'; + case 6: + return 'Tab Content Seven'; + case 7: + return 'Tab Content Eight'; + case 8: + return 'Tab Content Nine'; + case 9: + return 'Tab Content Ten'; + case 10: + return 'Tab Content Eleven'; + case 11: + return 'Tab Content Twelve'; + default: + return 'Tab Content Thirteen'; + } + }; + const labels = [ + , + , + , + , + , + , + , + , + , + , + , + , + , + ]; + + return ( +
+ Button]} + > + {getContent()} + +
+ ); +}; +HorizontalOverflow.args = { + type: 'default', + labels: [ + , + , + , + , + , + , + , + , + , + , + , + , + , + ], +}; +HorizontalOverflow.argTypes = { + type: { options: ['default', 'borderless'] }, + orientation: { control: { disable: true } }, + actions: { control: { disable: true } }, +}; + +export const VerticalOverflow: Story> = (args) => { + const [activeIndex, setActiveIndex] = React.useState(10); + const getContent = () => { + switch (activeIndex) { + case 0: + return 'Tab Content One'; + case 1: + return 'Tab Content Two'; + case 2: + return 'Tab Content Three'; + case 3: + return 'Tab Content Four'; + case 4: + return 'Tab Content Five'; + case 5: + return 'Tab Content Six'; + case 6: + return 'Tab Content Seven'; + case 7: + return 'Tab Content Eight'; + case 8: + return 'Tab Content Nine'; + case 9: + return 'Tab Content Ten'; + case 10: + return 'Tab Content Eleven'; + case 11: + return 'Tab Content Twelve'; + default: + return 'Tab Content Thirteen'; + } + }; + const labels = [ + , + , + , + , + , + , + , + , + , + , + , + , + , + ]; + + return ( +
+ Button]} + > + {getContent()} + +
+ ); +}; +VerticalOverflow.args = { + orientation: 'vertical', + type: 'default', + labels: [ + , + , + , + , + , + , + , + , + , + , + , + , + , + ], +}; +VerticalOverflow.argTypes = { + type: { options: ['default', 'borderless'] }, + orientation: { control: { disable: true } }, + actions: { control: { disable: true } }, +}; + export const Vertical: Story> = (args) => { const [index, setIndex] = React.useState(0); const getContent = () => { diff --git a/apps/storybook/src/Tabs.test.ts b/apps/storybook/src/Tabs.test.ts index bbf6e1a93cf..083dcaa515e 100644 --- a/apps/storybook/src/Tabs.test.ts +++ b/apps/storybook/src/Tabs.test.ts @@ -10,6 +10,8 @@ describe('Tabs', () => { 'Pill Tabs', 'Sublabels And Icons', 'Vertical', + 'Horizontal Overflow', + 'Vertical Overflow', ]; tests.forEach((testName) => { @@ -18,6 +20,18 @@ describe('Tabs', () => { cy.visit('iframe', { qs: { id } }); cy.wait(1000); // wait for resize observer to be done cy.compareSnapshot(testName); + + if (testName === 'Horizontal Overflow') { + const tabs = cy.get('li > button.iui-tab').should('have.length', 13); + + tabs.last().focus(); + cy.compareSnapshot(`${testName} (Scroll end)`); + + // cy somehow loses tabs list and does not focus on first element so getting it again. + cy.focused().blur(); + cy.get('li > button.iui-tab').first().focus(); + cy.compareSnapshot(`${testName} (Scroll start)`); + } }); }); }); diff --git a/apps/website/src/examples/Tabs.overflow.tsx b/apps/website/src/examples/Tabs.overflow.tsx new file mode 100644 index 00000000000..e7015664ac8 --- /dev/null +++ b/apps/website/src/examples/Tabs.overflow.tsx @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import * as React from 'react'; +import { Tabs, Tab } from '@itwin/itwinui-react'; + +export default () => { + const [index, setIndex] = React.useState(0); + const getContent = () => { + switch (index) { + case 0: + return 'Bentley Systems, Incorporated, is an American-based software development company that develops, manufactures, licenses, sells and supports computer software and services for the design, construction, and operation of infrastructure.'; + case 1: + return "The company's software serves the building, plant, civil, and geospatial markets in the areas of architecture, engineering, construction (AEC) and operations."; + case 2: + return 'Their software products are used to design, engineer, build, and operate large constructed assets such as roadways, railways, bridges, buildings, industrial plants, power plants, and utility networks.'; + case 3: + return 'The company re-invests 20% of their revenues in research and development.'; + case 4: + return 'Bentley Systems is headquartered in Exton, Pennsylvania, United States, but has development, sales and other departments in over 50 countries.'; + case 5: + return 'The company had revenues of $700 million in 2018.'; + case 6: + return 'Keith A. Bentley and Barry J. Bentley founded Bentley Systems in 1984.'; + default: + return 'They introduced the commercial version of PseudoStation in 1985, which allowed users of Intergraphs VAX systems to use low-cost graphics terminals to view and modify the designs on their Intergraph IGDS (Interactive Graphics Design System) installations.'; + } + }; + const labels = [ + , + , + , + , + , + , + , + , + ]; + + return ( +
+ + {getContent()} + +
+ ); +}; diff --git a/apps/website/src/examples/index.tsx b/apps/website/src/examples/index.tsx index 4d74e49eeda..a940e269595 100644 --- a/apps/website/src/examples/index.tsx +++ b/apps/website/src/examples/index.tsx @@ -209,6 +209,7 @@ export { default as SurfaceNoPaddingExample } from './Surface.nopadding'; export { default as TableMainExample } from './Table.main'; export { default as TabsMainExample } from './Tabs.main'; +export { default as TabsOverflowExample } from './Tabs.overflow'; export { default as TagMainExample } from './Tag.main'; export { default as TagBasicExample } from './Tag.basic'; diff --git a/apps/website/src/pages/docs/tabs.mdx b/apps/website/src/pages/docs/tabs.mdx index 1952684c595..8f53d8b33fe 100644 --- a/apps/website/src/pages/docs/tabs.mdx +++ b/apps/website/src/pages/docs/tabs.mdx @@ -20,6 +20,16 @@ import * as AllExamples from '~/examples'; +## Usage + +### Overflow + +If the available space does not allow all the tabs to be displayed, set `useOverflow` of `OverflowOptions` to true. This allows tabs to be scrollable. + + + + + ## Props diff --git a/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_dark_0_demo-overflow_0_.png b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_dark_0_demo-overflow_0_.png new file mode 100644 index 00000000000..9c2284f116f Binary files /dev/null and b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_dark_0_demo-overflow_0_.png differ diff --git a/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_hc_dark_0_demo-overflow_0_.png b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_hc_dark_0_demo-overflow_0_.png new file mode 100644 index 00000000000..bba717ad116 Binary files /dev/null and b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_hc_dark_0_demo-overflow_0_.png differ diff --git a/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_hc_light_0_demo-overflow_0_.png b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_hc_light_0_demo-overflow_0_.png new file mode 100644 index 00000000000..f3809a3e544 Binary files /dev/null and b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_hc_light_0_demo-overflow_0_.png differ diff --git a/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_light_0_demo-overflow_0_.png b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_light_0_demo-overflow_0_.png new file mode 100644 index 00000000000..dcf189fbe84 Binary files /dev/null and b/packages/itwinui-css/backstop/results/bitmaps_reference/iTwinUI_tabs_Type_Overflow_light_0_demo-overflow_0_.png differ diff --git a/packages/itwinui-css/backstop/scenarios/tabs.js b/packages/itwinui-css/backstop/scenarios/tabs.js index ee687c1e12a..30a5c2c755b 100644 --- a/packages/itwinui-css/backstop/scenarios/tabs.js +++ b/packages/itwinui-css/backstop/scenarios/tabs.js @@ -14,6 +14,10 @@ module.exports = [ selectors: ['#demo-default-vertical'], viewports: [{ width: 800, height: 600 }], }), + scenario('Type Overflow', { + selectors: ['#demo-overflow'], + viewports: [{ width: 800, height: 600 }], + }), scenario('Type borderless', { selectors: ['#demo-borderless'], viewports: [{ width: 800, height: 600 }], diff --git a/packages/itwinui-css/backstop/tests/tabs.html b/packages/itwinui-css/backstop/tests/tabs.html index 08d107df2a2..28bc03e362a 100644 --- a/packages/itwinui-css/backstop/tests/tabs.html +++ b/packages/itwinui-css/backstop/tests/tabs.html @@ -29,6 +29,9 @@ #demo-borderless-vertical { width: auto; } + #demo-overflow-vertical{ + height: 250px; + } @@ -842,6 +845,374 @@

Default


+

Overflow

+
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ +
+
+ +
+
+ +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. Duis aute irure dolor in reprehenderit in voluptate + velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +
+ + +
+ +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ +
+
+ +
+
+ +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. Duis aute irure dolor in reprehenderit in voluptate + velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id + est laborum. +
+ + +
+
+ +
+

Borderless

diff --git a/packages/itwinui-css/src/tabs/base.scss b/packages/itwinui-css/src/tabs/base.scss index 5c0fc5592b1..3b5b61244b5 100644 --- a/packages/itwinui-css/src/tabs/base.scss +++ b/packages/itwinui-css/src/tabs/base.scss @@ -99,6 +99,12 @@ $borderless-horizontal-tab-min-height: calc( } } + &[data-iui-overflow='true'] { + .iui-tab { + white-space: nowrap; + } + } + ~ .iui-tabs-content { padding-top: var(--iui-size-s); padding-bottom: var(--iui-size-s); @@ -124,6 +130,34 @@ $borderless-horizontal-tab-min-height: calc( &.iui-borderless { min-height: $borderless-horizontal-tab-min-height; } + + &[data-iui-overflow='true'] { + overflow-x: overlay; + + &[data-iui-scroll-placement='start'] { + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-mask-image: linear-gradient(90deg, transparent 0%, rgb(0, 0, 0) 0%, rgb(0, 0, 0) 95%, transparent 100%); + mask-image: linear-gradient(90deg, transparent 0%, rgb(0, 0, 0) 0%, rgb(0, 0, 0) 95%, transparent 100%); + } + + &[data-iui-scroll-placement='center'] { + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-mask-image: linear-gradient(90deg, transparent 0%, rgb(0, 0, 0) 5%, rgb(0, 0, 0) 95%, transparent 100%); + mask-image: linear-gradient(90deg, transparent 0%, rgb(0, 0, 0) 5%, rgb(0, 0, 0) 95%, transparent 100%); + } + + &[data-iui-scroll-placement='end'] { + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-mask-image: linear-gradient( + 90deg, + transparent 0%, + rgb(0, 0, 0) 5%, + rgb(0, 0, 0) 100%, + transparent 100% + ); + mask-image: linear-gradient(90deg, transparent 0%, rgb(0, 0, 0) 5%, rgb(0, 0, 0) 100%, transparent 100%); + } + } } .iui-tab { @@ -142,6 +176,7 @@ $borderless-horizontal-tab-min-height: calc( grid-template-areas: 'tabs tabs-content' 'tabs-actions tabs-content'; grid-template-columns: auto 1fr; grid-template-rows: 1fr auto; + height: 100%; .iui-tabs { li, @@ -152,6 +187,30 @@ $borderless-horizontal-tab-min-height: calc( ~ .iui-tabs-content { flex-grow: 1; + overflow: auto; + } + + &[data-iui-overflow='true'] { + overflow-y: overlay; + min-width: min-content; + + &[data-iui-scroll-placement='start'] { + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-mask-image: linear-gradient(0deg, transparent 0%, rgb(0, 0, 0) 5%, rgb(0, 0, 0) 100%, transparent 100%); + mask-image: linear-gradient(0deg, transparent 0%, rgb(0, 0, 0) 5%, rgb(0, 0, 0) 100%, transparent 100%); + } + + &[data-iui-scroll-placement='center'] { + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-mask-image: linear-gradient(0deg, transparent 0%, rgb(0, 0, 0) 5%, rgb(0, 0, 0) 95%, transparent 100%); + mask-image: linear-gradient(0deg, transparent 0%, rgb(0, 0, 0) 5%, rgb(0, 0, 0) 95%, transparent 100%); + } + + &[data-iui-scroll-placement='end'] { + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-mask-image: linear-gradient(0deg, transparent 0%, rgb(0, 0, 0) 0%, rgb(0, 0, 0) 95%, transparent 100%); + mask-image: linear-gradient(0deg, transparent 0%, rgb(0, 0, 0) 0%, rgb(0, 0, 0) 95%, transparent 100%); + } } } diff --git a/packages/itwinui-react/src/core/Tabs/Tabs.test.tsx b/packages/itwinui-react/src/core/Tabs/Tabs.test.tsx index 66ec33bb83a..c64eae0d692 100644 --- a/packages/itwinui-react/src/core/Tabs/Tabs.test.tsx +++ b/packages/itwinui-react/src/core/Tabs/Tabs.test.tsx @@ -76,6 +76,63 @@ it('should render vertical tabs', () => { expect(queryByText('Test content')).toHaveClass('iui-tabs-content'); }); +it('should allow horizontal scrolling when overflowOptions useOverflow is true', () => { + const { container } = render( + , + , + , + , + , + , + , + , + , + ]} + > + Test content + , + ); + + const tabContainer = container.querySelector('.iui-tabs') as HTMLElement; + expect(tabContainer).toBeTruthy(); + expect(tabContainer).toHaveAttribute('data-iui-overflow', 'true'); + + const tabs = container.querySelectorAll('.iui-tab'); + expect(tabs.length).toBe(9); +}); + +it('should allow vertical scrolling when overflowOptions useOverflow is true', () => { + const { container } = render( + , + , + , + , + , + , + , + , + , + ]} + > + Test content + , + ); + + const tabContainer = container.querySelector('.iui-tabs') as HTMLElement; + expect(tabContainer).toBeTruthy(); + expect(tabContainer).toHaveAttribute('data-iui-overflow', 'true'); + + const tabs = container.querySelectorAll('.iui-tab'); + expect(tabs.length).toBe(9); +}); + it('should render green tabs', () => { const { container } = renderComponent({ color: 'green' }); diff --git a/packages/itwinui-react/src/core/Tabs/Tabs.tsx b/packages/itwinui-react/src/core/Tabs/Tabs.tsx index b20b8fa75ed..d76c7b69004 100644 --- a/packages/itwinui-react/src/core/Tabs/Tabs.tsx +++ b/packages/itwinui-react/src/core/Tabs/Tabs.tsx @@ -11,10 +11,39 @@ import { useContainerWidth, useIsomorphicLayoutEffect, useIsClient, + useResizeObserver, } from '../utils/index.js'; import '@itwin/itwinui-css/css/tabs.css'; import { Tab } from './Tab.js'; +export type OverflowOptions = { + /** + * Whether to allow tabs list to scroll when there is overflow, + * i.e. when there is not enough space to fit all the tabs. + * + * Not applicable to types `pill` and `borderless`. + */ + useOverflow?: boolean; +}; + +type TabsOverflowProps = + | { + /** + * Options that can be specified to deal with tabs overflowing the allotted space. + */ + overflowOptions?: OverflowOptions; + /** + * Type of the tabs. + * + * If `orientation = 'vertical'`, `pill` is not applicable. + * @default 'default' + */ + type?: 'default'; + } + | { + type: 'pill' | 'borderless'; + }; + type TabsOrientationProps = | { /** @@ -97,7 +126,8 @@ export type TabsProps = { */ children?: React.ReactNode; } & TabsOrientationProps & - TabsTypeProps; + TabsTypeProps & + TabsOverflowProps; /** * @deprecated Since v2, use `TabProps` with `Tabs` @@ -146,6 +176,17 @@ export const Tabs = (props: TabsProps) => { props = { ...props }; delete props.actions; } + // Separate overflowOptions from props to avoid adding it to the DOM (using {...rest}) + let overflowOptions: OverflowOptions | undefined; + if ( + props.type !== 'borderless' && + props.type !== 'pill' && + props.overflowOptions + ) { + overflowOptions = props.overflowOptions; + props = { ...props }; + delete props.overflowOptions; + } const { labels, @@ -218,13 +259,217 @@ export const Tabs = (props: TabsProps) => { ); }, [type]); - const onTabClick = (index: number) => { - if (onTabSelected) { - onTabSelected(index); + const enableHorizontalScroll = React.useCallback((e: WheelEvent) => { + const ownerDoc = tablistRef.current; + if (ownerDoc === null) { + return; + } + + let scrollLeft = ownerDoc?.scrollLeft ?? 0; + if (e.deltaY > 0 || e.deltaX > 0) { + scrollLeft += 25; + } else if (e.deltaY < 0 || e.deltaX < 0) { + scrollLeft -= 25; + } + ownerDoc.scrollLeft = scrollLeft; + }, []); + + // allow normal mouse wheels to scroll horizontally for horizontal overflow + React.useEffect(() => { + const ownerDoc = tablistRef.current; + if (ownerDoc === null) { + return; + } + + if (!overflowOptions?.useOverflow || orientation === 'vertical') { + ownerDoc.removeEventListener('wheel', enableHorizontalScroll); + return; + } + + ownerDoc.addEventListener('wheel', enableHorizontalScroll); + }, [overflowOptions?.useOverflow, orientation, enableHorizontalScroll]); + + const isTabHidden = (activeTab: HTMLElement, isVertical: boolean) => { + const ownerDoc = tablistRef.current; + if (ownerDoc === null) { + return; + } + + const fadeBuffer = isVertical + ? ownerDoc.offsetHeight * 0.05 + : ownerDoc.offsetWidth * 0.05; + const visibleStart = isVertical ? ownerDoc.scrollTop : ownerDoc.scrollLeft; + const visibleEnd = isVertical + ? ownerDoc.scrollTop + ownerDoc.offsetHeight + : ownerDoc.scrollLeft + ownerDoc.offsetWidth; + const tabStart = isVertical ? activeTab.offsetTop : activeTab.offsetLeft; + const tabEnd = isVertical + ? activeTab.offsetTop + activeTab.offsetHeight + : activeTab.offsetLeft + activeTab.offsetWidth; + + if ( + tabStart > visibleStart + fadeBuffer && + tabEnd < visibleEnd - fadeBuffer + ) { + return 0; // tab is visible + } else if (tabStart < visibleStart + fadeBuffer) { + return -1; // tab is before visible section + } else { + return 1; // tab is after visible section } - setCurrentActiveIndex(index); }; + const easeInOutQuad = ( + time: number, + beginning: number, + change: number, + duration: number, + ) => { + if ((time /= duration / 2) < 1) { + return (change / 2) * time * time + beginning; + } + return (-change / 2) * (--time * (time - 2) - 1) + beginning; + }; + + const scrollToTab = React.useCallback( + ( + list: HTMLUListElement, + activeTab: HTMLElement, + duration: number, + isVertical: boolean, + tabPlacement: number, + ) => { + const start = isVertical ? list.scrollTop : list.scrollLeft; + let change = 0; + let currentTime = 0; + const increment = 20; + const fadeBuffer = isVertical + ? list.offsetHeight * 0.05 + : list.offsetWidth * 0.05; + + if (tabPlacement < 0) { + // if tab is before visible section + change = isVertical + ? activeTab.offsetTop - list.scrollTop + : activeTab.offsetLeft - list.scrollLeft; + change -= fadeBuffer; // give some space so the active tab isn't covered by the fade + } else { + // tab is after visible section + change = isVertical + ? activeTab.offsetTop - + (list.scrollTop + list.offsetHeight) + + activeTab.offsetHeight + : activeTab.offsetLeft - + (list.scrollLeft + list.offsetWidth) + + activeTab.offsetWidth; + change += fadeBuffer; // give some space so the active tab isn't covered by the fade + } + + const animateScroll = () => { + currentTime += increment; + const val = easeInOutQuad(currentTime, start, change, duration); + if (isVertical) { + list.scrollTop = val; + } else { + list.scrollLeft = val; + } + if (currentTime < duration) { + setTimeout(animateScroll, increment); + } + }; + animateScroll(); + }, + [], + ); + + // scroll to active tab if it is not visible with overflow + useIsomorphicLayoutEffect(() => { + setTimeout(() => { + const ownerDoc = tablistRef.current; + if ( + ownerDoc !== null && + overflowOptions?.useOverflow && + currentActiveIndex !== undefined + ) { + const activeTab = ownerDoc.querySelectorAll('.iui-tab')[ + currentActiveIndex + ] as HTMLElement; + const isVertical = orientation === 'vertical'; + const tabPlacement = isTabHidden(activeTab, isVertical); + + if (tabPlacement) { + scrollToTab(ownerDoc, activeTab, 100, isVertical, tabPlacement); + } + } + }, 50); + }, [ + overflowOptions?.useOverflow, + currentActiveIndex, + focusedIndex, + orientation, + scrollToTab, + ]); + + const [scrollingPlacement, setScrollingPlacement] = React.useState< + string | undefined + >(undefined); + const determineScrollingPlacement = React.useCallback(() => { + const ownerDoc = tablistRef.current; + if (ownerDoc === null) { + return; + } + + const isVertical = orientation === 'vertical'; + const visibleStart = isVertical ? ownerDoc.scrollTop : ownerDoc.scrollLeft; + const visibleEnd = isVertical + ? ownerDoc.scrollTop + ownerDoc.offsetHeight + : ownerDoc.scrollLeft + ownerDoc.offsetWidth; + const totalTabsSpace = isVertical + ? ownerDoc.scrollHeight + : ownerDoc.scrollWidth; + + if ( + Math.abs(visibleStart - 0) < 1 && + Math.abs(visibleEnd - totalTabsSpace) < 1 + ) { + setScrollingPlacement(undefined); + } else if (Math.abs(visibleStart - 0) < 1) { + setScrollingPlacement('start'); + } else if (Math.abs(visibleEnd - totalTabsSpace) < 1) { + setScrollingPlacement('end'); + } else { + setScrollingPlacement('center'); + } + }, [orientation, setScrollingPlacement]); + // apply correct mask when tabs list is resized + const [resizeRef] = useResizeObserver(determineScrollingPlacement); + resizeRef(tablistRef?.current); + + // check if overflow tabs are scrolled to far edges + React.useEffect(() => { + const ownerDoc = tablistRef.current; + if (ownerDoc === null) { + return; + } + + if (!overflowOptions?.useOverflow) { + ownerDoc.removeEventListener('scroll', determineScrollingPlacement); + return; + } + + ownerDoc.addEventListener('scroll', determineScrollingPlacement); + }, [overflowOptions?.useOverflow, determineScrollingPlacement]); + + const onTabClick = React.useCallback( + (index: number) => { + if (onTabSelected) { + onTabSelected(index); + } + setCurrentActiveIndex(index); + }, + [onTabSelected], + ); + const onKeyDown = (event: React.KeyboardEvent) => { // alt + arrow keys are used by browser / assistive technologies if (event.altKey) { @@ -290,6 +535,43 @@ export const Tabs = (props: TabsProps) => { } }; + const createTab = React.useCallback( + (label: React.ReactNode, index: number) => { + const onClick = () => { + setFocusedIndex(index); + onTabClick(index); + }; + return ( +
  • + {!React.isValidElement(label) ? ( + + ) : ( + React.cloneElement(label, { + className: cx(label.props.className, { + 'iui-active': index === currentActiveIndex, + }), + 'aria-selected': index === currentActiveIndex, + tabIndex: index === currentActiveIndex ? 0 : -1, + onClick: (args: unknown) => { + onClick(); + label.props.onClick?.(args); + }, + }) + )} +
  • + ); + }, + [currentActiveIndex, onTabClick], + ); + return (
    { }, tabsClassName, )} + data-iui-overflow={overflowOptions?.useOverflow} + data-iui-scroll-placement={scrollingPlacement} role='tablist' ref={refs} onKeyDown={onKeyDown} {...rest} > - {labels.map((label, index) => { - const onClick = () => { - setFocusedIndex(index); - onTabClick(index); - }; - return ( -
  • - {!React.isValidElement(label) ? ( - - ) : ( - React.cloneElement(label, { - className: cx(label.props.className, { - 'iui-active': index === currentActiveIndex, - }), - 'aria-selected': index === currentActiveIndex, - tabIndex: index === currentActiveIndex ? 0 : -1, - onClick: (args: unknown) => { - onClick(); - label.props.onClick?.(args); - }, - }) - )} -
  • - ); - })} + {labels.map((label, index) => createTab(label, index))} {actions && (