8000 refactor(CTabs): fully implement a controlled/uncontrolled pattern · coreui/coreui-react@e0f155f · GitHub
[go: up one dir, main page]

Skip to content

Commit e0f155f

Browse files
committed
refactor(CTabs): fully implement a controlled/uncontrolled pattern
1 parent f451d62 commit e0f155f

15 files changed

+184
-34
lines changed

packages/coreui-react/src/components/tabs/CTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { forwardRef, HTMLAttributes, useContext } from 'react'
22
import PropTypes from 'prop-types'
33
import classNames from 'classnames'
44

5-
import { TabsContext } from './CTabs'
5+
import { CTabsContext } from './CTabsContext'
66

77
export interface CTabProps extends HTMLAttributes<HTMLButtonElement> {
88
/**
@@ -21,7 +21,7 @@ export interface CTabProps extends HTMLAttributes<HTMLButtonElement> {
2121

2222
export const CTab = forwardRef<HTMLButtonElement, CTabProps>(
2323
({ children, className, itemKey, ...rest }, ref) => {
24-
const { _activeItemKey, setActiveItemKey, id } = useContext(TabsContext)
24+
const { _activeItemKey, setActiveItemKey, id } = useContext(CTabsContext)
2525

2626
const isActive = () => itemKey === _activeItemKey
2727

packages/coreui-react/src/components/tabs/CTabPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types'
33
import classNames from 'classnames'
44
import { Transition } from 'react-transition-group'
55

6-
import { TabsContext } from './CTabs'
6+
import { CTabsContext } from './CTabsContext'
77
import { useForkedRef } from '../../hooks'
88
import { getTransitionDurationFromElement } from '../../utils'
99

@@ -36,7 +36,7 @@ export interface CTabPanelProps extends HTMLAttributes<HTMLDivElement> {
3636

3737
export const CTabPanel = forwardRef<HTMLDivElement, CTabPanelProps>(
3838
({ children, className, itemKey, onHide, onShow, transition = true, visible, ...rest }, ref) => {
39-
const { _activeItemKey, id } = useContext(TabsContext)
39+
const { _activeItemKey, id } = useContext(CTabsContext)
4040

4141
const tabPaneRef = useRef(null)
4242
const forkedRef = useForkedRef(ref, tabPaneRef)

packages/coreui-react/src/components/tabs/CTabs.tsx

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,86 @@
1-
import React, { createContext, forwardRef, HTMLAttributes, useEffect, useId, useState } from 'react'
1+
import React, { forwardRef, HTMLAttributes, useId, useState } from 'react'
22
import PropTypes from 'prop-types'
33
import classNames from 'classnames'
44

5+
import { CTabsContext } from './CTabsContext'
6+
57
export interface CTabsProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
68
/**
7-
* The active item key.
9+
* Controls the currently active tab.
10+
*
11+
* When provided, the component operates in a controlled mode.
12+
* You must handle tab switching manually by updating this prop.
13+
*
14+
* @example
15+
* const [activeTab, setActiveTab] = useState(0);
16+
* <CTabs activeItemKey={activeTab} onChange={setActiveTab} />
817
*/
9-
activeItemKey: number | string
18+
activeItemKey?: number | string
19+
1020
/**
1121
* A string of all className you want applied to the base component.
1222
*/
1323
className?: string
24+
1425
/**
15-
* The callback is fired when the active tab changes.
26+
* Sets the initially active tab when the component mounts.
27+
*
28+
* After initialization, the component manages active tab changes internally.
29+
*
30+
* Use `defaultActiveItemKey` for uncontrolled usage.
31+
*
32+
* @example
33+
* <CTabs defaultActiveItemKey={1} />
1634
*/
17-
onChange?: (value: number | string) => void
18-
}
35+
defaultActiveItemKey?: number | string
1936

20-
export interface TabsContextProps {
21-
_activeItemKey?: number | string
22-
setActiveItemKey: React.Dispatch<React.SetStateAction<number | string | undefined>>
23-
id?: string
37+
/**
38+
* Callback fired when the active tab changes.
39+
*
40+
* - In controlled mode (`activeItemKey` provided), you must update `activeItemKey` yourself based on the value received.
41+
* - In uncontrolled mode, this callback is called after internal state updates.
42+
*
43+
* @param value - The newly selected tab key.
44+
*
45+
* @example
46+
* <CTabs onChange={(key) => console.log('Tab changed to', key)} />
47+
*/
48+
onChange?: (value: number | string) => void
2449
}
2550

26-
export const TabsContext = createContext({} as TabsContextProps)
27-
2851
export const CTabs = forwardRef<HTMLDivElement, CTabsProps>(
29-
({ children, activeItemKey, className, onChange }, ref) => {
52+
({ children, activeItemKey, className, defaultActiveItemKey, onChange }, ref) => {
3053
const id = useId()
31-
const [_activeItemKey, setActiveItemKey] = useState(activeItemKey)
54+
const isControlled = activeItemKey !== undefined
55+
const [internalActiveItemKey, setInternalActiveItemKey] = useState<number | string | undefined>(
56+
() => (isControlled ? undefined : defaultActiveItemKey)
57+
)
58+
59+
const currentActiveItemKey = isControlled ? activeItemKey : internalActiveItemKey
60+
61+
const setActiveItemKey = (value: number | string) => {
62+
if (!isControlled) {
63+
setInternalActiveItemKey(value)
64+
}
3265

33-
useEffect(() => {
34-
_activeItemKey && onChange && onChange(_activeItemKey)
35-
}, [_activeItemKey])
66+
onChange?.(value)
67+
}
3668

3769
return (
38-
<TabsContext.Provider value={{ _activeItemKey, setActiveItemKey, id }}>
70+
<CTabsContext.Provider value={{ _activeItemKey: currentActiveItemKey, setActiveItemKey, id }}>
3971
<div className={classNames('tabs', className)} ref={ref}>
4072
{children}
4173
</div>
42-
</TabsContext.Provider>
74+
</CTabsContext.Provider>
4375
)
44-
},
76+
}
4577
)
4678

4779
CTabs.propTypes = {
48-
activeItemKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
80+
activeItemKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
4981
children: PropTypes.node,
5082
className: PropTypes.string,
83+
defaultActiveItemKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
5184
onChange: PropTypes.func,
5285
}
5386

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { createContext } from 'react'
2+
3+
export interface CTabsContextProps {
4+
_activeItemKey?: number | string
5+
setActiveItemKey: React.Dispatch<React.SetStateAction<number | string | undefined>>
6+
id?: string
7+
}
8+
9+
export const CTabsContext = createContext({} as CTabsContextProps)

packages/docs/content/api/CTabs.api.mdx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ import CTabs from '@coreui/react/src/components/tabs/CTabs'
2222
</tr>
2323
<tr>
2424
<td colSpan= 10000 "3">
25-
<p>The active item key.</p>
25+
<p>Controls the currently active tab.</p>
26+
<p>When provided, the component operates in a controlled mode.<br />
27+
You must handle tab switching manually by updating this prop.</p>
28+
<JSXDocs code={`const [activeTab, setActiveTab] = useState(0);
29+
<CTabs activeItemKey={activeTab} onChange={setActiveTab} />`} />
2630
</td>
2731
</tr>
2832
<tr id="ctabs-class-name">
@@ -35,14 +39,32 @@ import CTabs from '@coreui/react/src/components/tabs/CTabs'
3539
<p>A string of all className you want applied to the base component.</p>
3640
</td>
3741
</tr>
42+
<tr id="ctabs-default-active-item-key">
43+
<td className="text-primary fw-semibold">defaultActiveItemKey<a href="#ctabs-default-active-item-key" aria-label="CTabs defaultActiveItemKey permalink" className="anchor-link after">#</a></td>
44+
<td>-</td>
45+
<td><code>{`string`}</code>, <code>{`number`}</code></td>
46+
</tr>
47+
<tr>
48+
<td colSpan="3">
49+
<p>Sets the initially active tab when the component mounts.</p>
50+
<p>After initialization, the component manages active tab changes internally.</p>
51+
<p>Use <code>{`defaultActiveItemKey`}</code> for uncontrolled usage.</p>
52+
<JSXDocs code={`<CTabs defaultActiveItemKey={1} />`} />
53+
</td>
54+
</tr>
3855
<tr id="ctabs-on-change">
3956
<td className="text-primary fw-semibold">onChange<a href="#ctabs-on-change" aria-label="CTabs onChange permalink" className="anchor-link after">#</a></td>
4057
<td>-</td>
4158
<td><code>{`(value: string | number) => void`}</code></td>
4259
</tr>
4360
<tr>
4461
<td colSpan="3">
45-
<p>The callback is fired when the active tab changes.</p>
62+
<p>Callback fired when the active tab changes.</p>
63+
<ul>
64+
<li>In controlled mode (<code>{`activeItemKey`}</code> provided), you must update <code>{`activeItemKey`}</code> yourself based on the value received.</li>
65+
<li>In uncontrolled mode, this callback is called after internal state updates.</li>
66+
</ul>
67+
<JSXDocs code={`<CTabs onChange={(key) => console.log('Tab changed to', key)} />`} />
4668
</td>
4769
</tr>
4870
</tbody>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React, { useState } from 'react'
2+
import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
3+
4+
export const TabsControlledExample = () => {
5+
const [activeTab, setActiveTab] = useState('profile')
6+
7+
return (
8+
<CTabs activeItemKey={activeTab} onChange={setActiveTab}>
9+
<CTabList variant="tabs">
10+
<CTab itemKey="home">Home</CTab>
11+
<CTab itemKey="profile">Profile</CTab>
12+
<CTab itemKey="contact">Contact</CTab>
13+
<CTab disabled itemKey="disabled">
14+
Disabled
15+
</CTab>
16+
</CTabList>
17+
<CTabContent>
18+
<CTabPanel className="p-3" itemKey="home">
19+
Home tab content
20+
</CTabPanel>
21+
<CTabPanel className="p-3" itemKey="profile">
22+
Profile tab content
23+
</CTabPanel>
24+
<CTabPanel className="p-3" itemKey="contact">
25+
Contact tab content
26+
</CTabPanel>
27+
<CTabPanel className="p-3" itemKey="disabled">
28+
Disabled tab content
29+
</CTabPanel>
30+
</CTabContent>
31+
</CTabs>
32+
)
33+
}
Lines changed: 33 additions & 0 deletions
< C2EE /tr>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React, { useState } from 'react'
2+
import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
3+
4+
export const TabsControlledExample = () => {
5+
const [activeTab, setActiveTab] = useState<number | string>('profile')
6+
7+
return (
8+
<CTabs activeItemKey={activeTab} onChange={setActiveTab}>
9+
<CTabList variant="tabs">
10+
<CTab itemKey="home">Home</CTab>
11+
<CTab itemKey="profile">Profile</CTab>
12+
<CTab itemKey="contact">Contact</CTab>
13+
<CTab disabled itemKey="disabled">
14+
Disabled
15+
</CTab>
16+
</CTabList>
17+
<CTabContent>
18+
<CTabPanel className="p-3" itemKey="home">
19+
Home tab content
20+
</CTabPanel>
21+
<CTabPanel className="p-3" itemKey="profile">
22+
Profile tab content
23+
</CTabPanel>
24+
<CTabPanel className="p-3" itemKey="contact">
25+
Contact tab content
26+
</CTabPanel>
27+
<CTabPanel className="p-3" itemKey="disabled">
28+
Disabled tab content
29+
</CTabPanel>
30+
</CTabContent>
31+
</CTabs>
32+
)
33+
}

packages/docs/content/components/tabs/examples/TabsExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
33

44
export const TabsExample = () => {
55
return (
6-
<CTabs activeItemKey="profile">
6+
<CTabs defaultActiveItemKey="profile">
77
<CTabList variant="tabs">
88
<CTab itemKey="home">Home</CTab>
99
<CTab itemKey="profile">Profile</CTab>

packages/docs/content/components/tabs/examples/TabsPillsExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
33

44
export const TabsPillsExample = () => {
55
return (
6-
<CTabs activeItemKey={2}>
6+
<CTabs defaultActiveItemKey={2}>
77
<CTabList variant="pills">
88
<CTab aria-controls="home-tab-pane" itemKey={1}>Home</CTab>
99
<CTab aria-controls="profile-tab-pane" itemKey={2}>Profile</CTab>

packages/docs/content/components/tabs/examples/TabsUnderlineBorderExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
33

44
export const TabsUnderlineBorderExample = () => {
55
return (
6-
<CTabs activeItemKey={2}>
6+
<CTabs defaultActiveItemKey={2}>
77
<CTabList variant="underline-border">
88
<CTab aria-controls="home-tab-pane" itemKey={1}>Home</CTab>
99
<CTab aria-controls="profile-tab-pane" itemKey={2}>Profile</CTab>

packages/docs/content/components/tabs/examples/TabsUnderlineExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
33

44
export const TabsUnderlineExample = () => {
55
return (
6-
<CTabs activeItemKey={2}>
6+
<CTabs defaultActiveItemKey={2}>
77
<CTabList variant="underline">
88
<CTab aria-controls="home-tab-pane" itemKey={1}>Home</CTab>
99
<CTab aria-controls="profile-tab-pane" itemKey={2}>Profile</CTab>

packages/docs/content/components/tabs/examples/TabsUnstyledExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
33

44
export const TabsUnstyledExample = () => {
55
return (
6-
<CTabs activeItemKey="profile">
6+
<CTabs defaultActiveItemKey="profile">
77
<CTabList>
88
<CTab itemKey="home">Home</CTab>
99
<CTab itemKey="profile">Profile</CTab>

packages/docs/content/components/tabs/examples/TabsUnstyledFillAndJustify2Example.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
33

44
export const TabsUnstyledFillAndJustify2Example = () => {
55
return (
6-
<CTabs activeItemKey={2}>
6+
<CTabs defaultActiveItemKey={2}>
77
<CTabList variant="tabs" layout="justified">
88
<CTab aria-controls="home-tab-pane" itemKey={1}>Home</CTab>
99
<CTab aria-controls="profile-tab-pane" itemKey={2}>Profile</CTab>

packages/docs/content/components/tabs/examples/TabsUnstyledFillAndJustifyExample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CTab, CTabContent, CTabList, CTabPanel, CTabs } from '@coreui/react'
33

44
export const TabsUnstyledFillAndJustifyExample = () => {
55
return (
6-
<CTabs activeItemKey={2}>
6+
<CTabs defaultActiveItemKey={2}>
77
<CTabList variant="tabs" layout="fill">
88
<CTab aria-controls="home-tab-pane" itemKey={1}>Home</CTab>
99
<CTab aria-controls="profile-tab-pane" itemKey={2}>Profile tab with longer content</CTab>

packages/docs/content/components/tabs/index.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,26 @@ For equal-width elements, use `layout="justified"`. All horizontal space will be
5050

5151
<ExampleSnippet component="TabsUnstyledFillAndJustify2Example" componentName="React Spinner" />
5252

53+
Sure! Here's a polished, production-ready **documentation section** (Markdown-style) explaining the **controlled usage** of the `<CTabs>` component, with a clear example:
54+
55+
---
56+
57+
## Controlled Tabs
58+
59+
Use the `activeItemKey` prop to control which tab is currently active. In this mode, the parent component is responsible for managing the active state and responding to user interactions via the `onChange` callback.
60+
61+
This is useful when you need to synchronize the tab state with your application logic, such as routing or complex UI state management.
62+
63+
**Key Points**
64+
65+
- `activeItemKey` sets the currently active tab.
66+
- `onChange` receives the new `itemKey` when a tab is clicked.
67+
- You must manually update `activeItemKey` in your state based on `onChange`.
68+
69+
> 💡 If you prefer the tabs to manage their own state, use `defaultActiveItemKey` instead.
70+
71+
<ExampleSnippet component="TabsControlledExample" componentName="React Spinner" />
72+
5373

5474
## Accessibility
5575

0 commit comments

Comments
 (0)
0