Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 18 additions & 5 deletions packages/react-core/src/components/Tabs/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export interface TabProps
children?: React.ReactNode;
/** additional classes added to the Tab */
className?: string;
/** URL associated with the Tab. A Tab with an href will render as an <a> instead of a <button>. A Tab inside a <Tabs component="nav"> should have an href. */
/** URL associated with the Tab. A Tab with an href will render as an <a> instead of a <button> unless component is supplied. */
href?: string;
/** Sets the element or custom component used for this Tab's button or link. Defaults to an anchor when href is supplied, otherwise a button. Does not change the Tabs container's outer wrapper. Custom components must forward the ref and supplied props to the underlying element. For custom components that render a button, pass type="button" or set it inside the component to prevent form submission. */
component?: React.ElementType<any> | React.ComponentType<any>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this, ElementType should contain ComponentType, but I am also fine with this as-is. https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/react/index.d.ts#L109

/** Content rendered in the tab title. Should be <TabTitleText> and/or <TabTitleIcon> for proper styling. */
title: React.ReactNode;
/** uniquely identifies the tab */
Expand Down Expand Up @@ -58,11 +60,13 @@ const TabBase: React.FunctionComponent<TabProps> = ({
isAriaDisabled,
inoperableEvents = ['onClick', 'onKeyPress'],
href,
component = href ? 'a' : 'button',
innerRef,
tooltip,
closeButtonAriaLabel,
isCloseDisabled = false,
actions,
onClick,
...props
}: TabProps) => {
const preventedEvents = inoperableEvents.reduce(
Expand All @@ -80,7 +84,8 @@ const TabBase: React.FunctionComponent<TabProps> = ({
if ((mountOnEnter || unmountOnExit) && eventKey !== localActiveKey) {
ariaControls = undefined;
}
const isButtonElement = Boolean(!href);
const isButtonElement = component === 'button';
const isCustomComponent = component !== 'a' && !isButtonElement;
const getDefaultTabIdx = () => {
if (isDisabled) {
return isButtonElement ? null : -1;
Expand All @@ -94,22 +99,30 @@ const TabBase: React.FunctionComponent<TabProps> = ({
parentInnerRef={innerRef}
className={css(
styles.tabsLink,
isDisabled && href && styles.modifiers.disabled,
isDisabled && !isButtonElement && styles.modifiers.disabled,
isAriaDisabled && styles.modifiers.ariaDisabled
)}
disabled={isButtonElement ? isDisabled : null}
aria-disabled={isDisabled || isAriaDisabled}
tabIndex={getDefaultTabIdx()}
onClick={(event: any) => handleTabClick(event, eventKey, tabContentRef)}
{...(isAriaDisabled ? preventedEvents : null)}
id={`pf-tab-${eventKey}-${childId || uniqueId}`}
aria-controls={ariaControls}
tabContentRef={tabContentRef}
ouiaId={childOuiaId}
href={href}
component={component}
role="tab"
aria-selected={eventKey === localActiveKey}
{...props}
onClick={(event: any) => {
if (isDisabled) {
event.preventDefault();
return;
}
handleTabClick(event, eventKey, tabContentRef, isCustomComponent);
onClick?.(event);
}}
{...(isAriaDisabled || (isDisabled && !isButtonElement) ? preventedEvents : null)}
>
{title}
</TabButton>
Expand Down
9 changes: 6 additions & 3 deletions packages/react-core/src/components/Tabs/TabButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ export interface TabButtonProps extends Omit<React.HTMLProps<HTMLAnchorElement |
children?: React.ReactNode;
/** additional classes added to the Tab */
className?: string;
/** URL associated with the Tab. A Tab with an href will render as an <a> instead of a <button>. A Tab inside a <Tabs component="nav"> should have an href. */
/** URL associated with the Tab. A Tab with an href will render as an <a> instead of a <button> unless component is supplied. */
href?: string;
/** Sets the element or custom component used for the tab's button or link. Defaults to an anchor when href is supplied, otherwise a button. For custom components that render a button, pass type="button" or set it inside the component to prevent form submission. */
component?: React.ElementType<any> | React.ComponentType<any>;
/** child reference for case in which a TabContent section is defined outside of a Tabs component */
tabContentRef?: React.Ref<any>;
/** Parents' innerRef passed down for properly displaying Tooltips */
Expand All @@ -20,13 +22,14 @@ export const TabButton: React.FunctionComponent<TabButtonProps> = ({
ouiaId,
parentInnerRef,
ouiaSafe,
component,
...props
}: TabButtonProps) => {
const Component = (props.href ? 'a' : 'button') as any;
const Component = (component || (props.href ? 'a' : 'button')) as any;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we ever reach the default now since Tab sets a component. I'm totally ok with leaving this if we think people use TabButton separately. It looks exported.


return (
<Component
{...(!props.href && { type: 'button' })}
{...(Component === 'button' && { type: 'button' })}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
ref={parentInnerRef}
{...getOUIAProps(TabButton.displayName, ouiaId, ouiaSafe)}
{...props}
Expand Down
11 changes: 8 additions & 3 deletions packages/react-core/src/components/Tabs/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export interface TabsProps
backScrollAriaLabel?: string;
/** Aria-label for the forward scroll button */
forwardScrollAriaLabel?: string;
/** Determines what tag is used around the tabs. Use "nav" to define the tabs inside a navigation region */
/** Determines the outer wrapper element for the Tabs container. Use "nav" to define a navigation region. To customize an individual tab's button or link, use the component property of Tab instead. */
component?: 'div' | 'nav';
/** Provides an accessible label for the tabs. Labels should be unique for each set of tabs that are present on a page. When component is set to nav, this prop should be defined to differentiate the tabs from other navigation regions on the page. */
'aria-label'?: string;
Expand Down Expand Up @@ -242,12 +242,17 @@ class Tabs extends Component<TabsProps, TabsState> {
handleTabClick(
event: React.MouseEvent<HTMLElement, MouseEvent>,
eventKey: number | string,
tabContentRef: React.RefObject<any>
tabContentRef: React.RefObject<any>,
isCustomComponent = false
) {
const { shownKeys, initialActiveKey } = this.state;
const { onSelect, defaultActiveKey, component, isNav } = this.props;

if (event.currentTarget instanceof HTMLAnchorElement && event.currentTarget.hasAttribute('href')) {
if (
!isCustomComponent &&
event.currentTarget instanceof HTMLAnchorElement &&
event.currentTarget.hasAttribute('href')
) {
event.preventDefault();

if (canUseDOM && (component === TabsComponent.nav || isNav)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/react-core/src/components/Tabs/TabsContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export interface TabsContextProps {
handleTabClick: (
event: React.MouseEvent<HTMLElement, MouseEvent>,
eventKey: number | string,
tabContentRef: React.RefObject<any>
tabContentRef: React.RefObject<any>,
isCustomComponent?: boolean
) => void;
handleTabClose?: (
event: React.MouseEvent<HTMLElement, MouseEvent>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { TabButton } from '../TabButton';

test('should render tab button', () => {
const { asFragment } = render(<TabButton>Tab button</TabButton>);
expect(asFragment()).toMatchSnapshot();
});

test('renders an anchor by default when href is supplied', () => {
render(<TabButton href="#tab">Tab link</TabButton>);
expect(screen.getByRole('link')).toHaveAttribute('href', '#tab');
expect(screen.getByRole('link')).not.toHaveAttribute('type');
});

test('uses the explicit component instead of the href-based default', () => {
render(
<TabButton component="button" href="#tab">
Tab button
</TabButton>
);
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
});

test('renders an explicit anchor without button-specific attributes', () => {
render(
<TabButton component="a" role="tab">
Tab link
</TabButton>
);
expect(screen.getByRole('tab').tagName).toBe('A');
expect(screen.getByRole('tab')).not.toHaveAttribute('type');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// Generated with OpenCode (GPT-6.1).
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef, forwardRef } from 'react';
import { Tabs } from '../Tabs';
import { Tab } from '../Tab';

const onNavigate = jest.fn();
// Router links invoke the supplied handler before navigating, and honor preventDefault.
const CustomLink = forwardRef<HTMLAnchorElement, React.ComponentPropsWithoutRef<'a'>>(
({ onClick, href = '#custom-link', ...props }, ref) => (
<a
{...props}
href={href}
ref={ref}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) {
onNavigate();
}
event.preventDefault();
}}
/>
)
);
CustomLink.displayName = 'CustomLink';

const CustomButton = forwardRef<HTMLButtonElement, React.ComponentPropsWithoutRef<'button'>>((props, ref) => (
<button {...props} ref={ref} />
));
CustomButton.displayName = 'CustomButton';

describe('custom tab components', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it.each([
{ description: 'default tabs without href', tabsProps: {}, href: undefined },
{ description: 'default tabs with href', tabsProps: {}, href: '#custom-link' },
{ description: 'nav wrapper without href', tabsProps: { component: 'nav' as const }, href: undefined },
{ description: 'nav wrapper with href', tabsProps: { component: 'nav' as const }, href: '#custom-link' },
{ description: 'nav styling without href', tabsProps: { isNav: true }, href: undefined },
{ description: 'nav styling with href', tabsProps: { isNav: true }, href: '#custom-link' }
])('allows custom navigation and tab selection for $description', async ({ tabsProps, href }) => {
const user = userEvent.setup();
const pushState = jest.spyOn(window.history, 'pushState');
const ref = createRef<HTMLAnchorElement>();

render(
<Tabs defaultActiveKey={0} {...tabsProps}>
<Tab eventKey={0} title="First tab" />
<Tab eventKey={1} title="Custom tab" component={CustomLink} href={href} ref={ref}>
Custom content
</Tab>
</Tabs>
);

const tab = screen.getByRole('tab', { name: 'Custom tab' });
expect(ref.current).toBe(tab);
expect(tab).toHaveAttribute('href', '#custom-link');
expect(tab).not.toHaveAttribute('type');
expect(tab).not.toHaveAttribute('disabled');

await user.click(tab);

expect(onNavigate).toHaveBeenCalledTimes(1);
expect(pushState).not.toHaveBeenCalled();
expect(tab).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Custom content');
});

it('calls onSelect when clicking a controlled custom tab', async () => {
const user = userEvent.setup();
const onSelect = jest.fn();
const onClick = jest.fn();
render(
<Tabs activeKey={0} onSelect={onSelect}>
<Tab eventKey={0} title="First tab" />
<Tab eventKey={1} title="Custom tab" component={CustomLink} onClick={onClick} />
</Tabs>
);

await user.click(screen.getByRole('tab', { name: 'Custom tab' }));

expect(onSelect).toHaveBeenCalledWith(expect.any(Object), 1);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledTimes(1);
});

it('allows a caller to prevent custom navigation without replacing tab selection', async () => {
const user = userEvent.setup();
const onSelect = jest.fn();
render(
<Tabs onSelect={onSelect}>
<Tab eventKey={1} title="Custom tab" component={CustomLink} onClick={(event) => event.preventDefault()} />
</Tabs>
);

await user.click(screen.getByRole('tab', { name: 'Custom tab' }));

expect(onSelect).toHaveBeenCalledWith(expect.any(Object), 1);
expect(onNavigate).not.toHaveBeenCalled();
});

it('selects a custom button tab without submitting its form when type="button" is supplied', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<Tabs defaultActiveKey={0}>
<Tab eventKey={0} title="First tab" />
<Tab eventKey={1} title="Custom button tab" component={CustomButton} type="button">
Custom button content
</Tab>
</Tabs>
</form>
);

const tab = screen.getByRole('tab', { name: 'Custom button tab' });
expect(tab).toHaveAttribute('type', 'button');

await user.click(tab);
await user.keyboard('{Enter}');

expect(tab).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tabpanel')).toHaveTextContent('Custom button content');
expect(onSubmit).not.toHaveBeenCalled();
});

it.each([
{ description: 'disabled', disabledProps: { isDisabled: true }, tabIndex: '-1' },
{ description: 'aria-disabled', disabledProps: { isAriaDisabled: true }, tabIndex: null }
])('prevents mouse and keyboard navigation for a $description custom link', async ({ disabledProps, tabIndex }) => {
const user = userEvent.setup();
const onSelect = jest.fn();
const onClick = jest.fn();
const onKeyPress = jest.fn();
render(
<Tabs onSelect={onSelect}>
<Tab
eventKey={0}
title="Custom tab"
component={CustomLink}
onClick={onClick}
onKeyPress={onKeyPress}
{...disabledProps}
/>
</Tabs>
);

const tab = screen.getByRole('tab', { name: 'Custom tab' });
expect(tab).toHaveAttribute('aria-disabled', 'true');
expect(tab).not.toHaveAttribute('disabled');
if (tabIndex) {
expect(tab).toHaveAttribute('tabindex', tabIndex);
} else {
await user.tab();
expect(tab).toHaveFocus();
}

await user.click(tab);
tab.focus();
await user.keyboard('{Enter}');

expect(onNavigate).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
expect(onClick).not.toHaveBeenCalled();
expect(onKeyPress).not.toHaveBeenCalled();
});
});
Loading
Loading