Repository navigation
fix(Tabs): support custom link components #12677
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
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 */ | ||
|
|
@@ -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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' })} | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ref={parentInnerRef} | ||
| {...getOUIAProps(TabButton.displayName, ouiaId, ouiaSafe)} | ||
| {...props} | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
27 changes: 26 additions & 1 deletion
27
packages/react-core/src/components/Tabs/__tests__/TabButton.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); |
176 changes: 176 additions & 0 deletions
176
packages/react-core/src/components/Tabs/__tests__/TabsCustomComponent.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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