Skip to content
34 changes: 32 additions & 2 deletions packages/react-core/src/components/Tabs/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { TabButton } from './TabButton';
import { TabsContext } from './TabsContext';
import { css } from '@patternfly/react-styles';
import { Tooltip } from '../Tooltip';
import { Button } from '../Button';
import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon';

export interface TabProps extends Omit<React.HTMLProps<HTMLAnchorElement | HTMLButtonElement>, 'title'>, OUIAProps {
/** content rendered inside the Tab content area. */
Expand Down Expand Up @@ -33,6 +35,10 @@ export interface TabProps extends Omit<React.HTMLProps<HTMLAnchorElement | HTMLB
innerRef?: React.Ref<any>;
/** Optional Tooltip rendered to a Tab. Should be <Tooltip> with appropriate props for proper rendering. */
tooltip?: React.ReactElement<any>;
/** @beta Aria-label for the close button added by passing the onClose property to Tabs. */
closeButtonAriaLabel?: string;
/** @beta Flag indicating the close button should be disabled */
isCloseDisabled?: boolean;
}

const TabBase: React.FunctionComponent<TabProps> = ({
Expand All @@ -49,6 +55,8 @@ const TabBase: React.FunctionComponent<TabProps> = ({
href,
innerRef,
tooltip,
closeButtonAriaLabel,
isCloseDisabled = false,
...props
}: TabProps) => {
const preventedEvents = inoperableEvents.reduce(
Expand All @@ -60,7 +68,9 @@ const TabBase: React.FunctionComponent<TabProps> = ({
}),
{}
);
const { mountOnEnter, localActiveKey, unmountOnExit, uniqueId, handleTabClick } = React.useContext(TabsContext);
const { mountOnEnter, localActiveKey, unmountOnExit, uniqueId, handleTabClick, handleTabClose } = React.useContext(
TabsContext
);
let ariaControls = tabContentId ? `${tabContentId}` : `pf-tab-section-${eventKey}-${childId || uniqueId}`;
if ((mountOnEnter || unmountOnExit) && eventKey !== localActiveKey) {
ariaControls = undefined;
Expand Down Expand Up @@ -102,10 +112,30 @@ const TabBase: React.FunctionComponent<TabProps> = ({

return (
<li
className={css(styles.tabsItem, eventKey === localActiveKey && styles.modifiers.current, childClassName)}
className={css(
styles.tabsItem,
eventKey === localActiveKey && styles.modifiers.current,
handleTabClose && styles.modifiers.action,
handleTabClose && (isDisabled || isAriaDisabled) && styles.modifiers.disabled,
childClassName
)}
role="presentation"
>
{tooltip ? <Tooltip {...tooltip.props}>{tabButton}</Tooltip> : tabButton}
{handleTabClose !== undefined && (
<span className={css(styles.tabsItemClose)}>
<Button
variant="plain"
aria-label={closeButtonAriaLabel || 'Close tab'}
onClick={(event: any) => handleTabClose(event, eventKey, tabContentRef)}
isDisabled={isCloseDisabled}
>
<span className={css(styles.tabsItemCloseIcon)}>
<TimesIcon />
</span>
</Button>
</span>
)}
</li>
);
};
Expand Down
30 changes: 28 additions & 2 deletions packages/react-core/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { css } from '@patternfly/react-styles';
import { PickOptional } from '../../helpers/typeUtils';
import AngleLeftIcon from '@patternfly/react-icons/dist/esm/icons/angle-left-icon';
import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon';
import PlusIcon from '@patternfly/react-icons/dist/esm/icons/plus-icon';
import { getUniqueId, isElementInView, formatBreakpointMods } from '../../helpers/util';
import { TabContent } from './TabContent';
import { TabProps } from './Tab';
Expand All @@ -31,6 +32,12 @@ export interface TabsProps extends Omit<React.HTMLProps<HTMLElement | HTMLDivEle
defaultActiveKey?: number | string;
/** Callback to handle tab selection */
onSelect?: (event: React.MouseEvent<HTMLElement, MouseEvent>, eventKey: number | string) => void;
/** @beta Callback to handle tab closing */
onClose?: (event: React.MouseEvent<HTMLElement, MouseEvent>, eventKey: number | string) => void;
/** @beta Callback for the add button. Passing this property inserts the add button */
onAdd?: () => void;
/** @beta Aria-label for the add button */
addButtonAriaLabel?: string;
/** Uniquely identifies the tabs */
id?: string;
/** Enables the filled tab list layout */
Expand Down Expand Up @@ -273,13 +280,21 @@ export class Tabs extends React.Component<TabsProps, TabsState> {
}

componentDidUpdate(prevProps: TabsProps) {
const { activeKey, mountOnEnter } = this.props;
const { activeKey, mountOnEnter, children } = this.props;
const { shownKeys } = this.state;
if (prevProps.activeKey !== activeKey && mountOnEnter && shownKeys.indexOf(activeKey) < 0) {
this.setState({
shownKeys: shownKeys.concat(activeKey)
});
}

if (
prevProps.children &&
children &&
React.Children.toArray(prevProps.children).length !== React.Children.toArray(children).length
) {
this.handleScrollButtons();
}
}

render() {
Expand Down Expand Up @@ -311,7 +326,10 @@ export class Tabs extends React.Component<TabsProps, TabsState> {
defaultIsExpanded,
toggleText,
toggleAriaLabel,
addButtonAriaLabel,
onToggle,
onClose,
onAdd,
...props
} = this.props;
const {
Expand Down Expand Up @@ -348,7 +366,8 @@ export class Tabs extends React.Component<TabsProps, TabsState> {
unmountOnExit,
localActiveKey,
uniqueId,
handleTabClick: (...args) => this.handleTabClick(...args)
handleTabClick: (...args) => this.handleTabClick(...args),
handleTabClose: onClose
}}
>
<Component
Expand Down Expand Up @@ -421,6 +440,13 @@ export class Tabs extends React.Component<TabsProps, TabsState> {
>
<AngleRightIcon />
</button>
{onAdd !== undefined && (
<span className={css(styles.tabsAdd)}>
<Button variant="plain" aria-label={addButtonAriaLabel || 'Add tab'} onClick={onAdd}>
<PlusIcon />
</Button>
</span>
)}
</Component>
{filteredChildren
.filter(
Expand Down
8 changes: 7 additions & 1 deletion packages/react-core/src/components/Tabs/TabsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface TabsContextProps {
eventKey: number | string,
tabContentRef: React.RefObject<any>
) => void;
handleTabClose: (
event: React.MouseEvent<HTMLElement, MouseEvent>,
eventKey: number | string,
tabContentRef?: React.RefObject<any>
) => void;
}

export const TabsContext = React.createContext<TabsContextProps>({
Expand All @@ -19,7 +24,8 @@ export const TabsContext = React.createContext<TabsContextProps>({
unmountOnExit: false,
localActiveKey: '',
uniqueId: '',
handleTabClick: () => null
handleTabClick: () => null,
handleTabClose: undefined
});

export const TabsContextProvider = TabsContext.Provider;
Expand Down
22 changes: 22 additions & 0 deletions packages/react-core/src/components/Tabs/__tests__/Tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ test('should render simple tabs', () => {
expect(asFragment()).toMatchSnapshot();
});

test('should render closeable tabs', () => {
const view = render(
<Tabs onClose={jest.fn()}>
<Tab eventKey={0} title={<TabTitleText>"Tab item 1"</TabTitleText>} closeButtonAriaLabel="close-label">
Tab 1 section
</Tab>
</Tabs>
);
expect(screen.getByLabelText('close-label')).toBeTruthy();
});

test('should render add button', () => {
const view = render(
<Tabs onAdd={jest.fn()} addButtonAriaLabel="add-label">
<Tab eventKey={0} title={<TabTitleText>"Tab item 1"</TabTitleText>} closeButtonAriaLabel="close-label">
Tab 1 section
</Tab>
</Tabs>
);
expect(screen.getByLabelText('add-label')).toBeTruthy();
});

test('should render uncontrolled tabs', () => {
const { asFragment } = render(
<Tabs defaultActiveKey={0}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ exports[`should render accessible tabs 1`] = `
<nav
aria-label="accessible Tabs example"
class="pf-c-tabs"
data-ouia-component-id="OUIA-Generated-Tabs-7"
data-ouia-component-id="OUIA-Generated-Tabs-9"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="accessibleTabs"
Expand Down Expand Up @@ -161,7 +161,7 @@ exports[`should render box tabs 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs pf-m-box"
data-ouia-component-id="OUIA-Generated-Tabs-6"
data-ouia-component-id="OUIA-Generated-Tabs-8"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="boxTabs"
Expand Down Expand Up @@ -355,7 +355,7 @@ exports[`should render box tabs of light variant 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs pf-m-box pf-m-color-scheme--light-300"
data-ouia-component-id="OUIA-Generated-Tabs-13"
data-ouia-component-id="OUIA-Generated-Tabs-15"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="boxLightVariantTabs"
Expand Down Expand Up @@ -508,7 +508,7 @@ exports[`should render expandable vertical tabs 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs pf-m-vertical pf-m-expandable"
data-ouia-component-id="OUIA-Generated-Tabs-4"
data-ouia-component-id="OUIA-Generated-Tabs-6"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="verticalTabs"
Expand All @@ -523,7 +523,7 @@ exports[`should render expandable vertical tabs 1`] = `
aria-disabled="false"
aria-labelledby="pf-random-id-0-text pf-random-id-0-button"
class="pf-c-button pf-m-plain"
data-ouia-component-id="OUIA-Generated-Button-plain-1"
data-ouia-component-id="OUIA-Generated-Button-plain-3"
data-ouia-component-type="PF4/Button"
data-ouia-safe="true"
id="pf-random-id-0-button"
Expand Down Expand Up @@ -745,7 +745,7 @@ exports[`should render filled tabs 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs pf-m-fill"
data-ouia-component-id="OUIA-Generated-Tabs-8"
data-ouia-component-id="OUIA-Generated-Tabs-10"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="filledTabs"
Expand Down Expand Up @@ -898,7 +898,7 @@ exports[`should render secondary tabs 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs"
data-ouia-component-id="OUIA-Generated-Tabs-9"
data-ouia-component-id="OUIA-Generated-Tabs-11"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="primarieTabs"
Expand Down Expand Up @@ -1020,7 +1020,7 @@ exports[`should render secondary tabs 1`] = `
>
<div
class="pf-c-tabs pf-m-secondary"
data-ouia-component-id="OUIA-Generated-Tabs-10"
data-ouia-component-id="OUIA-Generated-Tabs-12"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="secondaryTabs"
Expand Down Expand Up @@ -1393,7 +1393,7 @@ exports[`should render tabs with eventKey Strings 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs"
data-ouia-component-id="OUIA-Generated-Tabs-11"
data-ouia-component-id="OUIA-Generated-Tabs-13"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="eventKeyTabs"
Expand Down Expand Up @@ -1547,7 +1547,7 @@ exports[`should render tabs with no bottom border 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs pf-m-no-border-bottom"
data-ouia-component-id="OUIA-Generated-Tabs-14"
data-ouia-component-id="OUIA-Generated-Tabs-16"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="noBottomBorderTabs"
Expand Down Expand Up @@ -1700,7 +1700,7 @@ exports[`should render tabs with separate content 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs"
data-ouia-component-id="OUIA-Generated-Tabs-12"
data-ouia-component-id="OUIA-Generated-Tabs-14"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="separateTabs"
Expand Down Expand Up @@ -1863,7 +1863,7 @@ exports[`should render uncontrolled tabs 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs"
data-ouia-component-id="OUIA-Generated-Tabs-2"
data-ouia-component-id="OUIA-Generated-Tabs-4"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
>
Expand Down Expand Up @@ -2056,7 +2056,7 @@ exports[`should render vertical tabs 1`] = `
<DocumentFragment>
<div
class="pf-c-tabs pf-m-vertical"
data-ouia-component-id="OUIA-Generated-Tabs-3"
data-ouia-component-id="OUIA-Generated-Tabs-5"
data-ouia-component-type="PF4/Tabs"
data-ouia-safe="true"
id="verticalTabs"
Expand Down
7 changes: 7 additions & 0 deletions packages/react-core/src/components/Tabs/examples/Tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -1617,3 +1617,10 @@ class ToggledSeparateContent extends React.Component {
}
}
```

### Dynamic

To enable closeable tabs, pass the `onClose` property to `Tabs`, and to enable the add button, pass the `onAdd` property to `Tabs`. Aria labels may be controlled manually by passing `closeButtonAriaLabel` to `Tab` and `addButtonAriaLabel` to `Tabs`.

```ts file="./TabsDynamic.tsx" isBeta
```
64 changes: 64 additions & 0 deletions packages/react-core/src/components/Tabs/examples/TabsDynamic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React from 'react';
import { Tabs, Tab, TabTitleText } from '@patternfly/react-core';

export const TabsDynamic: React.FunctionComponent = () => {
const [activeTabKey, setActiveTabKey] = React.useState<number>(0);
const [tabs, setTabs] = React.useState<string[]>(['Terminal 1', 'Terminal 2', 'Terminal 3']);
const [newTabNumber, setNewTabNumber] = React.useState<number>(4);
const tabComponentRef = React.useRef<any>();
const firstMount = React.useRef(true);

const onClose = (event: any, tabIndex: string | number) => {
const tabIndexNum = tabIndex as number;
let nextTabIndex = activeTabKey;
if (tabIndexNum < activeTabKey) {
// if a preceding tab is closing, keep focus on the new index of the current tab
nextTabIndex = activeTabKey - 1 > 0 ? activeTabKey - 1 : 0;
} else if (activeTabKey === tabs.length - 1) {
// if the closing tab is the last tab, focus the preceding tab
nextTabIndex = tabs.length - 2 > 0 ? tabs.length - 2 : 0;
}
setActiveTabKey(nextTabIndex);
setTabs(tabs.filter((tab, index) => index !== tabIndex));
};

const onAdd = () => {
setTabs([...tabs, `Terminal ${newTabNumber}`]);
setActiveTabKey(tabs.length);
setNewTabNumber(newTabNumber + 1);
};

React.useEffect(() => {
if (firstMount.current) {
firstMount.current = false;
return;
} else {
const first = tabComponentRef.current.tabList.current.childNodes[activeTabKey];
first && first.firstChild.focus();
}
}, [tabs]);

return (
<Tabs
activeKey={activeTabKey}
onSelect={(event: any, tabIndex: string | number) => setActiveTabKey(tabIndex as number)}
onClose={onClose}
onAdd={onAdd}
aria-label="Tabs in the addable/closeable example"
addButtonAriaLabel="Add new tab"
ref={tabComponentRef}
>
{tabs.map((tab, index) => (
<Tab
key={index}
eventKey={index}
title={<TabTitleText>{tab}</TabTitleText>}
closeButtonAriaLabel={`Close ${tab}`}
isCloseDisabled={tabs.length === 1}
>
{tab}
</Tab>
))}
</Tabs>
);
};