;
+ disabledIndices?: number[];
}
}
@@ -116,6 +119,10 @@ CompositeRoot.propTypes /* remove-proptypes */ = {
* @ignore
*/
direction: PropTypes.oneOf(['ltr', 'rtl']),
+ /**
+ * @ignore
+ */
+ disabledIndices: PropTypes.arrayOf(PropTypes.number),
/**
* @ignore
*/
diff --git a/packages/react/src/composite/root/useCompositeRoot.ts b/packages/react/src/composite/root/useCompositeRoot.ts
index 28583d9457..eb6eb77dee 100644
--- a/packages/react/src/composite/root/useCompositeRoot.ts
+++ b/packages/react/src/composite/root/useCompositeRoot.ts
@@ -52,11 +52,13 @@ export interface UseCompositeRootParameters {
* @default false
*/
stopEventPropagation?: boolean;
+ /**
+ * Array of item indices to be considered disabled.
+ * Used for composite items that are focusable when disabled.
+ */
+ disabledIndices?: number[];
}
-// Advanced options of Composite, to be implemented later if needed.
-const disabledIndices = undefined;
-
/**
* @ignore - internal hook.
*/
@@ -73,6 +75,7 @@ export function useCompositeRoot(params: UseCompositeRootParameters) {
rootRef: externalRef,
enableHomeAndEndKeys = false,
stopEventPropagation = false,
+ disabledIndices,
} = params;
const [internalHighlightedIndex, internalSetHighlightedIndex] = React.useState(0);
@@ -249,18 +252,19 @@ export function useCompositeRoot(params: UseCompositeRootParameters) {
},
}),
[
- highlightedIndex,
- stopEventPropagation,
cols,
dense,
+ disabledIndices,
elementsRef,
+ enableHomeAndEndKeys,
+ highlightedIndex,
isGrid,
itemSizes,
loop,
mergedRef,
onHighlightedIndexChange,
orientation,
- enableHomeAndEndKeys,
+ stopEventPropagation,
],
);
@@ -270,7 +274,8 @@ export function useCompositeRoot(params: UseCompositeRootParameters) {
highlightedIndex,
onHighlightedIndexChange,
elementsRef,
+ disabledIndices,
}),
- [getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef],
+ [getRootProps, highlightedIndex, onHighlightedIndexChange, elementsRef, disabledIndices],
);
}
diff --git a/packages/react/src/tabs/list/TabsList.tsx b/packages/react/src/tabs/list/TabsList.tsx
index 0ecd3cf333..f6a8664309 100644
--- a/packages/react/src/tabs/list/TabsList.tsx
+++ b/packages/react/src/tabs/list/TabsList.tsx
@@ -11,6 +11,8 @@ import { type TabMetadata } from '../tab/useTabsTab';
import { useTabsList } from './useTabsList';
import { TabsListContext } from './TabsListContext';
+const EMPTY_ARRAY: number[] = [];
+
/**
* Groups the individual tab buttons.
* Renders a `` element.
@@ -94,6 +96,7 @@ const TabsList = React.forwardRef(function TabsList(
onHighlightedIndexChange={setHighlightedTabIndex}
onMapChange={setTabMap}
render={renderElement()}
+ disabledIndices={EMPTY_ARRAY}
/>
);
diff --git a/packages/react/src/tabs/root/TabsRoot.test.tsx b/packages/react/src/tabs/root/TabsRoot.test.tsx
index 46eeae21e2..1685fb928c 100644
--- a/packages/react/src/tabs/root/TabsRoot.test.tsx
+++ b/packages/react/src/tabs/root/TabsRoot.test.tsx
@@ -431,9 +431,39 @@ describe('', () => {
expect(handleKeyDown.callCount).to.equal(1);
expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
});
+
+ it('moves focus to a disabled tab without activating it', async () => {
+ const handleKeyDown = spy();
+ const { getAllByRole } = await render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ const [, disabledTab, lastTab] = getAllByRole('tab');
+ await act(async () => {
+ lastTab.focus();
+ });
+
+ fireEvent.keyDown(lastTab, { key: previousItemKey });
+ await flushMicrotasks();
+
+ expect(disabledTab).toHaveFocus();
+ expect(handleKeyDown.callCount).to.equal(1);
+ expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
+ });
});
- describe('with `activateOnFocus = true`', () => {
+ describe('with `activateOnFocus = true`', async () => {
it('moves focus to the last tab while activating it if focus is on the first tab', async () => {
const handleChange = spy();
const handleKeyDown = spy();
@@ -503,7 +533,7 @@ describe('', () => {
});
});
- it('skips over disabled tabs', async () => {
+ it('moves focus to a disabled tab without activating it', async () => {
const handleKeyDown = spy();
const { getAllByRole } = await render(
@@ -520,7 +550,7 @@ describe('', () => {
,
);
- const [firstTab, , lastTab] = getAllByRole('tab');
+ const [, disabledTab, lastTab] = getAllByRole('tab');
await act(async () => {
lastTab.focus();
});
@@ -528,7 +558,7 @@ describe('', () => {
fireEvent.keyDown(lastTab, { key: previousItemKey });
await flushMicrotasks();
- expect(firstTab).toHaveFocus();
+ expect(disabledTab).toHaveFocus();
expect(handleKeyDown.callCount).to.equal(1);
expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
});
@@ -601,6 +631,43 @@ describe('', () => {
expect(handleKeyDown.callCount).to.equal(1);
expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
});
+
+ it('moves focus to a disabled tab without activating it', async () => {
+ const handleChange = spy();
+ const handleKeyDown = spy();
+ const { getAllByRole } = await render(
+
+
+
+
+
+
+
+
+ ,
+ );
+ const [firstTab, disabledTab, thirdTab] = getAllByRole('tab');
+ await act(async () => {
+ firstTab.focus();
+ });
+
+ fireEvent.keyDown(firstTab, { key: nextItemKey });
+ await flushMicrotasks();
+
+ expect(disabledTab).toHaveFocus();
+ expect(handleChange.callCount).to.equal(0);
+ expect(handleKeyDown.callCount).to.equal(1);
+ expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
+
+ fireEvent.keyDown(disabledTab, { key: nextItemKey });
+ await flushMicrotasks();
+ expect(thirdTab).toHaveFocus();
+ });
});
describe('with `activateOnFocus = true`', () => {
@@ -673,11 +740,13 @@ describe('', () => {
});
});
- it('skips over disabled tabs', async () => {
+ it('moves focus to a disabled tab without activating it', async () => {
+ const handleChange = spy();
const handleKeyDown = spy();
const { getAllByRole } = await render(
', () => {
,
);
- const [firstTab, , lastTab] = getAllByRole('tab');
+ const [firstTab, disabledTab, thirdTab] = getAllByRole('tab');
await act(async () => {
firstTab.focus();
});
@@ -698,9 +767,14 @@ describe('', () => {
fireEvent.keyDown(firstTab, { key: nextItemKey });
await flushMicrotasks();
- expect(lastTab).toHaveFocus();
+ expect(disabledTab).toHaveFocus();
+ expect(handleChange.callCount).to.equal(0);
expect(handleKeyDown.callCount).to.equal(1);
expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
+
+ fireEvent.keyDown(disabledTab, { key: nextItemKey });
+ await flushMicrotasks();
+ expect(thirdTab).toHaveFocus();
});
});
},
@@ -762,28 +836,32 @@ describe('', () => {
expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
});
- it('moves focus to first non-disabled tab', async () => {
- const handleKeyDown = spy();
- const { getAllByRole } = await render(
-
-
-
-
-
-
- ,
- );
- const [, secondTab, lastTab] = getAllByRole('tab');
- await act(async () => {
- lastTab.focus();
- });
+ [false, true].forEach((activateOnFocusProp) => {
+ it(`when \`activateOnFocus = ${activateOnFocusProp}\`, moves focus to a disabled tab without activating it`, async () => {
+ const handleChange = spy();
+ const handleKeyDown = spy();
+ const { getAllByRole } = await render(
+
+
+
+
+
+
+ ,
+ );
+ const [disabledTab, , lastTab] = getAllByRole('tab');
+ await act(async () => {
+ lastTab.focus();
+ });
- fireEvent.keyDown(lastTab, { key: 'Home' });
- await flushMicrotasks();
+ fireEvent.keyDown(lastTab, { key: 'Home' });
+ await flushMicrotasks();
- expect(secondTab).toHaveFocus();
- expect(handleKeyDown.callCount).to.equal(1);
- expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
+ expect(disabledTab).toHaveFocus();
+ expect(handleChange.callCount).to.equal(0);
+ expect(handleKeyDown.callCount).to.equal(1);
+ expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
+ });
});
});
@@ -841,28 +919,33 @@ describe('', () => {
expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
});
- it('moves focus to first non-disabled tab', async () => {
- const handleKeyDown = spy();
- const { getAllByRole } = await render(
-
-
-
-
-
-
- ,
- );
- const [firstTab, secondTab] = getAllByRole('tab');
- await act(async () => {
- firstTab.focus();
- });
+ [false, true].forEach((activateOnFocusProp) => {
+ it(`when \`activateOnFocus = ${activateOnFocusProp}\`, moves focus to a disabled tab without activating it`, async () => {
+ const handleChange = spy();
+ const handleKeyDown = spy();
+ const { getAllByRole } = await render(
+
+
+
+
+
+
+ ,
+ );
+
+ const [firstTab, , disabledTab] = getAllByRole('tab');
+ await act(async () => {
+ firstTab.focus();
+ });
- fireEvent.keyDown(firstTab, { key: 'End' });
- await flushMicrotasks();
+ fireEvent.keyDown(firstTab, { key: 'End' });
+ await flushMicrotasks();
- expect(secondTab).toHaveFocus();
- expect(handleKeyDown.callCount).to.equal(1);
- expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
+ expect(disabledTab).toHaveFocus();
+ expect(handleChange.callCount).to.equal(0);
+ expect(handleKeyDown.callCount).to.equal(1);
+ expect(handleKeyDown.firstCall.args[0]).to.have.property('defaultPrevented', true);
+ });
});
});
});
diff --git a/packages/react/src/tabs/tab/useTabsTab.ts b/packages/react/src/tabs/tab/useTabsTab.ts
index 0a9ead673a..e0e4ff09a7 100644
--- a/packages/react/src/tabs/tab/useTabsTab.ts
+++ b/packages/react/src/tabs/tab/useTabsTab.ts
@@ -58,11 +58,15 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue {
return valueParam === selectedTabValue;
}, [index, selectedTabValue, valueParam]);
- // when activateOnFocus is `true`, ensure the active item in Composite's roving
- // focus group matches the selected Tab
+ const isSelectionSyncedWithHighlightRef = React.useRef(false);
+
useEnhancedEffect(() => {
+ if (isSelectionSyncedWithHighlightRef.current === true) {
+ return;
+ }
if (activateOnFocus && selected && index > -1 && highlightedTabIndex !== index) {
setHighlightedTabIndex(index);
+ isSelectionSyncedWithHighlightRef.current = true;
}
}, [activateOnFocus, highlightedTabIndex, index, selected, setHighlightedTabIndex]);
@@ -97,11 +101,22 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue {
onTabActivation(tabValue, event.nativeEvent);
},
onFocus(event) {
- if (!activateOnFocus || selected || disabled) {
+ if (selected) {
+ return;
+ }
+
+ if (index > 1 && index !== highlightedTabIndex) {
+ setHighlightedTabIndex(index);
+ }
+
+ if (disabled) {
return;
}
- if (!isPressingRef.current || (isPressingRef.current && isMainButtonRef.current)) {
+ if (
+ (activateOnFocus && !isPressingRef.current) || // keyboard focus
+ (isPressingRef.current && isMainButtonRef.current) // focus caused by pointerdown
+ ) {
onTabActivation(tabValue, event.nativeEvent);
}
},
@@ -139,6 +154,9 @@ function useTabsTab(parameters: useTabsTab.Parameters): useTabsTab.ReturnValue {
tabPanelId,
tabValue,
disabled,
+ index,
+ setHighlightedTabIndex,
+ highlightedTabIndex,
],
);