diff --git a/packages/eds-core-react/src/components/Tabs/Tab.tsx b/packages/eds-core-react/src/components/Tabs/Tab.tsx index dae1db6be7..331dd38c62 100644 --- a/packages/eds-core-react/src/components/Tabs/Tab.tsx +++ b/packages/eds-core-react/src/components/Tabs/Tab.tsx @@ -38,6 +38,9 @@ const StyledTab = styled.button.attrs( text-overflow: ellipsis; overflow-x: hidden; + scroll-snap-align: end; + scroll-snap-stop: always; + &:focus { outline: none; } diff --git a/packages/eds-core-react/src/components/Tabs/TabList.tsx b/packages/eds-core-react/src/components/Tabs/TabList.tsx index cccad51a7f..dfba299b6e 100644 --- a/packages/eds-core-react/src/components/Tabs/TabList.tsx +++ b/packages/eds-core-react/src/components/Tabs/TabList.tsx @@ -2,6 +2,7 @@ import { forwardRef, useContext, useRef, + useState, useCallback, useEffect, ReactElement, @@ -36,11 +37,29 @@ const StyledTabList = styled.div.attrs( display: grid; grid-auto-flow: column; grid-auto-columns: ${({ variant }) => variants[variant] as VariantsRecord}; + overflow-x: ${({ scrollable }) => (scrollable ? 'auto' : 'hidden')}; + scroll-snap-type: x mandatory; + overscroll-behavior-x: contain; + + @media (prefers-reduced-motion: no-preference) { + scroll-behavior: smooth; + } + @media (hover: none) { + overflow-x: scroll; + -webkit-overflow-scrolling: touch; + scrollbar-width: 0; + & ::-webkit-scrollbar { + width: 0; + height: 0; + } + } ` export type TabListProps = { /** Sets the width of the tabs */ variant?: Variants + /** adds scrollbar if tabs overflow on non-touch devices */ + scrollable?: boolean } & HTMLAttributes type TabChild = { @@ -59,18 +78,24 @@ const TabList = forwardRef(function TabsList( handleChange, tabsId, variant = 'minWidth', + scrollable = false, tabsFocused, } = useContext(TabsContext) const currentTab = useRef(activeTab) + const [arrowNavigating, setArrowNavigating] = useState(false) const selectedTabRef = useCallback( (node: HTMLElement) => { - if (node !== null && tabsFocused) { + if ( + (node !== null && tabsFocused) || + (node !== null && arrowNavigating) + ) { + setArrowNavigating(false) node.focus() } }, - [tabsFocused], + [arrowNavigating, tabsFocused], ) useEffect(() => { @@ -109,15 +134,18 @@ const TabList = forwardRef(function TabsList( const i = direction === 'left' ? 1 : -1 const nextTab = focusableChildren[focusableChildren.indexOf(currentTab.current) - i] + setArrowNavigating(true) handleChange(nextTab === undefined ? fallbackTab : nextTab) } const handleKeyPress = (event: React.KeyboardEvent) => { const { key } = event if (key === 'ArrowLeft') { + event.preventDefault() handleTabsChange('left', lastFocusableChild) } if (key === 'ArrowRight') { + event.preventDefault() handleTabsChange('right', firstFocusableChild) } } @@ -128,6 +156,7 @@ const TabList = forwardRef(function TabsList( ref={ref} {...props} variant={variant} + scrollable={scrollable} > {Tabs} diff --git a/packages/eds-core-react/src/components/Tabs/Tabs.context.ts b/packages/eds-core-react/src/components/Tabs/Tabs.context.ts index 6c5703bc64..d926a99d0e 100644 --- a/packages/eds-core-react/src/components/Tabs/Tabs.context.ts +++ b/packages/eds-core-react/src/components/Tabs/Tabs.context.ts @@ -3,6 +3,7 @@ import { Variants } from './Tabs.types' type State = { variant: Variants + scrollable: boolean handleChange: (index: number) => void activeTab: number tabsId: string @@ -11,6 +12,7 @@ type State = { const TabsContext = createContext({ variant: 'minWidth', + scrollable: false, handleChange: () => null, activeTab: 0, tabsId: '', diff --git a/packages/eds-core-react/src/components/Tabs/Tabs.stories.tsx b/packages/eds-core-react/src/components/Tabs/Tabs.stories.tsx index ab567ace81..fcab6cff72 100644 --- a/packages/eds-core-react/src/components/Tabs/Tabs.stories.tsx +++ b/packages/eds-core-react/src/components/Tabs/Tabs.stories.tsx @@ -1,7 +1,9 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useLayoutEffect } from 'react' import styled from 'styled-components' import { Tabs, + Button, + Icon, TabsProps, Typography, Search, @@ -14,6 +16,34 @@ import { action } from '@storybook/addon-actions' // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {} +const TabsRow = styled.div` + display: flex; +` +const NavButton = styled(Button)` + flex-shrink: 0; +` +const StyledTabList = styled(Tabs.List)` + --track-color: #ffffff; + --thumb-color: #dcdcdc; + scrollbar-color: var(--track-color) var(--thumb-color); + scrollbar-width: thin; + padding-bottom: 8px; + + // For Google Chrome/webkit + & ::-webkit-scrollbar { + height: 8px; + } + + & ::-webkit-scrollbar-thumb { + background: var(--thumb-color); + border-radius: 8px; + } + + & ::-webkit-scrollbar-track { + background: var(--track-color); + } +` + export default { title: 'Navigation/Tabs', component: Tabs, @@ -270,6 +300,192 @@ export const WithStyledComponent: Story = () => { ) } +export const Overflow: Story = () => { + const [activeTab, setActiveTab] = useState(0) + const list = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + const [totalWidth, setTotalWidth] = useState(0) + const [prevDisabled, setPrevDisabled] = useState(true) + const [nextDisabled, setNextDisabled] = useState(false) + + const handleChange = (index: number) => { + setActiveTab(index) + } + + useLayoutEffect(() => { + const cachedList = list.current + let delayToScrollEnd: ReturnType + + const handleScroll = () => { + if (delayToScrollEnd) clearTimeout(delayToScrollEnd) + delayToScrollEnd = setTimeout(() => { + cachedList.scrollLeft === 0 + ? setPrevDisabled(true) + : setPrevDisabled(false) + containerWidth + Math.ceil(cachedList.scrollLeft) === totalWidth + ? setNextDisabled(true) + : setNextDisabled(false) + }, 20) + } + + if (cachedList) { + setContainerWidth(cachedList.clientWidth) + setTotalWidth(cachedList.scrollWidth) + cachedList.addEventListener('scroll', handleScroll, { passive: true }) + } + + return () => { + if (delayToScrollEnd) clearTimeout(delayToScrollEnd) + cachedList.removeEventListener('scroll', handleScroll) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [containerWidth, list, totalWidth]) + + const scroll = (direction: string) => { + //Tabs have "scroll-snap-align: end" so we need to scroll less than + //the full row to avoid skipping past tabs. Here we set it to 80% + const SCROLL_AMOUNT = 0.8 + let target = 0 + const signifier = direction === 'left' ? -1 : 1 + target = + list.current.scrollLeft + signifier * containerWidth * SCROLL_AMOUNT + list.current.scrollTo(target, 0) + } + + return ( + + + scroll('left')} + aria-hidden="true" + tabIndex={-1} + disabled={prevDisabled} + > + + + + {Array.from({ length: 20 }, (_, i) => ( + Tab Title {i + 1} + ))} + + scroll('right')} + aria-hidden="true" + tabIndex={-1} + disabled={nextDisabled} + > + + + + + {Array.from({ length: 20 }, (_, i) => ( + Panel {i + 1} + ))} + + + ) +} + +Overflow.parameters = { + docs: { + description: { + story: + 'Tabs uses css `scroll-snap`, so in a case where there is overflow and the user wants to add next/previous buttons for scrolling, they can use `element.scrollTo` some amount and then css will take care of alignment', + }, + }, +} + +export const OverflowScroll: Story = () => { + const [activeTab, setActiveTab] = useState(0) + + const handleChange = (index: number) => { + setActiveTab(index) + } + + return ( + + + {Array.from({ length: 20 }, (_, i) => ( + Tab Title {i + 1} + ))} + + + {Array.from({ length: 20 }, (_, i) => ( + Panel {i + 1} + ))} + + + ) +} + +OverflowScroll.parameters = { + docs: { + description: { + story: + 'In the case of tabs overflowing, and where next/previous buttons are not desired, the `scrollable` prop adds `overflow-x: auto` to the tabs list. Tabs uses css `scroll-snap` which handles alignment and tabs snapping into place. Note that scrollbar had been disabled for touch devices since the tabs are swipeable', + }, + }, +} + +export const OverflowScrollStyled: Story = () => { + const [activeTab, setActiveTab] = useState(0) + + const handleChange = (index: number) => { + setActiveTab(index) + } + + /* + //An example of how to make custom styled scrollbar for the Tabs.List + const StyledTabList = styled(Tabs.List)` + --track-color: #ffffff; + --thumb-color: #dcdcdc; + scrollbar-color: var(--track-color) var(--thumb-color); + + //For firefox + scrollbar-width: thin; + padding-bottom: 8px; + + // For Google Chrome/Safari/Edge + & ::-webkit-scrollbar { + height: 8px; + } + + & ::-webkit-scrollbar-thumb { + background: var(--thumb-color); + border-radius: 8px; + } + + & ::-webkit-scrollbar-track { + background: var(--track-color); + } + ` */ + + return ( + + + {Array.from({ length: 15 }, (_, i) => ( + Tab Title {i + 1} + ))} + + + {Array.from({ length: 15 }, (_, i) => ( + Panel {i + 1} + ))} + + + ) +} + +OverflowScrollStyled.parameters = { + docs: { + description: { + story: ' The scrollbar styles for Tabs.List can be overwritten', + }, + }, +} + export const Compact: Story = () => { const focusedRef = useRef(null) const [density, setDensity] = useState('comfortable') @@ -307,3 +523,6 @@ Compact.parameters = { WithSearch.storyName = 'With search' WithInputInPanel.storyName = 'With input in panel' WithStyledComponent.storyName = 'With styled component' +Overflow.storyName = 'Overflow with next/previous buttons' +OverflowScroll.storyName = 'Overflow with default scrollbar' +OverflowScrollStyled.storyName = 'Overflow with customized scrollbar' diff --git a/packages/eds-core-react/src/components/Tabs/Tabs.tokens.ts b/packages/eds-core-react/src/components/Tabs/Tabs.tokens.ts index e842132e57..abfaabadf8 100644 --- a/packages/eds-core-react/src/components/Tabs/Tabs.tokens.ts +++ b/packages/eds-core-react/src/components/Tabs/Tabs.tokens.ts @@ -81,6 +81,7 @@ export const token: ComponentToken = { outline: { type: 'outline', width: '1px', + offset: '-1px', style: 'dashed', color: focusOutlineColor, }, diff --git a/packages/eds-core-react/src/components/Tabs/Tabs.tsx b/packages/eds-core-react/src/components/Tabs/Tabs.tsx index f91a537c31..4a31781066 100644 --- a/packages/eds-core-react/src/components/Tabs/Tabs.tsx +++ b/packages/eds-core-react/src/components/Tabs/Tabs.tsx @@ -1,8 +1,8 @@ -import { forwardRef, useState, HTMLAttributes } from 'react' +import { forwardRef, useState, HTMLAttributes, useEffect, useRef } from 'react' import { TabsProvider } from './Tabs.context' import { Variants } from './Tabs.types' import { token as tabsToken } from './Tabs.tokens' -import { useId, useToken } from '@equinor/eds-utils' +import { useId, useToken, useCombinedRefs } from '@equinor/eds-utils' import { ThemeProvider } from 'styled-components' import { useEds } from '../EdsProvider' @@ -13,6 +13,8 @@ export type TabsProps = { onChange?: (index: number) => void /** Sets the width of the tabs. Tabs can have a maximum width of 360px */ variant?: Variants + /** adds scrollbar if tabs overflow on non-touch devices */ + scrollable?: boolean } & Omit, 'onChange'> const Tabs = forwardRef(function Tabs( @@ -22,18 +24,25 @@ const Tabs = forwardRef(function Tabs( onBlur, onFocus, variant = 'minWidth', + scrollable, id, ...props }, ref, ) { const tabsId = useId(id, 'tabs') - + const tabsRef = useRef(null) + const combinedTabsRef = useCombinedRefs(tabsRef, ref) const [tabsFocused, setTabsFocused] = useState(false) + const [listenerAttached, setListenerAttached] = useState(false) let blurTimer const handleBlur = (e: React.FocusEvent) => { + setListenerAttached(false) + if (tabsRef.current) { + tabsRef.current.removeEventListener('keyup', checkIfTabWasPressed) + } blurTimer = setTimeout(() => { if (tabsFocused) { setTabsFocused(false) @@ -47,12 +56,34 @@ const Tabs = forwardRef(function Tabs( return } clearTimeout(blurTimer) - if (!tabsFocused) { - setTabsFocused(true) + + if (tabsFocused) return + if (!listenerAttached) { + if (tabsRef.current) { + setListenerAttached(true) + tabsRef.current.addEventListener('keyup', checkIfTabWasPressed, { + once: true, + capture: true, + }) + } } onFocus && onFocus(e) } + //Only force focus on active Tab if Tabs was navigated to with keyboard + const checkIfTabWasPressed = (event: KeyboardEvent) => { + setListenerAttached(false) + if (event.key === 'Tab') setTabsFocused(true) + } + + useEffect(() => { + const tabs = tabsRef.current + return () => { + if (tabs) tabs.removeEventListener('keyup', checkIfTabWasPressed) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const { density } = useEds() const token = useToken({ density }, tabsToken) @@ -64,10 +95,16 @@ const Tabs = forwardRef(function Tabs( handleChange: onChange, tabsId, variant, + scrollable, tabsFocused, }} > -
+
)