diff --git a/packages/react/src/tabs/root/TabsRoot.test.tsx b/packages/react/src/tabs/root/TabsRoot.test.tsx index 2233008e34..cc902c7472 100644 --- a/packages/react/src/tabs/root/TabsRoot.test.tsx +++ b/packages/react/src/tabs/root/TabsRoot.test.tsx @@ -168,6 +168,23 @@ describe('', () => { }); describe('prop: onValueChange', () => { + it('should call onValueChange on pointerdown', async () => { + const handleChange = spy(); + const handlePointerDown = spy(); + const { getAllByRole, user } = await render( + + + + + + , + ); + + await user.pointer({ keys: '[MouseLeft>]', target: getAllByRole('tab')[1] }); + expect(handleChange.callCount).to.equal(1); + expect(handlePointerDown.callCount).to.equal(1); + }); + it('should call onValueChange when clicking', async () => { const handleChange = spy(); const { getAllByRole } = await render( @@ -184,6 +201,21 @@ describe('', () => { expect(handleChange.firstCall.args[0]).to.equal(1); }); + it('should not call onValueChange on non-main button clicks', async () => { + const handleChange = spy(); + const { getAllByRole } = await render( + + + + + + , + ); + + fireEvent.click(getAllByRole('tab')[1], { button: 2 }); + expect(handleChange.callCount).to.equal(0); + }); + it('should not call onValueChange when already selected', async () => { const handleChange = spy(); const { getAllByRole } = await render( diff --git a/packages/react/src/tabs/tab/useTabsTab.ts b/packages/react/src/tabs/tab/useTabsTab.ts index 16e285d224..f138948379 100644 --- a/packages/react/src/tabs/tab/useTabsTab.ts +++ b/packages/react/src/tabs/tab/useTabsTab.ts @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import { mergeReactProps } from '../../utils/mergeReactProps'; +import { ownerDocument } from '../../utils/owner'; import { useEnhancedEffect } from '../../utils/useEnhancedEffect'; import { useForkRef } from '../../utils/useForkRef'; import { useBaseUiId } from '../../utils/useBaseUiId'; @@ -75,32 +76,56 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue { const tabPanelId = index > -1 ? getTabPanelIdByTabValueOrIndex(valueParam, index) : undefined; + const isPressingRef = React.useRef(false); + const isMainButtonRef = React.useRef(false); + const getRootProps = React.useCallback( (externalProps = {}) => { return mergeReactProps<'button'>( externalProps, - mergeReactProps<'button'>( - { - role: 'tab', - 'aria-controls': tabPanelId, - 'aria-selected': selected, - id, - ref: handleRef, - onClick(event) { + { + role: 'tab', + 'aria-controls': tabPanelId, + 'aria-selected': selected, + id, + ref: handleRef, + onClick(event) { + if (selected) { + return; + } + + onTabActivation(tabValue, event.nativeEvent); + }, + onFocus(event) { + if (!activateOnFocus || selected) { + return; + } + + if (!isPressingRef.current || (isPressingRef.current && isMainButtonRef.current)) { onTabActivation(tabValue, event.nativeEvent); - }, - onFocus(event) { - if (!activateOnFocus) { - return; - } - - if (selectedTabValue !== tabValue) { - onTabActivation(tabValue, event.nativeEvent); - } - }, + } + }, + onPointerDown(event) { + if (selected) { + return; + } + + isPressingRef.current = true; + + function handlePointerUp() { + isPressingRef.current = false; + isMainButtonRef.current = false; + } + + if (!event.button || event.button === 0) { + isMainButtonRef.current = true; + + const doc = ownerDocument(event.currentTarget); + doc.addEventListener('pointerup', handlePointerUp, { once: true }); + } }, - mergeReactProps(getItemProps(), getButtonProps()), - ), + }, + mergeReactProps(getItemProps(), getButtonProps()), ); }, [ @@ -111,7 +136,6 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue { id, onTabActivation, selected, - selectedTabValue, tabPanelId, tabValue, ],