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

feat: Allow a specific Tab to be made active #1258

Merged
merged 23 commits into from
Jun 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1b349df
feat(tabs): :sparkles: enable setting active tab
RubenSibon Jun 12, 2024
d6fc9fe
docs(tabs): :memo: add set initial active tab story
RubenSibon Jun 12, 2024
77e47f1
Merge branch 'develop' of github.com:Amsterdam/design-system into fea…
RubenSibon Jun 12, 2024
51c314d
Finetune documentation
VincentSmedinga Jun 12, 2024
e53d91a
refactor(tabs): :truck: rename state var
RubenSibon Jun 14, 2024
2890041
Merge branch 'feat/DES-676-initial-active-tab' of github.com:Amsterda…
RubenSibon Jun 14, 2024
a1bcada
refactor(tabs): :recycle: loop over tab comps & content
RubenSibon Jun 14, 2024
18bf3cf
refactor(react): :truck: sync set state fn with state var
RubenSibon Jun 14, 2024
d60c36a
Merge branch 'develop' of github.com:Amsterdam/design-system into fea…
RubenSibon Jun 14, 2024
b337ce3
fix(story): :adhesive_bandage: do not nest paragraphs
RubenSibon Jun 14, 2024
d31fb85
Merge branch 'develop' of github.com:Amsterdam/design-system into fea…
RubenSibon Jun 14, 2024
0e87a8d
fix(react): :bug: guard against invalid active tab numbers
RubenSibon Jun 14, 2024
812cc19
fix(react): :bug: check if active tab is number
RubenSibon Jun 14, 2024
7d45a66
refactor(story): :recycle: do not hard-code control max
RubenSibon Jun 14, 2024
79ff1d6
refactor(story): :recycle: do not rename imported fn
RubenSibon Jun 14, 2024
b472a5d
test(react): :white_check_mark: meaningfully name selected tabs
RubenSibon Jun 14, 2024
59e43b9
Use extracted variable when logging
VincentSmedinga Jun 15, 2024
30c1582
Improve variable name
VincentSmedinga Jun 15, 2024
353d613
Hide tab number in Button example
VincentSmedinga Jun 15, 2024
895d31d
Remove unnecessary and buggy Button story
VincentSmedinga Jun 15, 2024
4044057
Set children correctly in meta
VincentSmedinga Jun 15, 2024
c03b74e
Mention the `tab` prop in docs
VincentSmedinga Jun 15, 2024
1d0190c
Merge branch 'develop' into feat/DES-676-initial-active-tab
RubenSibon Jun 17, 2024
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
108 changes: 106 additions & 2 deletions packages/react/src/Tabs/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,111 @@ describe('Tabs', () => {
// 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
it('should be able to set the initially active tab', () => {
render(
<Tabs activeTab={2}>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

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

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

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

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

it('should set the first tab as the initially active tab with a too small argument', async () => {
render(
<Tabs activeTab={-3}>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

const firstTab = screen.getByRole('tab', { name: 'Tab 1' })
const lastTab = screen.getByRole('tab', { name: 'Tab 4' })

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

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

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

it('should set the last tab as the initially active tab with a too large argument', async () => {
render(
<Tabs activeTab={Infinity}>
VincentSmedinga marked this conversation as resolved.
Show resolved Hide resolved
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

const firstTab = screen.getByRole('tab', { name: 'Tab 1' })
const lastTab = screen.getByRole('tab', { name: 'Tab 4' })

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

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

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

it('should set the first tab as the initially active tab with an invalid number', async () => {
render(
<Tabs activeTab={NaN}>
<Tabs.List>
<Tabs.Button tab={0}>Tab 1</Tabs.Button>
<Tabs.Button tab={1}>Tab 2</Tabs.Button>
<Tabs.Button tab={2}>Tab 3</Tabs.Button>
<Tabs.Button tab={3}>Tab 4</Tabs.Button>
</Tabs.List>
<Tabs.Panel tab={0}>Content 1</Tabs.Panel>
<Tabs.Panel tab={1}>Content 2</Tabs.Panel>
<Tabs.Panel tab={2}>Content 3</Tabs.Panel>
<Tabs.Panel tab={3}>Content 4</Tabs.Panel>
</Tabs>,
)

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

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

expect(screen.getByRole('tabpanel')).toHaveTextContent('Content 1')
})
})
71 changes: 47 additions & 24 deletions packages/react/src/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,64 @@
*/

import clsx from 'clsx'
import { forwardRef, useId, useImperativeHandle, useRef, useState } from 'react'
import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react'
import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react'
import type { ForwardedRef, HTMLAttributes, PropsWithChildren, ReactNode } from 'react'
import { TabsButton } from './TabsButton'
import { TabsContext } from './TabsContext'
import { TabsList } from './TabsList'
import { TabsPanel } from './TabsPanel'
import { useKeyboardFocus } from '../common/useKeyboardFocus'

export type TabsProps = PropsWithChildren<HTMLAttributes<HTMLDivElement>>
export type TabsProps = {
/** The number of the active tab. Corresponds to its `tab` value. */
activeTab?: number
} & PropsWithChildren<HTMLAttributes<HTMLDivElement>>

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

const updateTab = (tab: number) => {
setActiveTab(tab)
}
const allTabs = useMemo(() => {
if (!Array.isArray(children)) return []
return (children[0].props.children as ReactNode[]).map((child) => child)
}, [children])

// use a passed ref if it's there, otherwise use innerRef
useImperativeHandle(ref, () => innerRef.current as HTMLDivElement)
useEffect(() => {
if (typeof activeTab !== 'number') return
if (!Number.isInteger(activeTab)) return

const { keyDown } = useKeyboardFocus(innerRef, {
rotating: true,
horizontally: true,
})
if (activeTab < 0) {
setActiveTabId(0)
} else if (activeTab > allTabs.length - 1) {
setActiveTabId(allTabs.length - 1)
} else {
setActiveTabId(activeTab)
}
}, [activeTab, allTabs])

return (
<TabsContext.Provider value={{ activeTab, updateTab, tabsId }}>
<div {...restProps} role="tabs" ref={innerRef} onKeyDown={keyDown} className={clsx('ams-tabs', className)}>
{children}
</div>
</TabsContext.Provider>
)
})
const updateTab = (tab: number) => {
setActiveTabId(tab)
}

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

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

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

TabsRoot.displayName = 'Tabs'

Expand Down
12 changes: 6 additions & 6 deletions packages/react/src/Tabs/TabsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,23 @@ export type TabsButtonProps = {

export const TabsButton = forwardRef(
({ children, className, tab = 0, ...restProps }: TabsButtonProps, ref: ForwardedRef<HTMLButtonElement>) => {
const { activeTab, updateTab, tabsId } = useContext(TabsContext)
const { activeTab, tabsId, updateTab } = 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}
className={clsx('ams-tabs__button', className)}
id={`${tabsId}-tab-${tab}`}
onClick={() => {
startTransition(() => {
updateTab(tab)
})
}}
className={clsx('ams-tabs__button', className)}
ref={ref}
role="tab"
tabIndex={activeTab === tab ? 0 : -1}
>
{children}
</button>
Expand Down
14 changes: 12 additions & 2 deletions storybook/src/components/Tabs/Tabs.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ import README from "../../../../packages/css/src/components/tabs/README.md?raw";

<Markdown>{README}</Markdown>

## Examples

### Default

Each tab consists of a button and a panel.
A `tab` prop with a corresponding value connects them.

<Primary />

## Tab
### With Initial Tab

The first tab is active by default.
Another tab’s panel can be displayed initially as well.

<Canvas of={TabsStories.Tab} />
<Canvas of={TabsStories.WithInitialTab} />
Loading
Loading