Skip to content

Commit

Permalink
feat: Add Tabs component (#1068)
Browse files Browse the repository at this point in the history
Co-authored-by: Vincent Smedinga <[email protected]>
Co-authored-by: Aram Limpens <[email protected]>
Co-authored-by: Aram <[email protected]>
  • Loading branch information
4 people authored Feb 29, 2024
1 parent b55f0a3 commit 4eec887
Show file tree
Hide file tree
Showing 20 changed files with 878 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/css/src/components/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

/* Append here */
@import "./tabs/tabs";
@import "./column/column";
@import "./margin/margin";
@import "./gap/gap";
Expand Down
32 changes: 32 additions & 0 deletions packages/css/src/components/tabs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!-- @license CC0-1.0 -->

# Tabs

Tabs are used to bundle related content in a compact overview within a page. Each tab has a short name, and these names indicate the relationship between the information displayed in each tab.

## How to Use

- The content of each tab is viewable independently, not in the context of one another.
- The content within each tab should have a similar structure.
- Use when there is limited visual space and content needs to be divided into sections.

You can navigate tabs with your keyboard:

| key | element |
| :------------- | :--------------------------------------------- |
| Enter or Space | Open or close the tab that has the focus |
| Tab | Go to the next element that can have focus |
| Shift + Tab | Go to the next element that can have focus |
| Left arrow | If the tabs have focus: go to the previous tab |
| Right arrow | If the tabs have focus: go to the next tab |
| Home | If the tabs have focus: go to the first tab |
| End | If the tabs have focus, go to the last tab |

### Caution

Do not use tabs if the content in each tab functions just as well on separate pages.

## References

- [W3C - Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/)
- [MDN - Tab Aria Roles](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tab_role)
41 changes: 41 additions & 0 deletions packages/css/src/components/tabs/tabs.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

@import "../../common/breakpoint";

.amsterdam-tabs__list {
border-bottom: var(--amsterdam-tabs-list-border-bottom);
display: flex;
overflow-x: auto;
}

.amsterdam-tabs__button {
background-color: var(--amsterdam-tabs-button-background-color);
border: var(--amsterdam-tabs-button-border);
color: var(--amsterdam-tabs-button-color);
cursor: var(--amsterdam-tabs-button-cursor);
font-family: var(--amsterdam-tabs-button-font-family);
font-size: var(--amsterdam-tabs-button-font-size);
font-weight: var(--amsterdam-tabs-button-font-weight);
line-height: var(--amsterdam-tabs-button-line-height);
outline-offset: var(--amsterdam-tabs-button-outline-offset);
padding-block: var(--amsterdam-tabs-button-padding-block);
padding-inline: var(--amsterdam-tabs-button-padding-inline);

&:disabled {
color: var(--amsterdam-tabs-button-disabled-color);
cursor: var(--amsterdam-tab-button-disabled-cursor);
}

&:hover:not([aria-selected="true"], [disabled]) {
box-shadow: var(--amsterdam-tabs-button-hover-box-shadow);
color: var(--amsterdam-tabs-button-hover-color);
}

&[aria-selected="true"] {
background-color: var(--amsterdam-tabs-button-selected-background-color);
color: var(--amsterdam-tabs-button-selected-color);
}
}
5 changes: 5 additions & 0 deletions packages/react/src/Tabs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- @license CC0-1.0 -->

# React Tabs component

[Tabs documentation](../../../css/src/components/tabs/README.md)
103 changes: 103 additions & 0 deletions packages/react/src/Tabs/Tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { Tabs } from './Tabs'
import '@testing-library/jest-dom'

describe('Tabs', () => {
it('renders', () => {
render(<Tabs />)

const component = screen.getByRole('tabs')

expect(component).toBeInTheDocument()
expect(component).toBeVisible()
})

it('renders a design system BEM class name', () => {
render(<Tabs />)

const component = screen.getByRole('tabs')

expect(component).toHaveClass('amsterdam-tabs')
})

it('renders an additional class name', () => {
render(<Tabs className="extra" />)

const component = screen.getByRole('tabs')

expect(component).toHaveClass('amsterdam-tabs extra')
})

it('supports ForwardRef in React', () => {
const ref = createRef<HTMLDivElement>()

const { container } = render(<Tabs ref={ref} />)

const component = container.querySelector(':only-child')

expect(ref.current).toBe(component)
})

it('supports children', () => {
render(
<Tabs>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
</Tabs>,
)

expect(screen.getByRole('tabs')).toBeInTheDocument()
expect(screen.getByRole('tablist')).toBeInTheDocument()
expect(screen.getByRole('tab', { selected: true })).toBeInTheDocument()
expect(screen.getByRole('tabpanel')).toBeInTheDocument()
})

it('should select a tab when clicked', async () => {
render(
<Tabs>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
</Tabs>,
)

const tabOne = screen.getByRole('tab', { name: 'Tab 1' })
const tabTwo = screen.getByRole('tab', { name: 'Tab 2' })

expect(tabOne).toHaveAttribute('aria-selected', 'true')
expect(tabOne).toHaveAttribute('tabindex', '0')

expect(tabTwo).toHaveAttribute('aria-selected', 'false')
expect(tabTwo).toHaveAttribute('tabindex', '-1')

expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1')

if (tabTwo) {
fireEvent.click(tabTwo)
}

expect(tabOne).toHaveAttribute('aria-selected', 'false')
expect(tabOne).toHaveAttribute('tabindex', '-1')

expect(tabTwo).toHaveAttribute('aria-selected', 'true')
expect(tabTwo).toHaveAttribute('tabindex', '0')

expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 2')
})

it.skip('should forward the onClick event on the Tab', () => {
// This feature has not been implemented yet
})

it.skip('should be able to set the active initial tab', () => {
// This feature has not been implemented yet
})
})
64 changes: 64 additions & 0 deletions packages/react/src/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import clsx from 'clsx'
import { forwardRef, useId, useImperativeHandle, useRef, useState } from 'react'
import type { ForwardedRef, ForwardRefExoticComponent, HTMLAttributes, PropsWithChildren, RefAttributes } from 'react'
import { TabsButton } from './TabsButton'
import { TabsContext } from './TabsContext'
import { TabsList } from './TabsList'
import { TabsPanel } from './TabsPanel'
import useFocusWithArrows from '../common/useFocusWithArrows'

export type TabsProps = PropsWithChildren<HTMLAttributes<HTMLDivElement>>

type TabsComponent = {
/** Always use a TabList to hold the Tab Buttons */
List: typeof TabsList
/** Use a TabButton for each tab */
Button: typeof TabsButton
/** A TabsPanel will only return its contents when the corresponding TabsButton is activated */
Panel: typeof TabsPanel
} & ForwardRefExoticComponent<TabsProps & RefAttributes<HTMLDivElement>>

export const Tabs = forwardRef(
({ children, className, ...restProps }: TabsProps, ref: ForwardedRef<HTMLDivElement>) => {
const tabsId = useId()
const [activeTab, setActiveTab] = useState(0)
const innerRef = useRef<HTMLDivElement>(null)

const updateTab = (tab: number) => {
setActiveTab(tab)
}

// use a passed ref if it's there, otherwise use innerRef
useImperativeHandle(ref, () => innerRef.current as HTMLDivElement)

const { keyDown } = useFocusWithArrows({
ref: innerRef,
rotating: true,
horizontally: true,
})

return (
<TabsContext.Provider value={{ activeTab, updateTab, tabsId }}>
<div
{...restProps}
role="tabs"
ref={innerRef}
onKeyDown={keyDown}
className={clsx('amsterdam-tabs', className)}
>
{children}
</div>
</TabsContext.Provider>
)
},
) as TabsComponent

Tabs.List = TabsList
Tabs.Button = TabsButton
Tabs.Panel = TabsPanel
Tabs.displayName = 'Tabs'
63 changes: 63 additions & 0 deletions packages/react/src/Tabs/TabsButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { TabsButton } from './TabsButton'
import '@testing-library/jest-dom'

describe('Tabs button', () => {
it('renders', () => {
render(<TabsButton tab={0} />)

const component = screen.getByRole('tab')

expect(component).toBeInTheDocument()
})

it('renders a design system BEM class name', () => {
render(<TabsButton tab={0} />)

const component = screen.getByRole('tab')

expect(component).toHaveClass('amsterdam-tabs__button')
})

it('renders an additional class name', () => {
render(<TabsButton tab={0} className="extra" />)

const component = screen.getByRole('tab')

expect(component).toHaveClass('amsterdam-tabs__button extra')
})

it('renders a label', () => {
render(<TabsButton tab={0}>Label</TabsButton>)

const component = screen.getByRole('tab', { name: 'Label' })

expect(component).toBeInTheDocument()
})

it('renders the correct id based on the tabs prop', () => {
const { container } = render(<TabsButton tab={123} />)

const component = container.querySelector('#-tab-123')

expect(component).toBeInTheDocument()
})

it('should associate the button with the correct tab', () => {
render(<TabsButton tab={0} />)

const component = screen.getByRole('tab')

expect(component).toHaveAttribute('aria-controls', '-panel-0')
})

it('supports ForwardRef in React', () => {
const ref = createRef<HTMLButtonElement>()
render(<TabsButton tab={0} ref={ref} />)

const component = screen.getByRole('tab')

expect(ref.current).toBe(component)
})
})
41 changes: 41 additions & 0 deletions packages/react/src/Tabs/TabsButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import clsx from 'clsx'
import { forwardRef, startTransition, useContext } from 'react'
import type { ButtonHTMLAttributes, ForwardedRef, PropsWithChildren } from 'react'
import { TabsContext } from './TabsContext'

export type TabsButtonProps = {
tab: number
} & PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>

export const TabsButton = forwardRef(
({ children, className, tab = 0, ...restProps }: TabsButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
const { activeTab, updateTab, tabsId } = useContext(TabsContext)

return (
<button
{...restProps}
role="tab"
id={`${tabsId}-tab-${tab}`}
aria-controls={`${tabsId}-panel-${tab}`}
aria-selected={activeTab === tab}
tabIndex={activeTab === tab ? 0 : -1}
ref={ref}
onClick={() => {
startTransition(() => {
updateTab(tab)
})
}}
className={clsx('amsterdam-tabs__button', className)}
>
{children}
</button>
)
},
)

TabsButton.displayName = 'Tabs.Button'
21 changes: 21 additions & 0 deletions packages/react/src/Tabs/TabsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import { createContext } from 'react'

export type TabsContextValue = {
activeTab: number
// eslint-disable-next-line no-unused-vars
updateTab: (tab: number) => void
tabsId: string
}

const defaultValues: TabsContextValue = {
activeTab: 0,
updateTab: () => {},
tabsId: '',
}

export const TabsContext = createContext(defaultValues)
Loading

0 comments on commit 4eec887

Please sign in to comment.