Skip to content

Commit

Permalink
✨ Enable Tabs horizontal overflow (#1962)
Browse files Browse the repository at this point in the history
* 💄Add support for scrolling tabs

* 📝 Added story for overflowed tabs with next/prev button

* 🐛 Improve handling eventlistener

* 🐛 fix tab focus when using arrows

* ✨ Adding scrollable prop

* 📝 Added scrollable example to storybook

* 🐛 Move styledTablist out of story so it doesn't break

* setListenerAttached only set when addEventListener definetly added

* 🎨 cleanup Tabs button story

* 🐛 focus: outline offset should be negative outline width

* 📝 Changed the ordering of stories
  • Loading branch information
oddvernes committed Feb 16, 2022
1 parent 5476638 commit 364cd31
Show file tree
Hide file tree
Showing 6 changed files with 300 additions and 9 deletions.
3 changes: 3 additions & 0 deletions packages/eds-core-react/src/components/Tabs/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const StyledTab = styled.button.attrs<TabProps>(
text-overflow: ellipsis;
overflow-x: hidden;
scroll-snap-align: end;
scroll-snap-stop: always;
&:focus {
outline: none;
}
Expand Down
33 changes: 31 additions & 2 deletions packages/eds-core-react/src/components/Tabs/TabList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
forwardRef,
useContext,
useRef,
useState,
useCallback,
useEffect,
ReactElement,
Expand Down Expand Up @@ -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<HTMLDivElement>

type TabChild = {
Expand All @@ -59,18 +78,24 @@ const TabList = forwardRef<HTMLDivElement, TabListProps>(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(() => {
Expand Down Expand Up @@ -109,15 +134,18 @@ const TabList = forwardRef<HTMLDivElement, TabListProps>(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<HTMLDivElement>) => {
const { key } = event
if (key === 'ArrowLeft') {
event.preventDefault()
handleTabsChange('left', lastFocusableChild)
}
if (key === 'ArrowRight') {
event.preventDefault()
handleTabsChange('right', firstFocusableChild)
}
}
Expand All @@ -128,6 +156,7 @@ const TabList = forwardRef<HTMLDivElement, TabListProps>(function TabsList(
ref={ref}
{...props}
variant={variant}
scrollable={scrollable}
>
{Tabs}
</StyledTabList>
Expand Down
2 changes: 2 additions & 0 deletions packages/eds-core-react/src/components/Tabs/Tabs.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Variants } from './Tabs.types'

type State = {
variant: Variants
scrollable: boolean
handleChange: (index: number) => void
activeTab: number
tabsId: string
Expand All @@ -11,6 +12,7 @@ type State = {

const TabsContext = createContext<State>({
variant: 'minWidth',
scrollable: false,
handleChange: () => null,
activeTab: 0,
tabsId: '',
Expand Down
221 changes: 220 additions & 1 deletion packages/eds-core-react/src/components/Tabs/Tabs.stories.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -270,6 +300,192 @@ export const WithStyledComponent: Story<TabsProps> = () => {
)
}

export const Overflow: Story<TabsProps> = () => {
const [activeTab, setActiveTab] = useState(0)
const list = useRef<HTMLDivElement>(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<typeof setTimeout>

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 (
<Tabs activeTab={activeTab} onChange={handleChange}>
<TabsRow>
<NavButton
variant="ghost_icon"
onClick={() => scroll('left')}
aria-hidden="true"
tabIndex={-1}
disabled={prevDisabled}
>
<Icon name="chevron_left" />
</NavButton>
<Tabs.List ref={list}>
{Array.from({ length: 20 }, (_, i) => (
<Tabs.Tab key={i}>Tab Title {i + 1}</Tabs.Tab>
))}
</Tabs.List>
<NavButton
variant="ghost_icon"
onClick={() => scroll('right')}
aria-hidden="true"
tabIndex={-1}
disabled={nextDisabled}
>
<Icon name="chevron_right" />
</NavButton>
</TabsRow>
<Tabs.Panels>
{Array.from({ length: 20 }, (_, i) => (
<Tabs.Panel key={i}>Panel {i + 1}</Tabs.Panel>
))}
</Tabs.Panels>
</Tabs>
)
}

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<TabsProps> = () => {
const [activeTab, setActiveTab] = useState(0)

const handleChange = (index: number) => {
setActiveTab(index)
}

return (
<Tabs activeTab={activeTab} onChange={handleChange} scrollable>
<Tabs.List>
{Array.from({ length: 20 }, (_, i) => (
<Tabs.Tab key={i}>Tab Title {i + 1}</Tabs.Tab>
))}
</Tabs.List>
<Tabs.Panels>
{Array.from({ length: 20 }, (_, i) => (
<Tabs.Panel key={i}>Panel {i + 1}</Tabs.Panel>
))}
</Tabs.Panels>
</Tabs>
)
}

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<TabsProps> = () => {
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 (
<Tabs activeTab={activeTab} onChange={handleChange} scrollable>
<StyledTabList>
{Array.from({ length: 15 }, (_, i) => (
<Tabs.Tab key={i}>Tab Title {i + 1}</Tabs.Tab>
))}
</StyledTabList>
<Tabs.Panels>
{Array.from({ length: 15 }, (_, i) => (
<Tabs.Panel key={i}>Panel {i + 1}</Tabs.Panel>
))}
</Tabs.Panels>
</Tabs>
)
}

OverflowScrollStyled.parameters = {
docs: {
description: {
story: ' The scrollbar styles for Tabs.List can be overwritten',
},
},
}

export const Compact: Story<TabsProps> = () => {
const focusedRef = useRef<HTMLButtonElement>(null)
const [density, setDensity] = useState<Density>('comfortable')
Expand Down Expand Up @@ -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'
1 change: 1 addition & 0 deletions packages/eds-core-react/src/components/Tabs/Tabs.tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export const token: ComponentToken = {
outline: {
type: 'outline',
width: '1px',
offset: '-1px',
style: 'dashed',
color: focusOutlineColor,
},
Expand Down
Loading

0 comments on commit 364cd31

Please sign in to comment.