Skip to content

Conversation

@milan-w
Copy link
Contributor

@milan-w milan-w commented Oct 30, 2025

Set (aria-)selected state via listener on parent tablist to also react on de-selection of item. Closes #5288

Proposed changes

Each tab item holds an internal state _selected to keep track of the active tab. However before this change the state function did not correctly react to a de-selection because the event was only triggered on the newly active tab.
Now each item registers an event listener on the parent component and sets de/selected according to this event.

The playwright test for the tab components has also been refactored slightly to eliminate an unneccesary file write process.

Types of changes

  • Bugfix (non-breaking change that fixes an issue)

  • New feature (non-breaking change which adds functionality)

  • Refactoring (improvements to existing components or architectural decisions)

  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

  • Documentation Update (if none of the other choices apply)

  • I have added tests that prove my fix is effective or that my feature works

  • I have added necessary documentation (if appropriate)

🔭🐙🐈 Test this branch here: https://design-system.deutschebahn.com/core-web/review/fix-tab-item-selected-state

@changeset-bot
Copy link

changeset-bot bot commented Oct 30, 2025

🦋 Changeset detected

Latest commit: c05f2a0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
@db-ux/core-components Patch
@db-ux/ngx-core-components Patch
@db-ux/react-core-components Patch
@db-ux/wc-core-components Patch
@db-ux/v-core-components Patch
@db-ux/core-foundations Patch
@db-ux/core-stylelint Patch
@db-ux/core-migration Patch
@db-ux/agent-cli Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@milan-w milan-w requested review from mfranzke and nmerget October 30, 2025 19:29
@github-actions github-actions bot added 🏘components 📕documentation Improvements or additions to documentation labels Oct 30, 2025
d-koppenhagen
d-koppenhagen previously approved these changes Oct 31, 2025
props.onChange(event);
}

// We have different ts types in different frameworks, so we need to use any here
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe we can keep this - the aria snapshots changed so it might be good to keep those additonally

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I re-added the stencil selector and reworked the event listeners, so the snapshots don't change anymore.

milan-w and others added 7 commits November 4, 2025 15:55
Set (aria-)selected state via listener on parent tablist to also react on de-selection of item.
use var instead of file to hold click event results for test duration
Now also sets aria-selected=true|false correctly which improves screen reader behaviour.
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
set child active after event registered, remove event listerner on unmount
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
add more checks and guardrails to TabItem on change events
@milan-w milan-w force-pushed the fix-tab-item-selected-state branch from ee7cfbe to d1535e1 Compare November 6, 2025 17:11
@milan-w milan-w requested a review from nmerget November 7, 2025 18:20
mfranzke
mfranzke previously approved these changes Nov 10, 2025
@github-project-automation github-project-automation bot moved this from 🏗 In progress to ⏰ready for release in UX Engineering Team Backlog Nov 10, 2025
@mfranzke mfranzke enabled auto-merge (squash) November 10, 2025 12:17
@mfranzke mfranzke requested a review from Copilot November 10, 2025 12:17
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR fixes the internal state management of the DBTabItem component to correctly track and update the _selected state, which improves accessibility by ensuring aria-selected attributes are properly set for screen readers. The key changes include:

  • Replaced file-based test state management with an in-memory variable for testing the onIndexChange callback
  • Fixed tab index calculation in DBTabs by using children instead of childNodes to exclude text nodes
  • Added event listener logic to synchronize _selected state across tabs when selection changes

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.

File Description
packages/components/src/components/tabs/tabs.spec.tsx Replaced file system operations with module-scoped variable for testing tab index changes
packages/components/src/components/tabs/tabs.lite.tsx Fixed index calculation by using list.children instead of list.childNodes and renamed variable from indices to tabIndex
packages/components/src/components/tab-item/tab-item.lite.tsx Added setSelectedOnChange function, event listener management, and improved _selected state tracking to fix aria-selected behavior
.changeset/sharp-pumas-obey.md Added changeset documenting the bug fix for DBTabItem internal state and accessibility improvements
Comments suppressed due to low confidence (1)

packages/components/src/components/tabs/tabs.spec.tsx:27

  • The comp component is defined at module scope with a closure over the module-scoped tabIndex variable. Since this component is reused across multiple tests (lines 31, 36, 48, 65, 70), all tests will mutate the same shared tabIndex variable. This creates test interdependencies and can lead to flaky tests depending on execution order.

Consider creating the component inside each test that needs it, or using a factory function that returns a fresh component instance with its own state.

const comp: any = (
	<DBTabs onIndexChange={(index: number) => (tabIndex = index)}>
		<DBTabList>
			<DBTabItem data-testid="test">Test 1</DBTabItem>
			<DBTabItem data-testid="test2">Test 2</DBTabItem>
			<DBTabItem>Test 3</DBTabItem>
		</DBTabList>

		<DBTabPanel>TestPanel 1</DBTabPanel>

		<DBTabPanel>TestPanel 2</DBTabPanel>

		<DBTabPanel>TestPanel 3</DBTabPanel>
	</DBTabs>
);

Comment on lines +94 to +98
// deselect this tab when another tab in tablist is selected
_ref.closest('[role=tablist]')?.addEventListener(
'change',
setSelectedOnChange
);
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

The event listener is added in onUpdate every time state.initialized becomes true and _ref exists. However, there's no removal of the previous listener before adding a new one. Since state.initialized is set to true in onMount and then set to false after the update, subsequent updates could potentially add multiple listeners to the same element, causing the handler to fire multiple times.

Consider tracking whether the listener has been added (e.g., with a flag) or ensuring the listener is only added once, or removing it before re-adding.

Copilot uses AI. Check for mistakes.
default: () => {
state._selected = true;
}
});
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

The condition if (_ref.checked && !state._selected) only updates state._selected to true when the tab becomes checked. However, there's no corresponding logic to set state._selected to false when the tab becomes unchecked.

The new setSelectedOnChange function (lines 32-41) is supposed to handle deselection, but as noted in another comment, it has a logic issue. Consider adding an else clause here to explicitly set state._selected to false when _ref.checked is false, ensuring the state is always synchronized with the checked status.

Suggested change
});
});
} else if (!_ref.checked && state._selected) {
useTarget({
stencil: () => {
state._selected = getBooleanAsString(false);
},
default: () => {
state._selected = false;
}
});

Copilot uses AI. Check for mistakes.
}, [props.name]);

onUnMount(() => {
if (state.initialized && _ref) {
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

The onUnMount hook checks state.initialized, but this is incorrect. At the time of unmounting, state.initialized is likely false because it's set to false in the onUpdate hook after initialization completes (line 92). This means the event listener cleanup in onUnMount will likely never execute, causing a memory leak.

The condition should either check if _ref exists, or remove the state.initialized check entirely since _ref should be sufficient to determine if the event listener was attached.

Suggested change
if (state.initialized && _ref) {
if (_ref) {

Copilot uses AI. Check for mistakes.
state._selected = getBooleanAsString(event.target === _ref);
},
default: () => {
state._selected = event.target === _ref;
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

The setSelectedOnChange function compares event.target === _ref, but _ref is a ref object, not the DOM element itself. To get the actual DOM element, you need to access _ref directly (since in Mitosis, refs work differently per framework). The correct comparison should check if the event's target matches the actual input element.

Additionally, this logic appears to deselect tabs when another is selected, but the condition event.target === _ref will only be true for the newly selected tab, not the previously selected one. The function should check if event.target !== _ref to properly deselect this tab when another tab is selected.

Suggested change
state._selected = event.target === _ref;
state._selected = event.target === _ref.current;

Copilot uses AI. Check for mistakes.
import { DBTabPanel } from '../tab-panel';

const filePath = './test-results/onIndexChange.txt';
let tabIndex: number;
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

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

The tabIndex variable is declared at module scope, which means its value persists across test runs. This can cause test pollution where one test's execution affects another test's state.

Before line 43 checks expect(tabIndex).toBe(undefined), if another test has already run and set tabIndex, this assertion will fail. Additionally, the variable is never reset between tests.

Consider either:

  1. Declaring tabIndex inside the test function and passing it via closure
  2. Using test.beforeEach() to reset the variable
  3. Making the variable test-specific using a different approach (e.g., a Map keyed by test context)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🏘components 📕documentation Improvements or additions to documentation

Projects

Status: ⏰ready for release

Development

Successfully merging this pull request may close these issues.

Tabs selected state incorrect

5 participants