From 4bf2c2e583ab92703706af00f88e8922d7a4ee96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 18:47:26 +0000 Subject: [PATCH 1/9] Initial plan From f2e5fd6f09fce54793fdc2d456d8917c08e748a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:11:30 +0000 Subject: [PATCH 2/9] Investigation: Identified root cause of keyboard navigation issue in custom-select option groups Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com> --- debug-custom-select.html | 215 ++++++++++++++++++ .../custom-select/custom-select.lite.tsx | 84 ++++--- test-custom-select-fix.html | 109 +++++++++ test-custom-select-group-fix.html | 160 +++++++++++++ 4 files changed, 534 insertions(+), 34 deletions(-) create mode 100644 debug-custom-select.html create mode 100644 test-custom-select-fix.html create mode 100644 test-custom-select-group-fix.html diff --git a/debug-custom-select.html b/debug-custom-select.html new file mode 100644 index 000000000000..62c453e747f4 --- /dev/null +++ b/debug-custom-select.html @@ -0,0 +1,215 @@ + + + + + + Custom Select Debug Test + + + +

Custom Select Group Navigation Debug

+ +
+

Test Instructions:

+
    +
  1. Click on the "Debug" button to show all input elements found
  2. +
  3. Click "Open Select" and then try arrow key navigation
  4. +
  5. Watch the console for debug output
  6. +
+
+ +
+

Option Group Example Structure:

+
+ Select an option +
+
    + +
  • + Option group 1 +
  • + + +
  • + +
  • + +
  • + +
  • + + +
  • + Option group 2 +
  • + + +
  • + +
  • + +
  • + +
  • +
+
+
+
+ + + + + +
+ + + + \ No newline at end of file diff --git a/packages/components/src/components/custom-select/custom-select.lite.tsx b/packages/components/src/components/custom-select/custom-select.lite.tsx index ec6c04da94c8..5c84e55ec000 100644 --- a/packages/components/src/components/custom-select/custom-select.lite.tsx +++ b/packages/components/src/components/custom-select/custom-select.lite.tsx @@ -292,47 +292,63 @@ 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) { + 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 { - 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(); + } } } } diff --git a/test-custom-select-fix.html b/test-custom-select-fix.html new file mode 100644 index 000000000000..c258b5cc6c69 --- /dev/null +++ b/test-custom-select-fix.html @@ -0,0 +1,109 @@ + + + + + + Custom Select Option Groups Test + + + + + +

Custom Select Option Groups Keyboard Navigation Test

+ +
+ Test Instructions: +
    +
  1. Click on the custom select below to open it
  2. +
  3. Use Arrow Down keys to navigate through options
  4. +
  5. Verify you can reach "G2:Option 1" and "G2:Option 2" (options in Option group 2)
  6. +
  7. Use Arrow Up keys to navigate backwards
  8. +
  9. Press Enter or Space to select an option
  10. +
+

Before Fix: Could not navigate to Option group 2 entries

+

After Fix: Should be able to navigate to all options including those in Option group 2

+
+ +
+

Single Select with Option Groups

+ + +
+ +
+

Multi Select with Option Groups (Reference)

+ + +
+ + + + \ No newline at end of file diff --git a/test-custom-select-group-fix.html b/test-custom-select-group-fix.html new file mode 100644 index 000000000000..9c484e2e996f --- /dev/null +++ b/test-custom-select-group-fix.html @@ -0,0 +1,160 @@ + + + + + + Custom Select - Option Groups Keyboard Test + + + + + +
+

Custom Select Keyboard Navigation Test

+ +
+

Instructions:

+
    +
  1. Click on the custom select below to open it
  2. +
  3. Try to navigate with Arrow Down/Up keys
  4. +
  5. Notice you can't reach "G2:Option 1" and "G2:Option 2" entries
  6. +
  7. Press Tab key - notice the same issue
  8. +
+
+ +
+

ISSUE: Single Select with Option Groups

+

Keyboard navigation cannot reach Option Group 2 entries

+ +
+ + + + + +
+
    + +
  • + Option group 1 +
  • + + +
  • + +
  • + +
  • + +
  • + + +
  • + Option group 2 +
  • + + +
  • + +
  • + +
  • + +
  • +
+
+
+
+ +
+

Expected Behavior: Arrow keys and Tab should be able to navigate to all radio button options, including those in "Option group 2".

+

Current Problem: Keyboard navigation stops after "G1:Option 2" and cannot reach "G2:Option 1" or "G2:Option 2".

+
+
+ + + + \ No newline at end of file From 9090fc1f4ebb30efd8b10218dc12a1c4db36cd2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:22:25 +0000 Subject: [PATCH 3/9] Fix keyboard navigation issue in custom-select option groups + add comprehensive test Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com> --- manual-test-navigation.html | 279 ++++++++++++++++++ .../custom-select-option-groups.spec.tsx | 124 ++++++++ .../custom-select/custom-select.lite.tsx | 7 +- .../custom-select/custom-select.spec.tsx | 71 +++++ test-navigation-logic.html | 147 +++++++++ 5 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 manual-test-navigation.html create mode 100644 packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx create mode 100644 test-navigation-logic.html diff --git a/manual-test-navigation.html b/manual-test-navigation.html new file mode 100644 index 000000000000..d186cef6a0de --- /dev/null +++ b/manual-test-navigation.html @@ -0,0 +1,279 @@ + + + + + + Custom Select Issue Reproduction + + + +

Custom Select Option Groups - Issue Reproduction

+ +
+

Instructions:

+
    +
  1. Click on the select to open it
  2. +
  3. Use Arrow Down/Up keys to navigate
  4. +
  5. Notice if you can reach "G2:Option 1" and "G2:Option 2"
  6. +
  7. Watch the console for debug output
  8. +
+
+ +
+ + + + + +
+ +
+
+ +
+ + +
+
+ + + + \ No newline at end of file diff --git a/packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx b/packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx new file mode 100644 index 000000000000..f547d8850d99 --- /dev/null +++ b/packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx @@ -0,0 +1,124 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/experimental-ct-react'; + +import { DBCustomSelect } from './index'; +// @ts-ignore - vue can only find it with .ts as file ending +import { DEFAULT_VIEWPORT } from '../../shared/constants.ts'; + +const optionGroupsComp: any = ( + +); + +const optionGroupsMultiple: any = ( + +); + +test.describe('DBCustomSelect - Option Groups Keyboard Navigation', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(DEFAULT_VIEWPORT); + }); + + test('single select: should navigate through all options including those in second group', async ({ page, mount }) => { + const component = await mount(optionGroupsComp); + const summary = component.locator('summary'); + + // Open the dropdown and focus first option + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(1000); // wait for focus to apply + + // Should be focused on G1:Option 1 + const focused1 = await page.evaluate(() => document.activeElement?.value); + expect(focused1).toBe('G1:Option 1'); + + // Navigate to G1:Option 2 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + const focused2 = await page.evaluate(() => document.activeElement?.value); + expect(focused2).toBe('G1:Option 2'); + + // THIS IS THE 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 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + const focused3 = await page.evaluate(() => document.activeElement?.value); + expect(focused3).toBe('G2:Option 1'); // This is where the bug occurs! + + // Continue to G2:Option 2 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + const focused4 = await page.evaluate(() => document.activeElement?.value); + expect(focused4).toBe('G2:Option 2'); + + // Test reverse navigation + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(100); + const focused5 = await page.evaluate(() => document.activeElement?.value); + expect(focused5).toBe('G2:Option 1'); + + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(100); + const focused6 = await page.evaluate(() => document.activeElement?.value); + expect(focused6).toBe('G1:Option 2'); + }); + + test('multiple select: should navigate through all options including those in second group', async ({ page, mount }) => { + const component = await mount(optionGroupsMultiple); + + // Open the dropdown and focus first option + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(1000); // wait for focus to apply + + // Should be focused on G1:Option 1 + const focused1 = await page.evaluate(() => document.activeElement?.value); + expect(focused1).toBe('G1:Option 1'); + + // Navigate to G1:Option 2 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + const focused2 = await page.evaluate(() => document.activeElement?.value); + expect(focused2).toBe('G1:Option 2'); + + // THIS IS THE CRITICAL TEST: Navigate from G1:Option 2 to G2:Option 1 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + const focused3 = await page.evaluate(() => document.activeElement?.value); + expect(focused3).toBe('G2:Option 1'); // This should work in multiple mode + + // Continue to G2:Option 2 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + const focused4 = await page.evaluate(() => document.activeElement?.value); + expect(focused4).toBe('G2:Option 2'); + }); + + test('should be accessible', async ({ mount }) => { + const component = await mount(optionGroupsComp); + const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + }); +}); \ No newline at end of file diff --git a/packages/components/src/components/custom-select/custom-select.lite.tsx b/packages/components/src/components/custom-select/custom-select.lite.tsx index 5c84e55ec000..53cfe426c0b6 100644 --- a/packages/components/src/components/custom-select/custom-select.lite.tsx +++ b/packages/components/src/components/custom-select/custom-select.lite.tsx @@ -322,10 +322,11 @@ export default function DBCustomSelect(props: DBCustomSelectProps) { } if (!prevElement) { + // Check if we have a "select all" checkbox (only relevant for multi-select) + const selectAllCheckbox = detailsRef.querySelector(`input[type="checkbox"]`); if ( - detailsRef.querySelector( - `input[type="checkbox"]` - ) !== activeElement + selectAllCheckbox && + selectAllCheckbox !== activeElement ) { // We are on the top list checkbox but there is a select all checkbox as well state.handleFocusFirstDropdownCheckbox( diff --git a/packages/components/src/components/custom-select/custom-select.spec.tsx b/packages/components/src/components/custom-select/custom-select.spec.tsx index 08080072e638..dc459e671466 100644 --- a/packages/components/src/components/custom-select/custom-select.spec.tsx +++ b/packages/components/src/components/custom-select/custom-select.spec.tsx @@ -39,6 +39,21 @@ const selectAllSelect: any = ( placeholder="Placeholder"> ); +const optionGroupsComp: any = ( + +); + const testComponent = () => { test('should contain text', async ({ mount }) => { const component = await mount(comp); @@ -67,6 +82,61 @@ const testA11y = () => { }); }; +const testOptionGroups = () => { + test('option groups single select: should navigate through all options including those in second group', async ({ page, mount }) => { + const component = await mount(optionGroupsComp); + const summary = component.locator('summary'); + + // Open the dropdown and focus first option + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(1000); // wait for focus to apply + + // 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 page.waitForTimeout(100); + const focused2 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + expect(focused2).toBe('G1:Option 2'); + + // THIS IS THE 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 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + const focused3 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + expect(focused3).toBe('G2:Option 1'); // This is where the bug was! + + // Continue to G2:Option 2 + await page.keyboard.press('ArrowDown'); + await page.waitForTimeout(100); + 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 page.waitForTimeout(100); + const focused5 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + expect(focused5).toBe('G2:Option 1'); + + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(100); + const focused6 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + expect(focused6).toBe('G1:Option 2'); + }); + + 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 = () => { test('click on single item', async ({ page, mount }) => { const component = await mount(comp); @@ -147,4 +217,5 @@ test.describe('DBCustomSelect', () => { testComponent(); testAction(); testA11y(); + testOptionGroups(); }); diff --git a/test-navigation-logic.html b/test-navigation-logic.html new file mode 100644 index 000000000000..0832496d2954 --- /dev/null +++ b/test-navigation-logic.html @@ -0,0 +1,147 @@ + + + + + + Test Navigation Logic + + + +

Testing Custom Select Navigation Logic

+ +
+

Option Group Structure (DOM representation):

+
    + +
  • + Option group 1 +
  • + + +
  • + + G1:Option 1 +
  • + +
  • + + G1:Option 2 +
  • + + +
  • + Option group 2 +
  • + + +
  • + + G2:Option 1 +
  • + +
  • + + G2:Option 2 +
  • +
+
+ + + + + +
+ + + + \ No newline at end of file From 23565b284339f0af7c26397c76094d52cb1d0329 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:26:01 +0000 Subject: [PATCH 4/9] Final validation and cleanup - fix confirmed working Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com> --- debug-custom-select.html | 215 -------------- manual-test-navigation.html | 279 ------------------ .../custom-select-option-groups.spec.tsx | 124 -------- test-custom-select-fix.html | 109 ------- test-custom-select-group-fix.html | 160 ---------- test-navigation-logic.html | 147 --------- 6 files changed, 1034 deletions(-) delete mode 100644 debug-custom-select.html delete mode 100644 manual-test-navigation.html delete mode 100644 packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx delete mode 100644 test-custom-select-fix.html delete mode 100644 test-custom-select-group-fix.html delete mode 100644 test-navigation-logic.html diff --git a/debug-custom-select.html b/debug-custom-select.html deleted file mode 100644 index 62c453e747f4..000000000000 --- a/debug-custom-select.html +++ /dev/null @@ -1,215 +0,0 @@ - - - - - - Custom Select Debug Test - - - -

Custom Select Group Navigation Debug

- -
-

Test Instructions:

-
    -
  1. Click on the "Debug" button to show all input elements found
  2. -
  3. Click "Open Select" and then try arrow key navigation
  4. -
  5. Watch the console for debug output
  6. -
-
- -
-

Option Group Example Structure:

-
- Select an option -
-
    - -
  • - Option group 1 -
  • - - -
  • - -
  • - -
  • - -
  • - - -
  • - Option group 2 -
  • - - -
  • - -
  • - -
  • - -
  • -
-
-
-
- - - - - -
- - - - \ No newline at end of file diff --git a/manual-test-navigation.html b/manual-test-navigation.html deleted file mode 100644 index d186cef6a0de..000000000000 --- a/manual-test-navigation.html +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - Custom Select Issue Reproduction - - - -

Custom Select Option Groups - Issue Reproduction

- -
-

Instructions:

-
    -
  1. Click on the select to open it
  2. -
  3. Use Arrow Down/Up keys to navigate
  4. -
  5. Notice if you can reach "G2:Option 1" and "G2:Option 2"
  6. -
  7. Watch the console for debug output
  8. -
-
- -
- - - - - -
-
    - -
  • - Option group 1 -
  • - - -
  • - -
  • - -
  • - -
  • - - -
  • - Option group 2 -
  • - - -
  • - -
  • - -
  • - -
  • -
-
-
- -
- - -
-
- - - - \ No newline at end of file diff --git a/packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx b/packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx deleted file mode 100644 index f547d8850d99..000000000000 --- a/packages/components/src/components/custom-select/custom-select-option-groups.spec.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import AxeBuilder from '@axe-core/playwright'; -import { expect, test } from '@playwright/experimental-ct-react'; - -import { DBCustomSelect } from './index'; -// @ts-ignore - vue can only find it with .ts as file ending -import { DEFAULT_VIEWPORT } from '../../shared/constants.ts'; - -const optionGroupsComp: any = ( - -); - -const optionGroupsMultiple: any = ( - -); - -test.describe('DBCustomSelect - Option Groups Keyboard Navigation', () => { - test.beforeEach(async ({ page }) => { - await page.setViewportSize(DEFAULT_VIEWPORT); - }); - - test('single select: should navigate through all options including those in second group', async ({ page, mount }) => { - const component = await mount(optionGroupsComp); - const summary = component.locator('summary'); - - // Open the dropdown and focus first option - await page.keyboard.press('Tab'); - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(1000); // wait for focus to apply - - // Should be focused on G1:Option 1 - const focused1 = await page.evaluate(() => document.activeElement?.value); - expect(focused1).toBe('G1:Option 1'); - - // Navigate to G1:Option 2 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - const focused2 = await page.evaluate(() => document.activeElement?.value); - expect(focused2).toBe('G1:Option 2'); - - // THIS IS THE 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 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - const focused3 = await page.evaluate(() => document.activeElement?.value); - expect(focused3).toBe('G2:Option 1'); // This is where the bug occurs! - - // Continue to G2:Option 2 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - const focused4 = await page.evaluate(() => document.activeElement?.value); - expect(focused4).toBe('G2:Option 2'); - - // Test reverse navigation - await page.keyboard.press('ArrowUp'); - await page.waitForTimeout(100); - const focused5 = await page.evaluate(() => document.activeElement?.value); - expect(focused5).toBe('G2:Option 1'); - - await page.keyboard.press('ArrowUp'); - await page.waitForTimeout(100); - const focused6 = await page.evaluate(() => document.activeElement?.value); - expect(focused6).toBe('G1:Option 2'); - }); - - test('multiple select: should navigate through all options including those in second group', async ({ page, mount }) => { - const component = await mount(optionGroupsMultiple); - - // Open the dropdown and focus first option - await page.keyboard.press('Tab'); - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(1000); // wait for focus to apply - - // Should be focused on G1:Option 1 - const focused1 = await page.evaluate(() => document.activeElement?.value); - expect(focused1).toBe('G1:Option 1'); - - // Navigate to G1:Option 2 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - const focused2 = await page.evaluate(() => document.activeElement?.value); - expect(focused2).toBe('G1:Option 2'); - - // THIS IS THE CRITICAL TEST: Navigate from G1:Option 2 to G2:Option 1 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - const focused3 = await page.evaluate(() => document.activeElement?.value); - expect(focused3).toBe('G2:Option 1'); // This should work in multiple mode - - // Continue to G2:Option 2 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - const focused4 = await page.evaluate(() => document.activeElement?.value); - expect(focused4).toBe('G2:Option 2'); - }); - - test('should be accessible', async ({ mount }) => { - const component = await mount(optionGroupsComp); - const accessibilityScanResults = await new AxeBuilder({ page }).analyze(); - expect(accessibilityScanResults.violations).toEqual([]); - }); -}); \ No newline at end of file diff --git a/test-custom-select-fix.html b/test-custom-select-fix.html deleted file mode 100644 index c258b5cc6c69..000000000000 --- a/test-custom-select-fix.html +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - Custom Select Option Groups Test - - - - - -

Custom Select Option Groups Keyboard Navigation Test

- -
- Test Instructions: -
    -
  1. Click on the custom select below to open it
  2. -
  3. Use Arrow Down keys to navigate through options
  4. -
  5. Verify you can reach "G2:Option 1" and "G2:Option 2" (options in Option group 2)
  6. -
  7. Use Arrow Up keys to navigate backwards
  8. -
  9. Press Enter or Space to select an option
  10. -
-

Before Fix: Could not navigate to Option group 2 entries

-

After Fix: Should be able to navigate to all options including those in Option group 2

-
- -
-

Single Select with Option Groups

- - -
- -
-

Multi Select with Option Groups (Reference)

- - -
- - - - \ No newline at end of file diff --git a/test-custom-select-group-fix.html b/test-custom-select-group-fix.html deleted file mode 100644 index 9c484e2e996f..000000000000 --- a/test-custom-select-group-fix.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - - Custom Select - Option Groups Keyboard Test - - - - - -
-

Custom Select Keyboard Navigation Test

- -
-

Instructions:

-
    -
  1. Click on the custom select below to open it
  2. -
  3. Try to navigate with Arrow Down/Up keys
  4. -
  5. Notice you can't reach "G2:Option 1" and "G2:Option 2" entries
  6. -
  7. Press Tab key - notice the same issue
  8. -
-
- -
-

ISSUE: Single Select with Option Groups

-

Keyboard navigation cannot reach Option Group 2 entries

- -
- - - - - -
-
    - -
  • - Option group 1 -
  • - - -
  • - -
  • - -
  • - -
  • - - -
  • - Option group 2 -
  • - - -
  • - -
  • - -
  • - -
  • -
-
-
-
- -
-

Expected Behavior: Arrow keys and Tab should be able to navigate to all radio button options, including those in "Option group 2".

-

Current Problem: Keyboard navigation stops after "G1:Option 2" and cannot reach "G2:Option 1" or "G2:Option 2".

-
-
- - - - \ No newline at end of file diff --git a/test-navigation-logic.html b/test-navigation-logic.html deleted file mode 100644 index 0832496d2954..000000000000 --- a/test-navigation-logic.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - Test Navigation Logic - - - -

Testing Custom Select Navigation Logic

- -
-

Option Group Structure (DOM representation):

-
    - -
  • - Option group 1 -
  • - - -
  • - - G1:Option 1 -
  • - -
  • - - G1:Option 2 -
  • - - -
  • - Option group 2 -
  • - - -
  • - - G2:Option 1 -
  • - -
  • - - G2:Option 2 -
  • -
-
- - - - - -
- - - - \ No newline at end of file From 12ccbdb1ff2b823a68b9358c216bd612db95411b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:40:06 +0000 Subject: [PATCH 5/9] Consolidate option groups tests into existing test functions Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com> --- .../custom-select/custom-select.spec.tsx | 96 +++++++++---------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/components/src/components/custom-select/custom-select.spec.tsx b/packages/components/src/components/custom-select/custom-select.spec.tsx index dc459e671466..a0a9ca4e0ce4 100644 --- a/packages/components/src/components/custom-select/custom-select.spec.tsx +++ b/packages/components/src/components/custom-select/custom-select.spec.tsx @@ -80,54 +80,7 @@ const testA11y = () => { expect(accessibilityScanResults.violations).toEqual([]); }); -}; - -const testOptionGroups = () => { - test('option groups single select: should navigate through all options including those in second group', async ({ page, mount }) => { - const component = await mount(optionGroupsComp); - const summary = component.locator('summary'); - - // Open the dropdown and focus first option - await page.keyboard.press('Tab'); - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(1000); // wait for focus to apply - - // 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 page.waitForTimeout(100); - const focused2 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); - expect(focused2).toBe('G1:Option 2'); - - // THIS IS THE 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 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - const focused3 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); - expect(focused3).toBe('G2:Option 1'); // This is where the bug was! - - // Continue to G2:Option 2 - await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(100); - 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 page.waitForTimeout(100); - const focused5 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); - expect(focused5).toBe('G2:Option 1'); - - await page.keyboard.press('ArrowUp'); - await page.waitForTimeout(100); - const focused6 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); - expect(focused6).toBe('G1:Option 2'); - }); - - test('option groups: should be accessible', async ({ page, mount }) => { + test('option groups should be accessible', async ({ page, mount }) => { await mount(optionGroupsComp); const accessibilityScanResults = await new AxeBuilder({ page }) .include('.db-custom-select') @@ -210,12 +163,57 @@ const testAction = () => { await page.keyboard.press('Escape'); await expect(summary).toContainText('Option 1'); }); + + 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 page.waitForTimeout(1000); // wait for focus to apply + + // 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 page.waitForTimeout(100); + 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 page.waitForTimeout(100); + 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 page.waitForTimeout(100); + 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 page.waitForTimeout(100); + const focused5 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + expect(focused5).toBe('G2:Option 1'); + + await page.keyboard.press('ArrowUp'); + await page.waitForTimeout(100); + const focused6 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + expect(focused6).toBe('G1:Option 2'); + }); }; + + test.describe('DBCustomSelect', () => { test.use({ viewport: DEFAULT_VIEWPORT }); testComponent(); testAction(); testA11y(); - testOptionGroups(); }); From 38459196e9570217182ab7d34b3838d1a2346522 Mon Sep 17 00:00:00 2001 From: Maximilian Franzke <787658+mfranzke@users.noreply.github.com> Date: Mon, 8 Sep 2025 08:04:51 +0200 Subject: [PATCH 6/9] Update packages/components/src/components/custom-select/custom-select.spec.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/custom-select/custom-select.spec.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/components/src/components/custom-select/custom-select.spec.tsx b/packages/components/src/components/custom-select/custom-select.spec.tsx index a0a9ca4e0ce4..1d8cb29cade7 100644 --- a/packages/components/src/components/custom-select/custom-select.spec.tsx +++ b/packages/components/src/components/custom-select/custom-select.spec.tsx @@ -209,8 +209,6 @@ const testAction = () => { }); }; - - test.describe('DBCustomSelect', () => { test.use({ viewport: DEFAULT_VIEWPORT }); testComponent(); From 69c1bc963102b071f3799c592156cea4de2b2636 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 8 Sep 2025 06:41:25 +0000 Subject: [PATCH 7/9] test: replace hard-coded timeouts with reliable waitForFocusChange helper function --- .../custom-select/custom-select.spec.tsx | 81 +++++++++++++------ 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/components/src/components/custom-select/custom-select.spec.tsx b/packages/components/src/components/custom-select/custom-select.spec.tsx index 1d8cb29cade7..90209a81f2ed 100644 --- a/packages/components/src/components/custom-select/custom-select.spec.tsx +++ b/packages/components/src/components/custom-select/custom-select.spec.tsx @@ -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) => { + const activeElement = document.activeElement as HTMLInputElement; + return activeElement && activeElement.value === expected; + }, + expectedValue, + { timeout } + ); +}; + const comp: any = ( { 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'); }); @@ -108,7 +124,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'); @@ -131,7 +147,7 @@ const testAction = () => { const summary = component.locator('summary'); 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, Option 2'); @@ -143,7 +159,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 @@ -156,7 +172,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'); // For multiple select, dropdown should remain open after Enter await expect(component.locator('details')).toHaveAttribute('open'); @@ -164,47 +180,62 @@ const testAction = () => { await expect(summary).toContainText('Option 1'); }); - test('option groups keyboard navigation: should navigate between option groups correctly', async ({ page, 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 page.waitForTimeout(1000); // wait for focus to apply - + await waitForFocusChange(page, 'G1:Option 1'); + // Should be focused on G1:Option 1 - const focused1 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + 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 page.waitForTimeout(100); - const focused2 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + 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 page.waitForTimeout(100); - const focused3 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + 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 page.waitForTimeout(100); - const focused4 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + 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 page.waitForTimeout(100); - const focused5 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + 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 page.waitForTimeout(100); - const focused6 = await page.evaluate(() => (document.activeElement as HTMLInputElement)?.value); + await waitForFocusChange(page, 'G1:Option 2'); + const focused6 = await page.evaluate( + () => (document.activeElement as HTMLInputElement)?.value + ); expect(focused6).toBe('G1:Option 2'); }); }; From 7ed7d37077b6384c7c39bf44b5f4e1543549f8bd Mon Sep 17 00:00:00 2001 From: Nicolas Merget Date: Mon, 29 Sep 2025 13:26:08 +0200 Subject: [PATCH 8/9] fix: issue with test in custom-select.spec.tsx --- .../src/components/custom-select/custom-select.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/custom-select/custom-select.spec.tsx b/packages/components/src/components/custom-select/custom-select.spec.tsx index 90209a81f2ed..a71a9d0bf8a2 100644 --- a/packages/components/src/components/custom-select/custom-select.spec.tsx +++ b/packages/components/src/components/custom-select/custom-select.spec.tsx @@ -12,7 +12,7 @@ const waitForFocusChange = async ( timeout = 5000 ) => { await page.waitForFunction( - (expected) => { + (expected: any) => { const activeElement = document.activeElement as HTMLInputElement; return activeElement && activeElement.value === expected; }, @@ -147,7 +147,7 @@ const testAction = () => { const summary = component.locator('summary'); await page.keyboard.press('Tab'); await page.keyboard.press('ArrowDown'); - await waitForFocusChange(page, 'Option 1'); + await page.waitForTimeout(1000); // wait for focus to apply await page.keyboard.press('Space'); await page.keyboard.press('Escape'); await expect(summary).toContainText('Option 1, Option 2'); From ffd1562254e3581b62682c8a11c6d1019f09a960 Mon Sep 17 00:00:00 2001 From: Nicolas Merget Date: Mon, 29 Sep 2025 13:44:56 +0200 Subject: [PATCH 9/9] chore: add changeset --- .changeset/keyboard-navigation-option-groups.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/keyboard-navigation-option-groups.md diff --git a/.changeset/keyboard-navigation-option-groups.md b/.changeset/keyboard-navigation-option-groups.md new file mode 100644 index 000000000000..8f8994ebaf1b --- /dev/null +++ b/.changeset/keyboard-navigation-option-groups.md @@ -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. + +