Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4bf2c2e
Initial plan
Copilot Sep 4, 2025
f2e5fd6
Investigation: Identified root cause of keyboard navigation issue in …
Copilot Sep 4, 2025
9090fc1
Fix keyboard navigation issue in custom-select option groups + add co…
Copilot Sep 4, 2025
23565b2
Final validation and cleanup - fix confirmed working
Copilot Sep 4, 2025
12ccbdb
Consolidate option groups tests into existing test functions
Copilot Sep 4, 2025
3845919
Update packages/components/src/components/custom-select/custom-select…
mfranzke Sep 8, 2025
69c1bc9
test: replace hard-coded timeouts with reliable waitForFocusChange he…
Sep 8, 2025
b9d92f2
Merge branch 'main' into copilot/fix-4920
mfranzke Sep 10, 2025
9a3c56d
Merge branch 'main' into copilot/fix-4920
mfranzke Sep 10, 2025
294360c
Merge branch 'main' into copilot/fix-4920
mfranzke Sep 10, 2025
6e96a40
Merge branch 'main' into copilot/fix-4920
mfranzke Sep 12, 2025
7ed7d37
fix: issue with test in custom-select.spec.tsx
nmerget Sep 29, 2025
03c8460
Merge branch 'main' into copilot/fix-4920
nmerget Sep 29, 2025
ffd1562
chore: add changeset
nmerget Sep 29, 2025
6b84a2f
Merge remote-tracking branch 'origin/copilot/fix-4920' into copilot/f…
nmerget Sep 29, 2025
d33a6e3
Merge remote-tracking branch 'origin/main' into copilot/fix-4920
nmerget Sep 29, 2025
0bceb19
Merge remote-tracking branch 'origin/main' into copilot/fix-4920
nmerget Sep 30, 2025
379e111
Merge branch 'main' into copilot/fix-4920
nmerget Sep 30, 2025
e3a680c
Merge branch 'main' into copilot/fix-4920
nmerget Sep 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/keyboard-navigation-option-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@db-ux/core-components": patch
"@db-ux/ngx-core-components": patch
"@db-ux/react-core-components": patch
"@db-ux/v-core-components": patch
"@db-ux/wc-core-components": patch
---

- fix(custom-select): keyboard navigation for option groups in single-select mode:
- Fixes a keyboard accessibility issue where users could not navigate to options in subsequent option groups using arrow keys in single-select mode.
- Now, all options are accessible via keyboard regardless of group boundaries.


Original file line number Diff line number Diff line change
Expand Up @@ -297,47 +297,74 @@ export default function DBCustomSelect(props: DBCustomSelectProps) {
event.key === 'ArrowDown' ||
event.key === 'ArrowRight'
) {
if (listElement?.nextElementSibling) {
listElement?.nextElementSibling
?.querySelector('input')
?.focus();
} else {
// Find next element with input, skipping group titles
let nextElement =
listElement?.nextElementSibling;
while (nextElement) {
const nextInput =
nextElement.querySelector('input');
if (nextInput) {
nextInput.focus();
break;
}
nextElement =
nextElement.nextElementSibling;
}

if (!nextElement) {
// We are on the last checkbox we move to the top checkbox
state.handleFocusFirstDropdownCheckbox(
activeElement
);
}
} else {
if (listElement?.previousElementSibling) {
listElement?.previousElementSibling
?.querySelector('input')
?.focus();
} else if (
detailsRef.querySelector(
`input[type="checkbox"]`
) !== activeElement
) {
// We are on the top list checkbox but there is a select all checkbox as well
state.handleFocusFirstDropdownCheckbox(
activeElement
);
} else {
// We are on the top checkbox, we need to move to the search
// or to the last checkbox
const search = getSearchInput(detailsRef);
if (search) {
delay(() => {
search.focus();
}, 100);
// Find previous element with input, skipping group titles
let prevElement =
listElement?.previousElementSibling;
while (prevElement) {
const prevInput =
prevElement.querySelector('input');
if (prevInput) {
prevInput.focus();
break;
}
prevElement =
prevElement.previousElementSibling;
}

if (!prevElement) {
// Check if we have a "select all" checkbox (only relevant for multi-select)
const selectAllCheckbox =
detailsRef.querySelector(
`input[type="checkbox"]`
);
if (
selectAllCheckbox &&
selectAllCheckbox !== activeElement
) {
// We are on the top list checkbox but there is a select all checkbox as well
state.handleFocusFirstDropdownCheckbox(
activeElement
);
} else {
const checkboxList: HTMLInputElement[] =
Array.from(
detailsRef?.querySelectorAll(
`input[type="checkbox"],input[type="radio"]`
)
);
if (checkboxList.length) {
checkboxList.at(-1)?.focus();
// We are on the top checkbox, we need to move to the search
// or to the last checkbox
const search =
getSearchInput(detailsRef);
if (search) {
delay(() => {
search.focus();
}, 100);
} else {
const checkboxList: HTMLInputElement[] =
Array.from(
detailsRef?.querySelectorAll(
`input[type="checkbox"],input[type="radio"]`
)
);
if (checkboxList.length) {
checkboxList.at(-1)?.focus();
}
}
}
}
Expand Down
126 changes: 114 additions & 12 deletions packages/components/src/components/custom-select/custom-select.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import { DBCustomSelect } from './index';
// @ts-ignore - vue can only find it with .ts as file ending
import { DEFAULT_VIEWPORT } from '../../shared/constants.ts';

// Helper function to wait for focus changes instead of using hard-coded timeouts
const waitForFocusChange = async (
page: any,
expectedValue: string,
timeout = 5000
) => {
await page.waitForFunction(
(expected: any) => {
const activeElement = document.activeElement as HTMLInputElement;
return activeElement && activeElement.value === expected;
},
expectedValue,
{ timeout }
);
};

const comp: any = (
<DBCustomSelect
options={[{ value: 'Option 1' }, { value: 'Option 2' }]}
Expand Down Expand Up @@ -39,6 +55,21 @@ const selectAllSelect: any = (
placeholder="Placeholder"></DBCustomSelect>
);

const optionGroupsComp: any = (
<DBCustomSelect
options={[
{ label: 'Option group 1', isGroupTitle: true },
{ value: 'G1:Option 1' },
{ value: 'G1:Option 2' },
{ label: 'Option group 2', isGroupTitle: true },
{ value: 'G2:Option 1' },
{ value: 'G2:Option 2' }
]}
label="Test Option Groups"
placeholder="Placeholder"
/>
);

const tagSelectWithCustomRemoveTexts: any = (
<DBCustomSelect
options={[
Expand All @@ -51,7 +82,7 @@ const tagSelectWithCustomRemoveTexts: any = (
selectedType="tag"
removeTagsTexts={[
'Remove Red Color',
'Remove Blue Color',
'Remove Blue Color',
'Remove Green Color'
]}
values={['Blue', 'Green']}
Expand Down Expand Up @@ -84,6 +115,14 @@ const testA11y = () => {

expect(accessibilityScanResults.violations).toEqual([]);
});
test('option groups should be accessible', async ({ page, mount }) => {
await mount(optionGroupsComp);
const accessibilityScanResults = await new AxeBuilder({ page })
.include('.db-custom-select')
.analyze();

expect(accessibilityScanResults.violations).toEqual([]);
});
};

const testAction = () => {
Expand All @@ -93,7 +132,7 @@ const testAction = () => {
await expect(summary).not.toContainText('Option 1');
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(1000); // wait for focus to apply
await waitForFocusChange(page, 'Option 1');
await page.keyboard.press('Space');
await expect(summary).toContainText('Option 1');
});
Expand All @@ -104,7 +143,7 @@ const testAction = () => {
await expect(summary).not.toContainText('Option 1');
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(1000); // wait for focus to apply
await waitForFocusChange(page, 'Option 1');
await page.keyboard.press('Space');
await page.keyboard.press('Escape');
await expect(summary).toContainText('Option 1');
Expand Down Expand Up @@ -139,7 +178,7 @@ const testAction = () => {
await expect(summary).not.toContainText('Option 1');
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(1000); // wait for focus to apply
await waitForFocusChange(page, 'Option 1');
await page.keyboard.press('Enter');
await expect(summary).toContainText('Option 1');
// For single select, dropdown should be closed after Enter
Expand All @@ -152,32 +191,95 @@ const testAction = () => {
await expect(summary).not.toContainText('Option 1');
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(1000); // wait for focus to apply
await waitForFocusChange(page, 'Option 1');
await page.keyboard.press('Enter');
// For multiple select, dropdown should remain open after Enter
await expect(component.locator('details')).toHaveAttribute('open');
await page.keyboard.press('Escape');
await expect(summary).toContainText('Option 1');
});

test('custom removeTagsTexts should correspond to correct options', async ({ mount }) => {
test('option groups keyboard navigation: should navigate between option groups correctly', async ({
page,
mount
}) => {
const component = await mount(optionGroupsComp);

// Open the dropdown and focus first option
await page.keyboard.press('Tab');
await page.keyboard.press('ArrowDown');
await waitForFocusChange(page, 'G1:Option 1');

// Should be focused on G1:Option 1
const focused1 = await page.evaluate(
() => (document.activeElement as HTMLInputElement)?.value
);
expect(focused1).toBe('G1:Option 1');

// Navigate to G1:Option 2
await page.keyboard.press('ArrowDown');
await waitForFocusChange(page, 'G1:Option 2');
const focused2 = await page.evaluate(
() => (document.activeElement as HTMLInputElement)?.value
);
expect(focused2).toBe('G1:Option 2');

// CRITICAL TEST: Navigate from G1:Option 2 to G2:Option 1
// This should skip the "Option group 2" title and focus on G2:Option 1
// This is the core issue that was fixed in #4920
await page.keyboard.press('ArrowDown');
await waitForFocusChange(page, 'G2:Option 1');
const focused3 = await page.evaluate(
() => (document.activeElement as HTMLInputElement)?.value
);
expect(focused3).toBe('G2:Option 1'); // This was previously broken

// Continue to G2:Option 2
await page.keyboard.press('ArrowDown');
await waitForFocusChange(page, 'G2:Option 2');
const focused4 = await page.evaluate(
() => (document.activeElement as HTMLInputElement)?.value
);
expect(focused4).toBe('G2:Option 2');

// Test reverse navigation
await page.keyboard.press('ArrowUp');
await waitForFocusChange(page, 'G2:Option 1');
const focused5 = await page.evaluate(
() => (document.activeElement as HTMLInputElement)?.value
);
expect(focused5).toBe('G2:Option 1');

await page.keyboard.press('ArrowUp');
await waitForFocusChange(page, 'G1:Option 2');
const focused6 = await page.evaluate(
() => (document.activeElement as HTMLInputElement)?.value
);
expect(focused6).toBe('G1:Option 2');
});

test('custom removeTagsTexts should correspond to correct options', async ({
mount
}) => {
const component = await mount(tagSelectWithCustomRemoveTexts);

// Should have tags for Blue and Green (selected values)
const tags = component.locator('.db-tag');
await expect(tags).toHaveCount(2);

// Get the remove buttons and their tooltip text
const removeButtons = component.locator('.db-tag .db-tab-remove-button');
const removeButtons = component.locator(
'.db-tag .db-tab-remove-button'
);
await expect(removeButtons).toHaveCount(2);

// The first selected option is 'Blue' (index 1 in options array)
// Should have 'Remove Blue Color' tooltip
const firstRemoveButton = removeButtons.first();
const firstTooltip = firstRemoveButton.locator('.db-tooltip');
await expect(firstTooltip).toContainText('Remove Blue Color');
// The second selected option is 'Green' (index 2 in options array)

// The second selected option is 'Green' (index 2 in options array)
// Should have 'Remove Green Color' tooltip
const secondRemoveButton = removeButtons.last();
const secondTooltip = secondRemoveButton.locator('.db-tooltip');
Expand Down