Skip to content
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

Tabs overflow strategy #253

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
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
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down
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 {
/**
Expand Down Expand Up @@ -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 =
Expand All @@ -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>
)
}
Expand Down
Loading