diff --git a/CHANGELOG.md b/CHANGELOG.md index f680292b..0025d153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,8 @@ - [Autocomplete - adding hiddenErrorText as mandatory](https://github.com/Capgemini/dcx-react-library/issues/638) +- [Tabs - manage state outside component (from props) as mandatory](https://github.com/Capgemini/dcx-react-library/issues/705) + **Upgrade notes** - If you upgrading from a previous release and you use the Autocomplete component it will requires the addition of a new property called **hiddenErrorText**. diff --git a/migration.md b/migration.md new file mode 100644 index 00000000..ef0e2d9c --- /dev/null +++ b/migration.md @@ -0,0 +1,110 @@ +# Migration Guide: Tabs Component + +This document explains how to migrate your code from the old implementation of the `TabGroup`/`Tabs` component to the new version, which now manages active state internally and provides a simpler, more declarative API. + +--- + +## Main Changes + +- **Removal of imperative ref-based state control:** + You no longer need (or are able) to manipulate the active tab using refs and imperative methods like `updateActiveTab`. +- **Active tab state is managed internally:** + The active tab is now handled inside the component. You can still control it externally using the `activeKey` prop if needed. +- **Simpler, more declarative API:** + Interactions are handled via standard React props and events (`onSelect`), not via refs. + +--- + +## Before (Old Implementation) + +```jsx +const ref = useRef(); + + + + Content 1 + + + Content 2 + +; + +// Change the active tab imperatively: +ref.current.updateActiveTab('tab-2'); +``` + +--- + +## After (New Implementation) + +### 1. **Controlled by Parent (optional):** + +If you want to control the active tab from the parent, use the `activeKey` prop and the `onSelect` event: + +```jsx +const [activeTab, setActiveTab] = useState('tab-1'); + + + + Content 1 + + + Content 2 + +; +``` + +### 2. **Uncontrolled (Internal State):** + +If you do not pass `activeKey`, the component will manage the active tab internally: + +```jsx + + + Content 1 + + + Content 2 + + +``` + +--- + +## What do you need to change in your code? + +1. **Remove any use of refs to manipulate the active tab.** +2. **If you need to change the active tab from the parent, use the controlled pattern with `activeKey` and `onSelect`.** +3. **Update your tests:** + - Do not use refs or imperative methods. + - Simulate user interactions (clicks, keyboard) and check the visual state. + +--- + +## Example: Migrating a Test + +**Before:** + +```js +// Using ref and updateActiveTab +ref.current.updateActiveTab('tab-2'); +``` + +**After:** + +```js +// Simulate a click on the desired tab +fireEvent.click(screen.getByText('Tab 2')); +``` + +--- + +## Summary + +- Remove the use of refs and imperative methods. +- Use props and events to control the active tab if needed. +- Enjoy a simpler, more declarative API. + +--- + +If you have questions, check the component documentation or open an issue. diff --git a/src/tabGroup/TabGroup.tsx b/src/tabGroup/TabGroup.tsx index de927132..67604b3b 100644 --- a/src/tabGroup/TabGroup.tsx +++ b/src/tabGroup/TabGroup.tsx @@ -1,12 +1,4 @@ -import React, { - createContext, - forwardRef, - useContext, - useEffect, - useImperativeHandle, - useRef, - useState, -} from 'react'; +import React, { createContext, useContext, useEffect, useRef } from 'react'; import { classNames, Roles, useHydrated } from '../common'; export type TabGroupProps = { @@ -72,7 +64,7 @@ type TabContextProps = { /** * Tab next tab */ - nextTab?: string + nextTab?: string; /** * Tab Context update selected tab */ @@ -81,129 +73,103 @@ type TabContextProps = { export const TabContext = createContext(undefined); -export const TabGroup = forwardRef( - ( - { - children, - id, - ariaLabelTabList, - activeTabClassName, - disabledClassName, - activeKey, - className, - containerClassName, - contentClassName, - tabClassName, - tabLinkClassName, - onSelect, - }: TabGroupProps, - ref: any - ) => { - const hasUniqueEventKeys: (children: JSX.Element[]) => boolean = ( - children: JSX.Element[] - ) => - children.length === - new Set(children.map((child: JSX.Element) => child.props.eventKey)).size; - - if (!hasUniqueEventKeys(children)) { - throw new Error('Tab event keys must be unique'); - } - - const initialMount = useRef(true); - - const defaultActiveTabKey = activeKey || children[0].props.eventKey; - const defaultPreviousTabKey = children[children.findIndex((child: JSX.Element) => child.props.eventKey === defaultActiveTabKey) - 1]?.props.eventKey; - const defaultNextTabKey = children[children.findIndex((child: JSX.Element) => child.props.eventKey === defaultActiveTabKey) + 1]?.props.eventKey; - const [activeTab, setActiveTab] = useState(defaultActiveTabKey); - const [previousTab, setPreviousTab] = useState(defaultPreviousTabKey); - const [nextTab, setNextTab] = useState(defaultNextTabKey); - - const onClickHandler: (id: string) => void = (id: string) => - updateActiveTab(id); +export const TabGroup = ({ + children, + id, + ariaLabelTabList, + activeTabClassName, + disabledClassName, + activeKey, + className, + containerClassName, + contentClassName, + tabClassName, + tabLinkClassName, + onSelect, +}: TabGroupProps) => { + const hasUniqueEventKeys: (children: JSX.Element[]) => boolean = ( + children: JSX.Element[] + ) => + children.length === + new Set(children.map((child: JSX.Element) => child.props.eventKey)).size; + + if (!hasUniqueEventKeys(children)) { + throw new Error('Tab event keys must be unique'); + } - const updateActiveTab: (id: string) => boolean = (id: string) => { - const index = children.findIndex((child: JSX.Element) => child.props.eventKey === id); - if (index < 0) { - return false; - } + const initialMount = useRef(true); - if (!children[index].props.disabled) { - setActiveTab(id); - } - - setPreviousTab(children[index - 1]?.props.eventKey); - setNextTab(children[index + 1]?.props.eventKey); - return true; - }; + const currentActiveKey = activeKey || children[0].props.eventKey; - useImperativeHandle(ref, () => ({ - updateActiveTab, - })); + const onClickHandler: (id: string) => void = (id: string) => { + onSelect && onSelect(id); + }; - useEffect(() => { - if (!initialMount.current) onSelect && onSelect(activeTab); - else initialMount.current = false; - }, [activeTab]); + useEffect(() => { + if (!initialMount.current) onSelect && onSelect(currentActiveKey); + else initialMount.current = false; + }, [currentActiveKey]); - const activeTabElement = children.find( - (child: JSX.Element) => activeTab === child.props.eventKey - ); + const activeTabElement = children.find( + (child: JSX.Element) => currentActiveKey === child.props.eventKey + ); - const hydrated = useHydrated(); + const hydrated = useHydrated(); - const tabPanels = hydrated ? [activeTabElement] : children; + const tabPanels = hydrated ? [activeTabElement] : children; - return ( -
-
    +
      + - - {children.map((child: JSX.Element, index: number) => { - const classes: string = classNames([ - tabClassName, - child.props.className, - ]); - - return ( - - ); - })} - -
    - {tabPanels.map((tabPanel: JSX.Element | undefined, index: number) => ( - - {tabPanel && ( -
    { + const classes: string = classNames([ + tabClassName, + child.props.className, + ]); + + return ( + - {tabPanel.props.children} -
    - )} -
    - ))} -
- ); - } -); + {...child.props} + activeTabClassName={activeTabClassName} + ariaControls={child.props.eventKey} + disabledClassName={disabledClassName} + className={classes} + linkClassName={tabLinkClassName} + /> + ); + })} + + + {tabPanels.map((tabPanel: JSX.Element | undefined, index: number) => ( + + {tabPanel && ( +
+ {tabPanel.props.children} +
+ )} +
+ ))} + + ); +}; export const useTabGroup = () => { const context = useContext(TabContext); diff --git a/stories/TabGroup/ClassBased.stories.js b/stories/TabGroup/ClassBased.stories.js index 36388835..2847b8ef 100644 --- a/stories/TabGroup/ClassBased.stories.js +++ b/stories/TabGroup/ClassBased.stories.js @@ -1,6 +1,6 @@ import { TabGroup, Tab } from '../../src/tabGroup'; import { Button } from '../../src/button'; -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import './style.css'; /** * In this section we're using the TabGroup component providing the **GovUk style** passing the relative `className. @@ -17,114 +17,215 @@ export default { tags: ['autodocs'], }; -export const Basic = { - name: 'Basic', - render: function () { - return ( +const BasicTabs = () => { + const [activeTab, setActiveTab] = useState('tab-1'); + + return ( + + +

Past day

+ This the content for Past day +
+ +

Past week

+ This the content for Past week +
+ +

Past month

+ This the content for Past month +
+ +

Past year

+ This the content for Past year +
+
+ ); +}; + +const DisabledTabs = () => { + const [activeTab, setActiveTab] = useState('tab-1'); + + return ( + + + This the content for tab 1 + + + This the content for tab 2 + + + This the content for tab 3 + + + ); +}; + +const PreselectedTabs = () => { + const [activeTab, setActiveTab] = useState('tab-2'); + + return ( + + + This the content for tab 1 + + + This the content for tab 2 + + + This the content for tab 3 + + + ); +}; + +const EventTabs = () => { + const [activeTab, setActiveTab] = useState('tab-1'); + const handleChange = (eventKey) => { + setActiveTab(eventKey); + alert(`${eventKey} selected`); + }; + + return ( + + + This the content for tab 1 + + + This the content for tab 2 + + + This the content for tab 3 + + + ); +}; + +const ProgrammaticTabs = () => { + const [activeTab, setActiveTab] = useState('tab-pane-1-id'); + const tabRef = useRef(); + + return ( + <> - -

Past day

- This the content for Past day + + This is content for tab 1 + + + This is content for tab 2 - -

Past week

- This the content for Past week + + This is content for tab 3 - -

Past month

- This the content for Past month + + This is content for tab 4 - -

Past year

- This the content for Past year + + This is content for tab 5
- ); - }, +
+