-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Vincent Smedinga <[email protected]> Co-authored-by: Aram Limpens <[email protected]> Co-authored-by: Aram <[email protected]>
- Loading branch information
1 parent
b55f0a3
commit 4eec887
Showing
20 changed files
with
878 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.