Skip to content

Commit 503951f

Browse files
authored
fix: Non-dismissible tabs lose focus (#3539)
1 parent 149e8c7 commit 503951f

File tree

2 files changed

+42
-1
lines changed

2 files changed

+42
-1
lines changed

src/tabs/__tests__/tabs.test.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import React from 'react';
3+
import React, { useState } from 'react';
44
import { fireEvent, render } from '@testing-library/react';
55

66
import { KeyCode } from '@cloudscape-design/test-utils-core/utils';
@@ -940,6 +940,35 @@ describe('Tabs', () => {
940940
});
941941
});
942942

943+
describe('Dismissible mixed with non-dismissible', () => {
944+
test('moves focus from the last dismissible button to non-dismissible', () => {
945+
const TabsWrapper = () => {
946+
const [tabsDismissibles, setTabDismissibles] = useState([
947+
{
948+
label: 'First tab',
949+
id: 'first',
950+
content: <>first</>,
951+
},
952+
{
953+
label: 'Second tab',
954+
id: 'second',
955+
dismissible: true,
956+
dismissLabel: 'Dismiss second tab (dismissibles variant)',
957+
onDismiss: () => setTabDismissibles(prevTabs => prevTabs.slice(0, 1)),
958+
content: <>second</>,
959+
},
960+
]);
961+
962+
return <Tabs tabs={tabsDismissibles} />;
963+
};
964+
const { wrapper } = renderTabs(<TabsWrapper />);
965+
966+
wrapper.findDismissibleButtonByTabId('second')!.click();
967+
968+
expect(wrapper.findTabLinkById('first')!.getElement()).toHaveFocus();
969+
});
970+
});
971+
943972
describe('Actions', () => {
944973
test('renders action', () => {
945974
const actionButton = renderTabs(<Tabs tabs={actionDismissibleTabs} />).wrapper.findActionByTabIndex(2);

src/tabs/tab-header-bar.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from '../internal/context/single-tab-stop-navigation-context';
2020
import { hasModifierKeys, isPlainLeftClick } from '../internal/events';
2121
import useHiddenDescription from '../internal/hooks/use-hidden-description';
22+
import { usePrevious } from '../internal/hooks/use-previous';
2223
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
2324
import { KeyCode } from '../internal/keycode';
2425
import { circleIndex } from '../internal/utils/circle-index';
@@ -112,6 +113,7 @@ export function TabHeaderBar({
112113
const [focusedTabId, setFocusedTabId] = useState(activeTabId);
113114
const [previousActiveTabId, setPreviousActiveTabId] = useState<string | undefined>(activeTabId);
114115
const hasActionOrDismissible = tabs.some(tab => tab.action || tab.dismissible);
116+
const hadActionOrDismissible = usePrevious(hasActionOrDismissible);
115117
const tabActionAttributes = hasActionOrDismissible
116118
? {
117119
role: 'application',
@@ -124,6 +126,16 @@ export function TabHeaderBar({
124126
role: 'tablist',
125127
};
126128

129+
useEffect(() => {
130+
if (hadActionOrDismissible && !hasActionOrDismissible) {
131+
// when tabs becomes non-dismissible (e.g. when all dismissible tabs are removed),
132+
// the hasActionOrDismissible is changing which causing tabs to re-mount to the React tree,
133+
// which, in turn, causes losing their refs, and the nextActive.focus() function inside handleDismiss does not focus on the next tab
134+
// so this code does
135+
getNextFocusTarget()?.focus();
136+
}
137+
}, [hasActionOrDismissible, hadActionOrDismissible]);
138+
127139
useEffect(() => {
128140
if (headerBarRef.current) {
129141
setHorizontalOverflow(hasHorizontalOverflow(headerBarRef.current, inlineStartOverflowButton));

0 commit comments

Comments
 (0)