Skip to content

Tabs overflow strategy #253

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 22 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/css-no-scollbar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cypress-design/css': minor
---

add the no-scrollbar utillity class
7 changes: 7 additions & 0 deletions .changeset/overflowing-tabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@cypress-design/constants-tabs': minor
'@cypress-design/react-tabs': minor
'@cypress-design/vue-tabs': minor
---

manage the overflowing of tabs
66 changes: 65 additions & 1 deletion components/Tabs/assertions.ts
Original file line number Diff line number Diff line change
@@ -6,7 +6,29 @@ const tabs = [
{ id: 'ov', label: 'Overview' },
{ id: 'cl', label: 'Command Log' },
{ id: 'err', label: 'Errors' },
{ id: 'reco', label: 'Recommendations' },
{ id: 'reco', label: 'Recommend' },
]

const longTabs = [
{ id: 'ov1', label: 'Overview' },
{ id: 'cl1', label: 'Command Log' },
{ id: 'err1', label: 'Errors' },
{ id: 'o', label: 'o' },
{ id: 'd', label: 'd' },
{ id: 's', label: 's' },
{ id: 'reco1', label: 'Recommendations' },
{ id: 'ov2', label: 'Overview 1' },
{ id: 'cl2', label: 'Command Log 1' },
{ id: 'err2', label: 'Errors 1' },
{ id: 'reco2', label: 'Recommendations 1' },
{ id: 'ov3', label: 'Overview 2', active: true },
{ id: 'cl3', label: 'Command Log 2' },
{ id: 'err3', label: 'Errors 2' },
{ id: 'reco3', label: 'Recommendations 2' },
{ id: 'ov4', label: 'Overview 3' },
{ id: 'cl4', label: 'Command Log 3' },
{ id: 'err4', label: 'Errors 3' },
{ id: 'reco4', label: 'Recommendations 3' },
]

export default function assertions(
@@ -42,5 +64,47 @@ export default function assertions(
})
})
})

describe('overflowing tabs', () => {
it(
'displays ellipsis when tabs are overflowing',
{ viewportHeight: 500 },
() => {
mountStory({
tabs: longTabs,
// variant: 'underline-small',
})
cy.findByText('Show more tabs').should('exist')
}
)

it(
'displays ellipsis as active tab when tabs are overflowing',
{ viewportHeight: 500 },
() => {
mountStory({
tabs: longTabs,
// variant: 'underline-small',
})
cy.contains('button', 'Show more tabs').should(
'have.attr',
'aria-selected'
)
}
)

it(
'displays active tab when tabs are overflowing',
{ viewportHeight: 500 },
() => {
mountStory({
tabs: longTabs,
// variant: 'underline-small',
})
cy.findByText('Show more tabs').click({ force: true })
cy.findByText('Overview').click()
}
)
})
})
}
7 changes: 5 additions & 2 deletions components/Tabs/constants/src/index.ts
Original file line number Diff line number Diff line change
@@ -35,6 +35,9 @@ export interface Tab {
href?: string
}

export const overflowContainerClass =
'overflow-x-auto overflow-y-hidden no-scrollbar px-[1px] pb-[4px]'

export const variants = {
default: {
classes: {
@@ -110,7 +113,7 @@ export const variants = {
'underline-small': {
classes: {
wrapper:
'py-[4px] flex gap-[8px] border-b border-gray-100 text-gray-700 dark:text-white relative',
'py-[4px] inline-flex gap-[8px] border-b border-gray-100 text-gray-700 dark:text-white relative',
button:
'flex items-center px-[12px] h-[24px] leading-[20px] text-[14px] rounded font-medium whitespace-nowrap relative',
active: 'text-gray-900 dark:text-gray-400 z-20',
@@ -134,7 +137,7 @@ export const variants = {
'underline-large': {
classes: {
wrapper:
'py-[4px] flex gap-[8px] border-b border-gray-100 text-gray-700 dark:text-white relative',
'py-[4px] inline-flex gap-[8px] border-b border-gray-100 text-gray-700 dark:text-white relative',
button:
'flex items-center px-[12px] h-[32px] leading-[24px] text-[16px] rounded font-medium whitespace-nowrap relative',
active: 'text-gray-900 dark:text-gray-400 z-20',
168 changes: 99 additions & 69 deletions components/Tabs/react/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as React from 'react'
import clsx from 'clsx'
import { Tab, variants } from '@cypress-design/constants-tabs'
import {
Tab,
overflowContainerClass,
variants,
} from '@cypress-design/constants-tabs'

export interface TabsProps {
/**
@@ -41,28 +45,52 @@ export const Tabs: React.FC<TabsProps & React.HTMLProps<HTMLDivElement>> = ({
}, [activeIdProp])

const $tab = React.useRef<(HTMLButtonElement | HTMLAnchorElement)[]>([])
const $overflowContainer = React.useRef<HTMLDivElement>(null)

const [activeMarkerStyle, setActiveMarkerStyle] = React.useState<{
left?: string
width?: string
transitionProperty?: string
}>({})

React.useEffect(() => {
function getActiveTabEl() {
const activeTab = tabs.findIndex((tab) => tab.id === activeId)
if (activeTab > -1) {
const activeTabEl = $tab.current?.[activeTab]
if (activeTabEl) {
setActiveMarkerStyle({
left: `${activeTabEl.offsetLeft}px`,
width: `${activeTabEl.offsetWidth}px`,
transitionProperty: 'left, width',
})
return activeTabEl
}
}
return null
}

React.useEffect(() => {
const activeTabEl = getActiveTabEl()
if (activeTabEl) {
setActiveMarkerStyle({
left: `${activeTabEl.offsetLeft}px`,
width: `${activeTabEl.offsetWidth}px`,
transitionProperty: 'left, width',
})
}
setMounted(true)
}, [activeId])

React.useEffect(() => {
const activeTabEl = getActiveTabEl()
if ($overflowContainer.current && activeTabEl) {
// Scroll to active tab if it is not visible
const leftBoundary =
$overflowContainer.current.offsetWidth / 2 - activeTabEl.offsetWidth / 2

if (activeTabEl.offsetLeft > leftBoundary) {
$overflowContainer.current.scrollTo({
left: activeTabEl.offsetLeft - leftBoundary,
})
}
}
}, [])

function navigate(shift: number) {
const shiftedIndex = tabs.findIndex((tab) => tab.id === activeId) + shift
const nextIndex =
@@ -83,69 +111,71 @@ export const Tabs: React.FC<TabsProps & React.HTMLProps<HTMLDivElement>> = ({
variant in variants ? variants[variant].icon : variants.default.icon

return (
<div role="tablist" className={classes.wrapper} {...rest}>
{tabs.map((tab, index) => {
const ButtonTag = tab.href ? 'a' : 'button'
return (
<ButtonTag
key={tab.id}
role="tab"
href={tab.href}
className={clsx([
classes.button,
{
[classes.activeStatic]: tab.id === activeId && !mounted,
[classes.active]: tab.id === activeId,
[classes.inActive]: tab.id !== activeId,
},
])}
// @ts-expect-error React is incapable of typing this kind of ref so we do not add a type
ref={(el) => (el ? ($tab.current[index] = el) : null)}
tabIndex={tab.id === activeId ? undefined : -1}
aria-selected={tab.id === activeId ? true : undefined}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
setActiveId(tab.id)
onSwitch?.(tab)
}}
onKeyUp={(e) => {
if (e.key === 'ArrowRight') {
navigate(1)
} else if (e.key === 'ArrowLeft') {
navigate(-1)
}
}}
>
<>
{(() => {
const IconBefore = tab.iconBefore ?? tab.icon
return IconBefore ? (
<IconBefore {...iconProps} className="mr-[8px]" />
) : null
})()}
{tab.label}
{tab.tag ? <div className={classes.tag}>{tab.tag}</div> : null}
{tab.iconAfter ? (
<tab.iconAfter {...iconProps} className="ml-[8px]" />
<div ref={$overflowContainer} className={overflowContainerClass}>
<div role="tablist" className={classes.wrapper} {...rest}>
{tabs.map((tab, index) => {
const ButtonTag = tab.href ? 'a' : 'button'
return (
<ButtonTag
key={tab.id}
role="tab"
href={tab.href}
className={clsx([
classes.button,
{
[classes.activeStatic]: tab.id === activeId && !mounted,
[classes.active]: tab.id === activeId,
[classes.inActive]: tab.id !== activeId,
},
])}
// @ts-expect-error React is incapable of typing this kind of ref so we do not add a type
ref={(el) => (el ? ($tab.current[index] = el) : null)}
tabIndex={tab.id === activeId ? undefined : -1}
aria-selected={tab.id === activeId ? true : undefined}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) return
e.preventDefault()
setActiveId(tab.id)
onSwitch?.(tab)
}}
onKeyUp={(e) => {
if (e.key === 'ArrowRight') {
navigate(1)
} else if (e.key === 'ArrowLeft') {
navigate(-1)
}
}}
>
<>
{(() => {
const IconBefore = tab.iconBefore ?? tab.icon
return IconBefore ? (
<IconBefore {...iconProps} className="mr-[8px]" />
) : null
})()}
{tab.label}
{tab.tag ? <div className={classes.tag}>{tab.tag}</div> : null}
{tab.iconAfter ? (
<tab.iconAfter {...iconProps} className="ml-[8px]" />
) : null}
</>
{tab.id === activeId && !activeMarkerStyle.left ? (
<div className={classes.activeMarkerStatic} />
) : null}
</>
{tab.id === activeId && !activeMarkerStyle.left ? (
<div className={classes.activeMarkerStatic} />
) : null}
</ButtonTag>
)
})}
<div
key="active-marker"
className={clsx(classes.activeMarker, classes.activeMarkerColor)}
style={activeMarkerStyle}
/>
<div
key="active-marker-blend"
className={clsx(classes.activeMarker, classes.activeMarkerBlender)}
style={activeMarkerStyle}
/>
</ButtonTag>
)
})}
<div
key="active-marker"
className={clsx(classes.activeMarker, classes.activeMarkerColor)}
style={activeMarkerStyle}
/>
<div
key="active-marker-blend"
className={clsx(classes.activeMarker, classes.activeMarkerBlender)}
style={activeMarkerStyle}
/>
</div>
</div>
)
}
Loading